Five-player briscola with a descending calling auction and a secret partner. Starting with the player after the dealer, each player bids a lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the standing bid is the two the auction continues on points (62, 64, ...). The last player standing must bid, and the winner names any card of the won rank: its holder is the secret partner, or the caller plays alone when it is one of their own cards, and its suit becomes the trump. The calling side must reach 61 (or the raised target); a 60-60 deal with the plain target is a patta and is replayed. A match is a single deal, so the caller and partner (or the lone caller) against the other players map onto the platform's two-team result, Elo and match history. The rules live in tavolo.briscola.chiamata with the BriscolaChiamataEngine adapter registered alongside the other briscola variants; the partner's role and the side totals stay hidden until the called card is played. The web client gets a five-seat table, the auction panel with legal bids and point raises, the call picker, the reveal bands and a chiamata-aware final summary.
This commit is contained in:
@@ -39,7 +39,11 @@ from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import SessionMixin
|
||||
from redis.asyncio import Redis
|
||||
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||
from tavolo.briscola import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||
@@ -144,6 +148,12 @@ registry.register(
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||
log.debug(
|
||||
"game timeouts: hand_ack=%ds turn=%ds",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
Briscola — the Italian trick-taking game — as a
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||
Two variants share one rules core:
|
||||
Three variants share one rules core:
|
||||
|
||||
- `briscola` — the classic two-player game;
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola.
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola;
|
||||
- `briscola_chiamata` — five-player briscola with a calling auction and a
|
||||
secret partner.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -13,27 +15,40 @@ Two variants share one rules core:
|
||||
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||
envelope, no transport, no I/O.
|
||||
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
||||
(trump first, then the led suit; strength ace > three > king > horse >
|
||||
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
||||
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
||||
jack 2), patta re-deals and the target-score match end, deal-end
|
||||
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
||||
logging, so the whole rule set is unit-testable.
|
||||
- `engine.py` — the pure rules engine for two- and four-player briscola:
|
||||
deck, deals, trick resolution (trump first, then the led suit; strength
|
||||
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
|
||||
(trick winner first, the face-up briscola last), card points (ace 11,
|
||||
three 10, king 4, horse 3, jack 2), patta re-deals and the
|
||||
target-score match end, deal-end acknowledgements and auto-play.
|
||||
Deterministic and I/O-free apart from logging, so the whole rule set is
|
||||
unit-testable.
|
||||
- `chiamata.py` — the pure rules engine for five-player briscola
|
||||
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
|
||||
round-trip), the 8-card deal with no stock, the descending calling
|
||||
auction with point raises, the winner's call (including calling one's
|
||||
own card, which means playing alone), the called-card reveal, single-
|
||||
deal scoring with the 60–60 patta and the auto-actions for timeouts.
|
||||
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
||||
(`GameEngine`): the platform-facing adapters. They validate the
|
||||
`target_score` creation option (61–120, default 61) and extract the
|
||||
`MatchResult` (teams, winner, per-player scores and the match summary
|
||||
persisted as the match's JSON `result`).
|
||||
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
|
||||
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
|
||||
The first two validate the `target_score` creation option (61–120,
|
||||
default 61) and extract the `MatchResult` (teams, winner, per-player
|
||||
scores and the match summary persisted as the match's JSON `result`).
|
||||
Briscola chiamata takes no creation options.
|
||||
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||
application composition root.
|
||||
application composition root. Briscola chiamata uses the turn timeout
|
||||
for its auction bids, the called-card choice and the plays; it never
|
||||
enters the between-deals acknowledgement phase (a single deal ends the
|
||||
match), so the hand-ack timeout is accepted but unused.
|
||||
|
||||
## Rules
|
||||
|
||||
### Briscola (two-player and four-player)
|
||||
|
||||
- Three cards per player; the next card is turned face up: its suit is
|
||||
the trump (briscola) for the deal and it is the last card drawn.
|
||||
- The winner of a trick is the highest trump, or the highest card of the
|
||||
@@ -47,6 +62,31 @@ application composition root.
|
||||
- Between deals of an unfinished match every player acknowledges the
|
||||
summary (or the hand-end timeout deals on), exactly like scopone.
|
||||
|
||||
### Briscola chiamata (five-player)
|
||||
|
||||
- The whole 40-card deck is dealt out, eight cards each: there is no
|
||||
stock and no face-up card.
|
||||
- Starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a **lower** rank than the
|
||||
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
|
||||
the first bidder may bid any rank. Once the standing bid is the two,
|
||||
the auction continues on points instead (62, 64, …): the calling side
|
||||
then has to reach the raised target. The last player standing must
|
||||
bid, so a deal always gets a caller.
|
||||
- The auction winner names any card of the winning rank. If the winner
|
||||
holds it, they **play alone** against the other four; otherwise its
|
||||
holder is the **secret partner** and the two form the calling side.
|
||||
The called card's suit is the trump for the deal. The partner's
|
||||
identity is hidden until the called card is played.
|
||||
- Play follows briscola: the player after the dealer leads, the highest
|
||||
trump (or highest led-suit card) takes the trick and leads the next
|
||||
one; eight tricks exhaust every hand.
|
||||
- The calling side (caller plus partner, or the lone caller) must reach
|
||||
the declared target — 61 unless the auction raised it. With the plain
|
||||
target a 60–60 deal is a patta and is replayed with the next dealer.
|
||||
A match is a single deal, so the two sides map onto the platform's
|
||||
two-team result (Elo rating, match history).
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
@@ -54,5 +94,5 @@ application composition root.
|
||||
.venv/bin/python -m mypy -p tavolo.briscola
|
||||
```
|
||||
|
||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||
platform contract (actions, deadlines, serialization, results).
|
||||
`test_engine.py`/`test_plugin.py` cover the two classic variants;
|
||||
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
"""Briscola: tavolo's trick-taking game implementation.
|
||||
|
||||
Two variants share the same rules core and platform adapter:
|
||||
Three variants share the same rules core and platform adapter:
|
||||
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||
briscola (``briscola``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``).
|
||||
fixed-partnership briscola a quattro (``briscola_a_quattro``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
|
||||
briscola chiamata (``briscola_chiamata``).
|
||||
|
||||
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
||||
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
||||
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
|
||||
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
|
||||
adapts them to the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||
game-independent platform can host them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .plugin import BriscolaEngine, BriscolaQuattroEngine
|
||||
from .plugin import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
|
||||
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
|
||||
__all__ = [
|
||||
"BriscolaChiamataEngine",
|
||||
"BriscolaEngine",
|
||||
"BriscolaQuattroEngine",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
"""Pure rules engine for briscola chiamata.
|
||||
|
||||
Briscola chiamata is the five-player variant of briscola. The 40-card
|
||||
deck is dealt out entirely (8 cards each, no stock): there is no face-up
|
||||
briscola card. Instead the players auction off the right to call a
|
||||
partner:
|
||||
|
||||
* starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a rank lower than the standing
|
||||
bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first
|
||||
bidder may bid any rank. Once the standing bid is the 2, further bids
|
||||
raise the points the calling side must reach instead (62, 64, ...).
|
||||
* the last remaining bidder wins and names the exact card of the winning
|
||||
rank (any suit). If the winner holds the named card they play alone
|
||||
against the other four; otherwise its holder is the **secret partner**
|
||||
and the two form the calling side. The called card's suit is the trump
|
||||
(*briscola*) for the whole deal.
|
||||
* play and scoring follow briscola. The calling side must reach the
|
||||
declared target (61 unless raised); a 60-60 deal with the plain target
|
||||
is a **patta** and is replayed.
|
||||
|
||||
A match is a single deal (patta replays aside), so the two sides map
|
||||
onto the platform's two-team result (Elo rating, match history). The
|
||||
partner's identity stays hidden until the called card is played, at
|
||||
which point the roles are revealed.
|
||||
|
||||
Like :mod:`tavolo.briscola.engine` this module is deterministic and
|
||||
I/O-free apart from logging: it mutates (or reads)
|
||||
:class:`~tavolo.briscola.chiamata.ChiamataState` and raises
|
||||
:class:`~tavolo.platform.errors.GameError` subclasses on rule
|
||||
violations. The platform-facing adapter is
|
||||
:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
NotYourTurn,
|
||||
)
|
||||
|
||||
from .engine import TRICK_STRENGTH, card_points, shuffled_deck
|
||||
from .errors import CardNotInHand
|
||||
from .state import (
|
||||
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
SUITS,
|
||||
Card,
|
||||
Trick,
|
||||
TrickCard,
|
||||
parse_card,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Five seats, the whole 40-card deck dealt out.
|
||||
PLAYER_COUNT = 5
|
||||
HAND_SIZE = 8
|
||||
|
||||
PHASE_AUCTION = "auction"
|
||||
PHASE_CALLING = "calling"
|
||||
|
||||
# The two sides of the match: the caller (plus the partner, when there is
|
||||
# one) against the other players.
|
||||
SIDE_CALLER = 0
|
||||
SIDE_DEFENDERS = 1
|
||||
SIDE_NAMES = ("Chiamante", "Difensori")
|
||||
|
||||
# Role labels carried by the personalized view once they are public.
|
||||
ROLE_CALLER = "Chiamante"
|
||||
ROLE_PARTNER = "Socio"
|
||||
ROLE_DEFENDER = "Difensori"
|
||||
|
||||
# Points the calling side must reach: 61 unless the auction raised it.
|
||||
DEFAULT_TARGET = 61
|
||||
MIN_TARGET = 61
|
||||
MAX_TARGET = 120
|
||||
|
||||
# Rank ladder of the auction, strongest first: ace, three, king, horse,
|
||||
# jack, then the plain ranks downwards.
|
||||
BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)
|
||||
_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)}
|
||||
|
||||
_rng = random.SystemRandom()
|
||||
|
||||
|
||||
# -- state -----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataPlayer:
|
||||
sub: str
|
||||
name: str
|
||||
seat: int
|
||||
hand: List[Card] = field(default_factory=list)
|
||||
captured: List[Card] = field(default_factory=list)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sub": self.sub,
|
||||
"name": self.name,
|
||||
"seat": self.seat,
|
||||
"hand": [c.to_json() for c in self.hand],
|
||||
"captured": [c.to_json() for c in self.captured],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ChiamataPlayer":
|
||||
return ChiamataPlayer(
|
||||
sub=str(data["sub"]),
|
||||
name=str(data["name"]),
|
||||
seat=int(data["seat"]),
|
||||
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
||||
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bid:
|
||||
"""One auction bid: a rank, or (at the 2) a raised points target."""
|
||||
|
||||
seat: int
|
||||
rank: int
|
||||
points: int
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {"seat": self.seat, "rank": self.rank, "points": self.points}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Bid":
|
||||
return Bid(
|
||||
seat=int(data["seat"]),
|
||||
rank=int(data["rank"]),
|
||||
points=int(data["points"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Auction:
|
||||
"""The state of the calling auction."""
|
||||
|
||||
# Seat to act; always one of ``active``.
|
||||
turn: int = 0
|
||||
active: List[int] = field(default_factory=list)
|
||||
passed: List[int] = field(default_factory=list)
|
||||
current: Optional[Bid] = None
|
||||
history: List[Bid] = field(default_factory=list)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"turn": self.turn,
|
||||
"active": list(self.active),
|
||||
"passed": list(self.passed),
|
||||
"current": self.current.to_json() if self.current else None,
|
||||
"history": [b.to_json() for b in self.history],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Auction":
|
||||
return Auction(
|
||||
turn=int(data.get("turn", 0)),
|
||||
active=[int(s) for s in data.get("active", [])],
|
||||
passed=[int(s) for s in data.get("passed", [])],
|
||||
current=(
|
||||
Bid.from_json(data["current"]) if data.get("current") else None
|
||||
),
|
||||
history=[Bid.from_json(b) for b in data.get("history", [])],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataState:
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[ChiamataPlayer] = field(default_factory=list)
|
||||
dealer: int = 0
|
||||
# 1-based deal counter; grows with patta replays.
|
||||
hand_number: int = 1
|
||||
# 0 = the calling side (caller plus partner, when there is one),
|
||||
# 1 = the defenders. Only set once the match is finished.
|
||||
winner: Optional[int] = None
|
||||
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||
# The calling auction of the current deal.
|
||||
auction: Optional[Auction] = None
|
||||
# Seat that won the auction (public from then on).
|
||||
caller: Optional[int] = None
|
||||
# The card named by the caller; its suit is the trump.
|
||||
called_card: Optional[Card] = None
|
||||
briscola_suit: str = ""
|
||||
# Points target from the auction (61 unless raised).
|
||||
declared_target: int = DEFAULT_TARGET
|
||||
# True when the caller named a card of their own hand: no partner.
|
||||
solo: bool = False
|
||||
# True once the called card has been played; roles become public.
|
||||
called_played: bool = False
|
||||
# The partner's seat, recorded when the called card is played; stays
|
||||
# ``None`` for a solo call.
|
||||
partner_seat: Optional[int] = None
|
||||
# Cards of the trick in progress, in play order.
|
||||
trick: List[TrickCard] = field(default_factory=list)
|
||||
trick_leader: int = 0
|
||||
last_trick: Optional[Trick] = None
|
||||
# Seat currently to act (auction bidder, caller, or player on turn).
|
||||
turn: int = 0
|
||||
# Per-deal audit trail, patta entries included.
|
||||
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||
# While phase == "auction"/"playing": when the server acts for the
|
||||
# player on turn.
|
||||
turn_deadline: Optional[str] = None
|
||||
auction_deadline: Optional[str] = None
|
||||
# While phase == "calling": when the server names a card for the
|
||||
# auction winner.
|
||||
call_deadline: Optional[str] = None
|
||||
# Seconds a player has to act before the server acts for them.
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"dealer": self.dealer,
|
||||
"hand_number": self.hand_number,
|
||||
"winner": self.winner,
|
||||
"scores": list(self.scores),
|
||||
"auction": self.auction.to_json() if self.auction else None,
|
||||
"caller": self.caller,
|
||||
"called_card": (
|
||||
self.called_card.to_json() if self.called_card else None
|
||||
),
|
||||
"briscola_suit": self.briscola_suit,
|
||||
"declared_target": self.declared_target,
|
||||
"solo": self.solo,
|
||||
"called_played": self.called_played,
|
||||
"partner_seat": self.partner_seat,
|
||||
"trick": [t.to_json() for t in self.trick],
|
||||
"trick_leader": self.trick_leader,
|
||||
"last_trick": self.last_trick.to_json() if self.last_trick else None,
|
||||
"turn": self.turn,
|
||||
"hand_scores": list(self.hand_scores),
|
||||
"turn_deadline": self.turn_deadline,
|
||||
"auction_deadline": self.auction_deadline,
|
||||
"call_deadline": self.call_deadline,
|
||||
"turn_timeout": self.turn_timeout,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ChiamataState":
|
||||
return ChiamataState(
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[
|
||||
ChiamataPlayer.from_json(p) for p in data.get("players", [])
|
||||
],
|
||||
dealer=int(data.get("dealer", 0)),
|
||||
hand_number=int(data.get("hand_number", 1)),
|
||||
winner=data.get("winner"),
|
||||
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||
auction=(
|
||||
Auction.from_json(data["auction"]) if data.get("auction") else None
|
||||
),
|
||||
caller=data.get("caller"),
|
||||
called_card=(
|
||||
Card.from_json(data["called_card"])
|
||||
if data.get("called_card")
|
||||
else None
|
||||
),
|
||||
briscola_suit=str(data.get("briscola_suit", "")),
|
||||
declared_target=int(data.get("declared_target", DEFAULT_TARGET)),
|
||||
solo=bool(data.get("solo", False)),
|
||||
called_played=bool(data.get("called_played", False)),
|
||||
partner_seat=data.get("partner_seat"),
|
||||
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
|
||||
trick_leader=int(data.get("trick_leader", 0)),
|
||||
last_trick=(
|
||||
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
|
||||
),
|
||||
turn=int(data.get("turn", 0)),
|
||||
hand_scores=list(data.get("hand_scores", [])),
|
||||
turn_deadline=data.get("turn_deadline"),
|
||||
auction_deadline=data.get("auction_deadline"),
|
||||
call_deadline=data.get("call_deadline"),
|
||||
turn_timeout=int(
|
||||
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
|
||||
),
|
||||
)
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]:
|
||||
for player in self.players:
|
||||
if player.sub == sub:
|
||||
return player
|
||||
return None
|
||||
|
||||
def seated(self, sub: str) -> bool:
|
||||
return self.player_for(sub) is not None
|
||||
|
||||
|
||||
# -- game setup --------------------------------------------------------------
|
||||
|
||||
|
||||
def create_game(
|
||||
creator_sub: str,
|
||||
creator_name: str,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
) -> ChiamataState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
return ChiamataState(
|
||||
phase=PHASE_LOBBY,
|
||||
players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)],
|
||||
turn_timeout=turn_timeout,
|
||||
)
|
||||
|
||||
|
||||
def join_game(state: ChiamataState, sub: str, name: str) -> None:
|
||||
"""Seat ``sub`` in the next free chair, starting the match when full."""
|
||||
if state.phase != PHASE_LOBBY:
|
||||
raise GameNotStarted("game has already started")
|
||||
if state.seated(sub):
|
||||
raise AlreadyJoined("already joined this game")
|
||||
if len(state.players) >= PLAYER_COUNT:
|
||||
raise LobbyFull("game is full")
|
||||
seat = len(state.players)
|
||||
state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat))
|
||||
if len(state.players) == PLAYER_COUNT:
|
||||
start_game(state)
|
||||
|
||||
|
||||
def start_game(state: ChiamataState) -> None:
|
||||
"""Deal the first hand and open the auction."""
|
||||
if len(state.players) != PLAYER_COUNT:
|
||||
raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start")
|
||||
_deal(state)
|
||||
|
||||
|
||||
def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise IllegalMove(f"no player in seat {seat}")
|
||||
|
||||
|
||||
def _set_deadline(seconds: int) -> str:
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
||||
return deadline.isoformat()
|
||||
|
||||
|
||||
def _set_auction_deadline(state: ChiamataState) -> None:
|
||||
state.auction_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_call_deadline(state: ChiamataState) -> None:
|
||||
state.call_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_turn_deadline(state: ChiamataState) -> None:
|
||||
state.turn_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _deal(state: ChiamataState) -> None:
|
||||
"""Deal 8 cards each and open the auction."""
|
||||
deck = shuffled_deck()
|
||||
for player in state.players:
|
||||
player.hand = []
|
||||
player.captured = []
|
||||
state.trick = []
|
||||
state.last_trick = None
|
||||
state.turn_deadline = None
|
||||
state.call_deadline = None
|
||||
state.called_card = None
|
||||
state.briscola_suit = ""
|
||||
state.declared_target = DEFAULT_TARGET
|
||||
state.solo = False
|
||||
state.called_played = False
|
||||
state.partner_seat = None
|
||||
state.caller = None
|
||||
state.winner = None
|
||||
state.scores = [0, 0]
|
||||
# The whole deck is dealt, one card at a time, starting with the
|
||||
# player after the dealer.
|
||||
for _ in range(HAND_SIZE):
|
||||
for offset in range(PLAYER_COUNT):
|
||||
_player_at(
|
||||
state, (state.dealer + 1 + offset) % PLAYER_COUNT
|
||||
).hand.append(deck.pop())
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.auction = Auction(
|
||||
turn=first,
|
||||
active=[player.seat for player in state.players],
|
||||
)
|
||||
state.phase = PHASE_AUCTION
|
||||
_set_auction_deadline(state)
|
||||
log.debug(
|
||||
"deal %d dealt (dealer seat %d), auction opens with seat %d",
|
||||
state.hand_number,
|
||||
state.dealer,
|
||||
first,
|
||||
)
|
||||
|
||||
|
||||
# -- auction -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_auction(
|
||||
state: ChiamataState, sub: str
|
||||
) -> Tuple[Auction, ChiamataPlayer]:
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = state.player_for(sub)
|
||||
if player is None:
|
||||
raise NotYourTurn("you are not seated in this game")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - auction always exists here
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if auction.turn != player.seat:
|
||||
raise NotYourTurn("it is not your turn to bid")
|
||||
return auction, player
|
||||
|
||||
|
||||
def legal_bid_ranks(state: ChiamataState) -> List[int]:
|
||||
"""The ranks the player on turn may bid, empty at the point stage."""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return []
|
||||
current = auction.current
|
||||
if current is None:
|
||||
return list(BID_LADDER)
|
||||
if current.rank == 2:
|
||||
return []
|
||||
index = _BID_INDEX[current.rank]
|
||||
return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index]
|
||||
|
||||
|
||||
def legal_bid_points(state: ChiamataState) -> Optional[int]:
|
||||
"""The lowest legal points raise, ``None`` when there is none.
|
||||
|
||||
Points may only be raised once the standing bid is the 2; any value
|
||||
higher than the standing bid (up to :data:`MAX_TARGET`) is accepted.
|
||||
"""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return None
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
return None
|
||||
if current.points >= MAX_TARGET:
|
||||
return None
|
||||
return current.points + 2
|
||||
|
||||
|
||||
def _finish_auction(state: ChiamataState) -> None:
|
||||
"""The last active bidder wins and must name the called card."""
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if len(auction.active) != 1 or auction.current is None: # pragma: no cover
|
||||
raise IllegalMove("the auction cannot be concluded yet")
|
||||
winner = auction.active[0]
|
||||
state.caller = winner
|
||||
state.declared_target = auction.current.points
|
||||
state.turn = winner
|
||||
state.phase = PHASE_CALLING
|
||||
state.auction_deadline = None
|
||||
_set_call_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d wins the auction (rank %d, target %d)",
|
||||
state.hand_number,
|
||||
winner,
|
||||
auction.current.rank,
|
||||
auction.current.points,
|
||||
)
|
||||
|
||||
|
||||
def _advance_auction(state: ChiamataState) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
return
|
||||
for offset in range(1, PLAYER_COUNT + 1):
|
||||
seat = (auction.turn + offset) % PLAYER_COUNT
|
||||
if seat in auction.active:
|
||||
auction.turn = seat
|
||||
break
|
||||
state.turn = auction.turn
|
||||
_set_auction_deadline(state)
|
||||
|
||||
|
||||
def _apply_bid(state: ChiamataState, bid: Bid) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction.current = bid
|
||||
auction.history.append(bid)
|
||||
log.debug(
|
||||
"deal %d: seat %d bids rank %d at %d",
|
||||
state.hand_number,
|
||||
bid.seat,
|
||||
bid.rank,
|
||||
bid.points,
|
||||
)
|
||||
if len(auction.active) == 1:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
def bid_rank(state: ChiamataState, sub: str, rank: int) -> None:
|
||||
"""Bid ``rank``; it must be weaker than the standing bid."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if rank not in _BID_INDEX:
|
||||
raise IllegalMove(f"invalid rank: {rank!r}")
|
||||
current = auction.current
|
||||
if current is not None:
|
||||
if current.rank == 2:
|
||||
raise IllegalMove("the auction is at the 2: raise the points or pass")
|
||||
if _BID_INDEX[rank] <= _BID_INDEX[current.rank]:
|
||||
raise IllegalMove("a bid must call a lower rank than the standing bid")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET))
|
||||
|
||||
|
||||
def bid_points(state: ChiamataState, sub: str, points: int) -> None:
|
||||
"""Raise the points target; only legal once the standing bid is the 2."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
raise IllegalMove("points can only be raised after a bid of the 2")
|
||||
if points <= current.points:
|
||||
raise IllegalMove(
|
||||
f"the points must be higher than the standing bid ({current.points})"
|
||||
)
|
||||
if points > MAX_TARGET:
|
||||
raise IllegalMove(f"the points must be at most {MAX_TARGET}")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=2, points=points))
|
||||
|
||||
|
||||
def pass_auction(state: ChiamataState, sub: str) -> None:
|
||||
"""Leave the auction for good."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if player.seat not in auction.active: # pragma: no cover - turn implies active
|
||||
raise IllegalMove("you already passed")
|
||||
if len(auction.active) == 1 and auction.current is None:
|
||||
# Everyone else passed before any bid: the last player must bid.
|
||||
raise IllegalMove("you must bid: everyone else has passed")
|
||||
auction.active.remove(player.seat)
|
||||
auction.passed.append(player.seat)
|
||||
log.debug("deal %d: seat %d passes", state.hand_number, player.seat)
|
||||
if len(auction.active) == 1 and auction.current is not None:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
# -- calling -------------------------------------------------------------------
|
||||
|
||||
|
||||
def call(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||
"""The auction winner names the called card (any suit of the bid rank).
|
||||
|
||||
If the caller holds the named card they play alone; the card's suit
|
||||
becomes the trump for the deal.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = None if state.caller is None else _player_at(state, state.caller)
|
||||
player = state.player_for(sub)
|
||||
if player is None or caller is None or player.seat != caller.seat:
|
||||
raise NotYourTurn("only the auction winner calls the card")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
card = parse_card(card_code)
|
||||
if card.rank != auction.current.rank:
|
||||
raise IllegalMove(
|
||||
"the called card must match the rank won in the auction"
|
||||
)
|
||||
state.called_card = card
|
||||
state.briscola_suit = card.suit
|
||||
state.solo = card in caller.hand
|
||||
state.phase = PHASE_PLAYING
|
||||
# The player after the dealer opens the play, whichever seat won the
|
||||
# auction.
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.call_deadline = None
|
||||
_set_turn_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d calls %s%s",
|
||||
state.hand_number,
|
||||
caller.seat,
|
||||
card.code,
|
||||
" (playing alone)" if state.solo else "",
|
||||
)
|
||||
|
||||
|
||||
# -- play ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def play(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||
"""Apply one move by the player identified by ``sub``.
|
||||
|
||||
Any card in hand is a legal play; only turn order and phase are
|
||||
validated. Raises a :class:`~tavolo.platform.errors.GameError`
|
||||
subclass on any violation.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase == PHASE_AUCTION:
|
||||
raise IllegalMove("the auction is still open")
|
||||
if state.phase == PHASE_CALLING:
|
||||
raise IllegalMove("the called card has not been named yet")
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
|
||||
player = state.player_for(sub)
|
||||
if player is None or player.seat != state.turn:
|
||||
raise NotYourTurn("it is not your turn")
|
||||
|
||||
card = parse_card(card_code)
|
||||
if card not in player.hand:
|
||||
raise CardNotInHand(f"card {card.code} is not in your hand")
|
||||
|
||||
player.hand.remove(card)
|
||||
if state.called_card is not None and card == state.called_card:
|
||||
state.called_played = True
|
||||
state.partner_seat = (
|
||||
None if player.seat == state.caller else player.seat
|
||||
)
|
||||
log.info(
|
||||
"deal %d: the called card %s was played by seat %d (%s)",
|
||||
state.hand_number,
|
||||
card.code,
|
||||
player.seat,
|
||||
"the caller plays alone" if state.partner_seat is None else "partner revealed",
|
||||
)
|
||||
state.trick.append(TrickCard(seat=player.seat, card=card.code))
|
||||
log.debug(
|
||||
"deal %d: seat %d played %s (%d/%d in the trick)",
|
||||
state.hand_number,
|
||||
player.seat,
|
||||
card.code,
|
||||
len(state.trick),
|
||||
PLAYER_COUNT,
|
||||
)
|
||||
|
||||
if len(state.trick) == PLAYER_COUNT:
|
||||
_resolve_trick(state)
|
||||
else:
|
||||
state.turn = (state.turn + 1) % PLAYER_COUNT
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _resolve_trick(state: ChiamataState) -> None:
|
||||
"""Score the completed trick, draw no cards (deck already exhausted)."""
|
||||
winner = _trick_winner(state)
|
||||
taken = [parse_card(t.card) for t in state.trick]
|
||||
_player_at(state, winner).captured.extend(taken)
|
||||
state.last_trick = Trick(winner=winner, cards=list(state.trick))
|
||||
state.trick = []
|
||||
state.trick_leader = winner
|
||||
state.turn = winner
|
||||
log.debug(
|
||||
"deal %d: seat %d takes the trick with %s",
|
||||
state.hand_number,
|
||||
winner,
|
||||
[t.card for t in state.last_trick.cards],
|
||||
)
|
||||
|
||||
if all(not player.hand for player in state.players):
|
||||
_end_deal(state)
|
||||
return
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _trick_winner(state: ChiamataState) -> int:
|
||||
"""The seat winning the current (complete) trick."""
|
||||
played = [(t.seat, parse_card(t.card)) for t in state.trick]
|
||||
leader_suit = played[0][1].suit
|
||||
trumps = [
|
||||
(seat, card)
|
||||
for seat, card in played
|
||||
if card.suit == state.briscola_suit
|
||||
]
|
||||
pool = trumps or [
|
||||
(seat, card) for seat, card in played if card.suit == leader_suit
|
||||
]
|
||||
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
|
||||
|
||||
|
||||
# -- scoring -------------------------------------------------------------------
|
||||
|
||||
|
||||
def side_points(state: ChiamataState) -> Tuple[int, int]:
|
||||
"""The points captured by the calling side and the defenders.
|
||||
|
||||
The partner's pile only joins the caller's once the partner is known
|
||||
(the called card has been played); until then this is only used to
|
||||
score a finished deal, where the reveal has always happened.
|
||||
"""
|
||||
if state.caller is None:
|
||||
return (0, 0)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_points = 0
|
||||
defender_points = 0
|
||||
for player in state.players:
|
||||
points = sum(card_points(card) for card in player.captured)
|
||||
if player.seat in caller_seats:
|
||||
caller_points += points
|
||||
else:
|
||||
defender_points += points
|
||||
return (caller_points, defender_points)
|
||||
|
||||
|
||||
def _end_deal(state: ChiamataState) -> None:
|
||||
"""Score the deal: finish the match, or replay after a patta.
|
||||
|
||||
A 60-60 deal with the plain 61 target is a patta: its points do not
|
||||
count, the dealer rotates and the deal is replayed (a new auction).
|
||||
Otherwise the calling side wins by reaching the declared target.
|
||||
"""
|
||||
state.turn_deadline = None
|
||||
caller_points, defender_points = side_points(state)
|
||||
patta = (
|
||||
state.declared_target == DEFAULT_TARGET
|
||||
and caller_points == 60
|
||||
and defender_points == 60
|
||||
)
|
||||
caller_name = (
|
||||
_player_at(state, state.caller).name if state.caller is not None else None
|
||||
)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_cards = sum(
|
||||
len(player.captured)
|
||||
for player in state.players
|
||||
if player.seat in caller_seats
|
||||
)
|
||||
entry: Dict[str, Any] = {
|
||||
"deal": state.hand_number,
|
||||
"patta": patta,
|
||||
"points": {"A": caller_points, "B": defender_points},
|
||||
"cards": {"A": caller_cards, "B": 40 - caller_cards},
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": caller_name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
state.hand_scores.append(entry)
|
||||
log.debug(
|
||||
"deal %d scored caller %d - defenders %d%s (target %d)",
|
||||
state.hand_number,
|
||||
caller_points,
|
||||
defender_points,
|
||||
" patta" if patta else "",
|
||||
state.declared_target,
|
||||
)
|
||||
if patta:
|
||||
state.hand_number += 1
|
||||
state.dealer = (state.dealer + 1) % PLAYER_COUNT
|
||||
_deal(state)
|
||||
return
|
||||
|
||||
state.scores = [caller_points, defender_points]
|
||||
state.winner = (
|
||||
SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS
|
||||
)
|
||||
state.phase = PHASE_FINISHED
|
||||
log.info(
|
||||
"match ended, %s win %d-%d",
|
||||
SIDE_NAMES[state.winner],
|
||||
caller_points,
|
||||
defender_points,
|
||||
)
|
||||
|
||||
|
||||
# -- server-side auto actions ---------------------------------------------------
|
||||
|
||||
|
||||
def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Act for the bidder on turn: bid a random legal rank, else pass.
|
||||
|
||||
The only player who cannot afford to pass — the last one standing
|
||||
before any bid — always has every rank available, so a bid is always
|
||||
possible there.
|
||||
"""
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = _player_at(state, auction.turn)
|
||||
ranks = legal_bid_ranks(state)
|
||||
if ranks:
|
||||
chooser = rng or _rng
|
||||
bid_rank(state, player.sub, chooser.choice(ranks))
|
||||
return
|
||||
pass_auction(state, player.sub)
|
||||
|
||||
|
||||
def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Name a random suit of the auction rank for the winner."""
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = _player_at(state, state.caller) if state.caller is not None else None
|
||||
if caller is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("no call is waiting")
|
||||
chooser = rng or _rng
|
||||
card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS))
|
||||
call(state, caller.sub, card.code)
|
||||
|
||||
|
||||
def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Play a random card for the player currently on turn."""
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
player = _player_at(state, state.turn)
|
||||
if not player.hand:
|
||||
raise IllegalMove("the player on turn has no cards")
|
||||
chooser = rng or _rng
|
||||
card = chooser.choice(player.hand)
|
||||
play(state, player.sub, card.code)
|
||||
|
||||
|
||||
# -- views ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _roles_public(state: ChiamataState) -> bool:
|
||||
return state.called_played
|
||||
|
||||
|
||||
def state_for_player(
|
||||
state: ChiamataState, sub: Optional[str]
|
||||
) -> Dict[str, object]:
|
||||
"""Serialize ``state`` hiding other players' hands and secret roles.
|
||||
|
||||
Hands are reduced to a count, except for the requesting player's own
|
||||
hand. The partner's role only appears once the called card has been
|
||||
played; before that only the caller is labeled. The calling side's
|
||||
running total is withheld until then too: it would let onlookers
|
||||
deduce the partner's identity from who captured the points.
|
||||
"""
|
||||
viewer = state.player_for(sub)
|
||||
revealed = _roles_public(state)
|
||||
players: List[Dict[str, object]] = []
|
||||
for player in state.players:
|
||||
role = ""
|
||||
if state.caller is not None and player.seat == state.caller:
|
||||
role = ROLE_CALLER
|
||||
if revealed:
|
||||
if state.partner_seat is not None and player.seat == state.partner_seat:
|
||||
role = ROLE_PARTNER
|
||||
elif player.seat != state.caller:
|
||||
role = ROLE_DEFENDER
|
||||
view: Dict[str, object] = {
|
||||
"sub": player.sub,
|
||||
"name": player.name,
|
||||
"seat": player.seat,
|
||||
"team": role,
|
||||
"cards_left": len(player.hand),
|
||||
"captured_count": len(player.captured),
|
||||
"points": sum(card_points(card) for card in player.captured),
|
||||
}
|
||||
if viewer is not None and viewer.seat == player.seat:
|
||||
view["hand"] = [c.code for c in player.hand]
|
||||
players.append(view)
|
||||
|
||||
auction = state.auction
|
||||
auction_view: Optional[Dict[str, object]] = None
|
||||
if auction is not None:
|
||||
auction_view = {
|
||||
"turn": auction.turn,
|
||||
"active": list(auction.active),
|
||||
"passed": list(auction.passed),
|
||||
"current": auction.current.to_json() if auction.current else None,
|
||||
"history": [b.to_json() for b in auction.history],
|
||||
}
|
||||
|
||||
payload: Dict[str, object] = {
|
||||
"phase": state.phase,
|
||||
"player_count": PLAYER_COUNT,
|
||||
"dealer": state.dealer,
|
||||
"hand_number": state.hand_number,
|
||||
"turn": state.turn,
|
||||
"auction": auction_view,
|
||||
"caller": state.caller,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"briscola_suit": state.briscola_suit,
|
||||
"declared_target": state.declared_target,
|
||||
"called_played": state.called_played,
|
||||
"solo": state.solo if revealed else False,
|
||||
"partner": (
|
||||
state.partner_seat if revealed and state.partner_seat is not None else None
|
||||
),
|
||||
"players": players,
|
||||
"trick": [t.to_json() for t in state.trick],
|
||||
"trick_leader": state.trick_leader,
|
||||
"last_trick": state.last_trick.to_json() if state.last_trick else None,
|
||||
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
|
||||
"auction_deadline": state.auction_deadline,
|
||||
"call_deadline": state.call_deadline,
|
||||
"turn_deadline": state.turn_deadline,
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
}
|
||||
if revealed and state.caller is not None:
|
||||
caller_points, defender_points = side_points(state)
|
||||
payload["scores"] = {"A": caller_points, "B": defender_points}
|
||||
|
||||
if viewer is not None:
|
||||
if state.phase == PHASE_AUCTION and auction is not None:
|
||||
if auction.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
payload["legal_bids"] = {
|
||||
"ranks": legal_bid_ranks(state),
|
||||
"points_min": legal_bid_points(state),
|
||||
"points_max": MAX_TARGET,
|
||||
}
|
||||
elif state.phase == PHASE_CALLING and state.caller == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
current = auction.current if auction is not None else None
|
||||
if current is not None:
|
||||
payload["callable_cards"] = [
|
||||
Card(rank=current.rank, suit=suit).code for suit in SUITS
|
||||
]
|
||||
elif state.phase == PHASE_PLAYING and state.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
return payload
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The platform-facing adapter for briscola.
|
||||
|
||||
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
||||
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
||||
:class:`BriscolaEngine` and four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
||||
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
|
||||
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
|
||||
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
|
||||
:class:`BriscolaEngine`, four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine` and five-player
|
||||
:class:`BriscolaChiamataEngine`. They translate platform calls (create,
|
||||
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||
keeping all briscola knowledge inside this package. Nothing outside
|
||||
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
||||
@@ -24,7 +26,18 @@ from tavolo.platform.engine import (
|
||||
)
|
||||
from tavolo.platform.errors import GameError, IllegalMove
|
||||
|
||||
from . import engine
|
||||
from . import chiamata, engine
|
||||
from .chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_NAMES,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
)
|
||||
from .engine import card_points
|
||||
from .state import (
|
||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
DEFAULT_TARGET_SCORE,
|
||||
@@ -277,3 +290,291 @@ class BriscolaQuattroEngine(_BriscolaBase):
|
||||
min_players = 4
|
||||
max_players = 4
|
||||
player_count = 4
|
||||
|
||||
|
||||
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise GameError(f"no player in seat {seat}")
|
||||
|
||||
|
||||
class BriscolaChiamataEngine(GameEngine):
|
||||
"""Five-player briscola chiamata as a platform game engine.
|
||||
|
||||
One deal per match: the five players auction the call, the winner
|
||||
names a card (playing alone when it is in their own hand) and the
|
||||
calling side must reach the declared target (61 unless the auction
|
||||
raised it). A 60-60 deal with the plain target is a patta and is
|
||||
replayed. This keeps the match result a clean two-sided affair —
|
||||
caller and secret partner against the other three, or the lone
|
||||
caller against the other four — which the platform can rate,
|
||||
persist and display like any other game.
|
||||
"""
|
||||
|
||||
id = "briscola_chiamata"
|
||||
name = "Briscola chiamata"
|
||||
description = (
|
||||
"Five-player briscola with a descending auction: the winner calls "
|
||||
"a card, whose holder becomes the secret partner (or, when the "
|
||||
"caller holds it, the caller plays alone) and whose suit becomes "
|
||||
"the trump. The calling side must reach 61 points; one deal per "
|
||||
"match, replayed after a patta."
|
||||
)
|
||||
min_players = 5
|
||||
max_players = 5
|
||||
|
||||
options_schema: Mapping[str, Any] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self._turn_timeout = turn_timeout_seconds
|
||||
# A chiamata match is a single deal, so there is no between-deals
|
||||
# acknowledgement phase to time out; the parameter is accepted
|
||||
# for symmetry with the other briscola engines and wired by the
|
||||
# application root.
|
||||
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||
|
||||
# -- lobby ------------------------------------------------------------
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
creator = session.players[0]
|
||||
session.state = chiamata.create_game(
|
||||
creator_sub=creator.user_sub,
|
||||
creator_name=creator.display_name,
|
||||
turn_timeout=self._turn_timeout,
|
||||
)
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
chiamata.join_game(session.state, user_sub, display_name)
|
||||
session.players.append(
|
||||
Seat(user_sub=user_sub, display_name=display_name)
|
||||
)
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_LOBBY
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
return {
|
||||
"phase": state.phase,
|
||||
"declared_target": state.declared_target,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state: ChiamataState = session.state
|
||||
if action == "bid":
|
||||
rank = payload.get("rank")
|
||||
points = payload.get("points")
|
||||
if rank is not None:
|
||||
if isinstance(rank, bool) or not isinstance(rank, int):
|
||||
raise IllegalMove("'rank' must be an integer")
|
||||
chiamata.bid_rank(state, user_sub, rank)
|
||||
elif points is not None:
|
||||
if isinstance(points, bool) or not isinstance(points, int):
|
||||
raise IllegalMove("'points' must be an integer")
|
||||
chiamata.bid_points(state, user_sub, points)
|
||||
else:
|
||||
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
|
||||
elif action == "pass":
|
||||
chiamata.pass_auction(state, user_sub)
|
||||
elif action == "call":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.call(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
elif action == "play":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.play(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
else:
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
return chiamata.state_for_player(session.state, user_sub)
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_FINISHED
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
caller = (
|
||||
_chiamata_player(state, state.caller) if state.caller is not None else None
|
||||
)
|
||||
partner = (
|
||||
_chiamata_player(state, state.partner_seat)
|
||||
if state.partner_seat is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": None if caller is None else caller.name,
|
||||
"partner": None if partner is None else partner.name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
|
||||
# -- (de)serialization -------------------------------------------------
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
assert isinstance(state, ChiamataState)
|
||||
return state.to_json()
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return ChiamataState.from_json(dict(data))
|
||||
|
||||
# -- deadlines ----------------------------------------------------------
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
state: ChiamataState = session.state
|
||||
if state.phase == PHASE_AUCTION and state.auction_deadline:
|
||||
due_ms = _deadline_ms(state.auction_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="auction",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_CALLING and state.call_deadline:
|
||||
due_ms = _deadline_ms(state.call_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="call",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"call:{state.hand_number}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||
due_ms = _deadline_ms(state.turn_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="turn",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
return None
|
||||
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
current = self.next_deadline(session)
|
||||
if current is None or current.kind != kind or current.token != token:
|
||||
# Overtaken by events (a bid, call or play landed in time):
|
||||
# nothing to do.
|
||||
raise GameError("stale deadline")
|
||||
state: ChiamataState = session.state
|
||||
if kind == "auction":
|
||||
seat = state.turn
|
||||
chiamata.auto_bid(state)
|
||||
log.info(
|
||||
"auto-bid for seat %s (auction timeout, deal %d)",
|
||||
seat,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "call":
|
||||
caller = state.caller
|
||||
chiamata.auto_call(state)
|
||||
log.info(
|
||||
"auto-called for seat %s (call timeout, deal %d)",
|
||||
caller,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "turn":
|
||||
seat = state.turn
|
||||
chiamata.auto_play(state)
|
||||
log.info(
|
||||
"auto-played for seat %s (turn timeout, deal %d)",
|
||||
seat,
|
||||
state.hand_number,
|
||||
)
|
||||
else: # pragma: no cover - next_deadline never emits other kinds
|
||||
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||
|
||||
# -- results -------------------------------------------------------------
|
||||
|
||||
def result(self, session: GameSession) -> MatchResult:
|
||||
state: ChiamataState = session.state
|
||||
if state.winner is None:
|
||||
raise GameError("no result: the match is not finished")
|
||||
if state.caller is None: # pragma: no cover - a winner implies a caller
|
||||
raise GameError("no result: the caller is unknown")
|
||||
caller = state.caller
|
||||
partner = state.partner_seat
|
||||
caller_seats = [caller] + ([partner] if partner is not None else [])
|
||||
teams = [
|
||||
[_chiamata_player(state, seat).sub for seat in caller_seats],
|
||||
[
|
||||
player.sub
|
||||
for player in state.players
|
||||
if player.seat not in caller_seats
|
||||
],
|
||||
]
|
||||
caller_points, defender_points = chiamata.side_points(state)
|
||||
side_scores = [caller_points, defender_points]
|
||||
players = []
|
||||
for player in state.players:
|
||||
if player.seat == caller:
|
||||
role = ROLE_CALLER
|
||||
side = 0
|
||||
elif partner is not None and player.seat == partner:
|
||||
role = ROLE_PARTNER
|
||||
side = 0
|
||||
else:
|
||||
role = ROLE_DEFENDER
|
||||
side = 1
|
||||
players.append(
|
||||
PlayerResult(
|
||||
user_sub=player.sub,
|
||||
seat=player.seat,
|
||||
won=side == state.winner,
|
||||
team=role,
|
||||
score=float(side_scores[side]),
|
||||
details={
|
||||
"points": sum(card_points(card) for card in player.captured),
|
||||
"captured_cards": len(player.captured),
|
||||
},
|
||||
)
|
||||
)
|
||||
return MatchResult(
|
||||
teams=teams,
|
||||
winner_team=state.winner,
|
||||
players=players,
|
||||
summary={
|
||||
"team_a_score": caller_points,
|
||||
"team_b_score": defender_points,
|
||||
"winner_team": SIDE_NAMES[state.winner],
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": _chiamata_player(state, caller).name,
|
||||
"partner": (
|
||||
_chiamata_player(state, partner).name
|
||||
if partner is not None
|
||||
else None
|
||||
),
|
||||
"solo": state.solo,
|
||||
"deals_played": state.hand_number,
|
||||
"deal_scores": state.hand_scores,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
BID_LADDER,
|
||||
Bid,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_CALLER,
|
||||
SIDE_DEFENDERS,
|
||||
)
|
||||
from tavolo.briscola.errors import (
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
|
||||
|
||||
def card(code: str) -> Card:
|
||||
return Card.parse(code)
|
||||
|
||||
|
||||
def started_state() -> ChiamataState:
|
||||
"""A five-player game whose auction has just opened (dealer seat 0)."""
|
||||
state = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
return state
|
||||
|
||||
|
||||
def bid(state: ChiamataState, seat: int, rank: int) -> None:
|
||||
rules.bid_rank(state, f"p{seat}", rank)
|
||||
|
||||
|
||||
def make_state(
|
||||
hands,
|
||||
*,
|
||||
briscola_suit: str = "S",
|
||||
called_card: str | None = None,
|
||||
caller: int | None = None,
|
||||
solo: bool = False,
|
||||
called_played: bool = False,
|
||||
partner_seat: int | None = None,
|
||||
captured=None,
|
||||
turn: int = 0,
|
||||
dealer: int = 0,
|
||||
declared_target: int = rules.DEFAULT_TARGET,
|
||||
phase: str = PHASE_PLAYING,
|
||||
hand_number: int = 1,
|
||||
) -> ChiamataState:
|
||||
"""Build a controlled playing state directly (bypassing the auction)."""
|
||||
state = ChiamataState(
|
||||
phase=phase,
|
||||
dealer=dealer,
|
||||
hand_number=hand_number,
|
||||
declared_target=declared_target,
|
||||
briscola_suit=briscola_suit,
|
||||
called_card=card(called_card) if called_card else None,
|
||||
caller=caller,
|
||||
solo=solo,
|
||||
called_played=called_played,
|
||||
partner_seat=partner_seat,
|
||||
turn=turn,
|
||||
)
|
||||
for seat, hand in enumerate(hands):
|
||||
state.players.append(
|
||||
ChiamataPlayer(
|
||||
sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||
hand=[card(c) for c in hand],
|
||||
)
|
||||
)
|
||||
if captured is not None:
|
||||
for player, codes in zip(state.players, captured):
|
||||
player.captured = [card(c) for c in codes]
|
||||
return state
|
||||
|
||||
|
||||
def play_all(state: ChiamataState) -> None:
|
||||
"""Play the first card of each hand in turn order until the trick resolves."""
|
||||
for _ in range(rules.PLAYER_COUNT):
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
rules.play(state, player.sub, player.hand[0].code)
|
||||
|
||||
|
||||
class SetupTest(unittest.TestCase):
|
||||
def test_five_player_deal(self) -> None:
|
||||
state = started_state()
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
|
||||
for player in state.players:
|
||||
self.assertEqual(rules.HAND_SIZE, len(player.hand))
|
||||
# The whole deck is dealt: no stock, no face-up card.
|
||||
dealt = [c.code for p in state.players for c in p.hand]
|
||||
self.assertEqual(40, len(dealt))
|
||||
self.assertEqual(40, len(set(dealt)))
|
||||
# The auction opens with the player after the dealer.
|
||||
assert state.auction is not None
|
||||
self.assertEqual(1, state.auction.turn)
|
||||
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
|
||||
self.assertIsNone(state.auction.current)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
|
||||
|
||||
state = rules.create_game("p0", "p0")
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
rules.join_game(state, "p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
with self.assertRaises(GameNotStarted):
|
||||
rules.join_game(state, "p5", "p5")
|
||||
|
||||
lobby = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(lobby, f"p{i}", f"p{i}")
|
||||
# Joining a sixth player is impossible once the fifth started the
|
||||
# match; exercise LobbyFull on an artificially unfilled start.
|
||||
unfilled = rules.create_game("p0", "p0")
|
||||
unfilled.phase = rules.PHASE_LOBBY
|
||||
for i in range(1, 5):
|
||||
unfilled.players.append(
|
||||
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
|
||||
)
|
||||
with self.assertRaises(LobbyFull):
|
||||
rules.join_game(unfilled, "p5", "p5")
|
||||
|
||||
|
||||
class AuctionTest(unittest.TestCase):
|
||||
def test_first_bid_accepts_any_rank(self) -> None:
|
||||
state = started_state()
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
|
||||
|
||||
def test_bids_must_descend(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 2, 1) # same rank
|
||||
bid(state, 2, 3) # three, weaker than the ace
|
||||
bid(state, 3, 10) # king, weaker than the three
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 4, 3) # three is stronger than the king
|
||||
bid(state, 4, 9) # horse
|
||||
|
||||
def test_bid_ladder_ordering(self) -> None:
|
||||
# A bid must be weaker than the standing bid: descending strength
|
||||
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
|
||||
rules.bid_rank(state, f"p{opponent}", rank)
|
||||
# 3 and A are stronger than 6: rejected.
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 3)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 1)
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
|
||||
def test_not_your_turn(self) -> None:
|
||||
state = started_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
bid(state, 2, 1)
|
||||
|
||||
def test_pass_eliminates_and_last_standing_bids(self) -> None:
|
||||
state = started_state()
|
||||
for seat in (1, 2, 3, 4):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
assert state.auction is not None
|
||||
# Only seat 0 remains and nobody has bid: it must bid.
|
||||
self.assertEqual([0], state.auction.active)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.bid_rank(state, "p0", 2)
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(0, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_auction_ends_when_one_bidder_remains(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
rules.pass_auction(state, "p2")
|
||||
bid(state, 3, 10) # king
|
||||
rules.pass_auction(state, "p4")
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.pass_auction(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(3, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_point_raises_only_after_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p2", 62)
|
||||
bid(state, 2, 2)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p3", 2) # no ranks below the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 61) # not higher
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 121) # above the cap
|
||||
rules.bid_points(state, "p3", 63)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
|
||||
|
||||
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertTrue(view["your_turn"])
|
||||
legal = view["legal_bids"]
|
||||
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
|
||||
self.assertIsNone(legal["points_min"])
|
||||
# A player who is not on turn gets no legal bids.
|
||||
other = rules.state_for_player(state, "p3")
|
||||
self.assertNotIn("legal_bids", other)
|
||||
self.assertNotIn("your_turn", other)
|
||||
|
||||
def test_legal_points_exposed_at_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 2)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertEqual([], view["legal_bids"]["ranks"])
|
||||
self.assertEqual(63, view["legal_bids"]["points_min"])
|
||||
self.assertEqual(120, view["legal_bids"]["points_max"])
|
||||
|
||||
def test_auto_bid_bids_or_passes(self) -> None:
|
||||
state = started_state()
|
||||
rules.auto_bid(state, random.Random(1))
|
||||
assert state.auction is not None
|
||||
self.assertIsNotNone(state.auction.current)
|
||||
# The auction keeps moving and eventually concludes.
|
||||
for _ in range(50):
|
||||
if state.phase != PHASE_AUCTION:
|
||||
break
|
||||
rules.auto_bid(state, random.Random(2))
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertIsNotNone(state.caller)
|
||||
|
||||
|
||||
class CallTest(unittest.TestCase):
|
||||
def _called_state(self, rank: int = 1) -> ChiamataState:
|
||||
state = started_state()
|
||||
bid(state, 1, rank)
|
||||
for seat in (2, 3, 4, 0):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
return state
|
||||
|
||||
def test_call_must_match_the_winning_rank(self) -> None:
|
||||
state = self._called_state(1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.call(state, "p1", "03D")
|
||||
# Any suit of the winning rank is fine.
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual("D", state.briscola_suit)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_only_the_winner_calls(self) -> None:
|
||||
state = self._called_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.call(state, "p2", "01D")
|
||||
|
||||
def test_call_in_hand_means_playing_alone(self) -> None:
|
||||
state = self._called_state(1)
|
||||
# Seat 1 calls a card it holds: solo.
|
||||
state.players[1].hand[0] = card("01S")
|
||||
rules.call(state, "p1", "01S")
|
||||
self.assertTrue(state.solo)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
|
||||
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
|
||||
state = self._called_state(1)
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertNotEqual(state.caller, None)
|
||||
|
||||
def test_auto_call_names_the_bid_rank(self) -> None:
|
||||
state = self._called_state(10)
|
||||
rules.auto_call(state, random.Random(3))
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(10, state.called_card.rank)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_calling_phase_view(self) -> None:
|
||||
state = self._called_state(1)
|
||||
view = rules.state_for_player(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, view["phase"])
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertEqual(
|
||||
["01D", "01C", "01S", "01B"], view["callable_cards"]
|
||||
)
|
||||
other = rules.state_for_player(state, "p2")
|
||||
self.assertNotIn("callable_cards", other)
|
||||
|
||||
|
||||
class PlayTest(unittest.TestCase):
|
||||
def test_five_player_trick_resolution(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_trump_wins(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
|
||||
briscola_suit="C",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_called_card_reveals_the_partner(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
turn=1,
|
||||
)
|
||||
self.assertFalse(state.called_played)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertNotIn("scores", view)
|
||||
rules.play(state, "p1", "01D")
|
||||
# The called card has not been played yet.
|
||||
self.assertFalse(state.called_played)
|
||||
# Drive the trick until the called card appears: give it to seat 3.
|
||||
state.players[3].hand = [card("01S")]
|
||||
state.turn = 3
|
||||
rules.play(state, "p3", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertEqual(3, state.partner_seat)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_PARTNER, roles[3])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
self.assertIn("scores", view)
|
||||
|
||||
def test_called_card_played_by_the_caller_is_solo(self) -> None:
|
||||
state = make_state(
|
||||
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
solo=True,
|
||||
turn=0,
|
||||
)
|
||||
rules.play(state, "p0", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
view = rules.state_for_player(state, "p4")
|
||||
self.assertTrue(view["solo"])
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
|
||||
def test_play_validations(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
turn=0,
|
||||
)
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.play(state, "p1", "05D")
|
||||
with self.assertRaises(CardNotInHand):
|
||||
rules.play(state, "p0", "01D")
|
||||
state.phase = PHASE_FINISHED
|
||||
with self.assertRaises(GameFinished):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_play_rejected_before_the_call(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
caller=0,
|
||||
turn=0,
|
||||
phase=PHASE_CALLING,
|
||||
)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_auto_play_advances(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="09B",
|
||||
turn=0,
|
||||
)
|
||||
rules.auto_play(state, random.Random(7))
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertEqual(1, len(state.players[0].hand))
|
||||
|
||||
|
||||
class ScoringTest(unittest.TestCase):
|
||||
def _piles(self):
|
||||
# 60 points in denari + coppe, 60 in spade + bastoni.
|
||||
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
return a_cards, b_cards
|
||||
|
||||
def test_side_points_split_caller_side_and_defenders(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=3,
|
||||
called_played=True,
|
||||
captured=[
|
||||
["01D"], # caller, side A: 11
|
||||
["03C", "01S"], # defender: 21
|
||||
["10D"], # defender: 4
|
||||
["01C"], # partner, side A: 11
|
||||
["03S"], # defender: 10
|
||||
],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(22, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_solo_side_points(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
solo=True,
|
||||
called_played=True,
|
||||
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(11, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_calling_side_wins_at_the_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
b_rest = [c for c in b_cards if c != "01S"]
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, ["01S"], [], b_rest, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_CALLER, state.winner)
|
||||
self.assertEqual([71, 49], state.scores)
|
||||
|
||||
def test_defenders_win_below_the_declared_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
# Calling side only 30 points of the 61 needed.
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards[:5], [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
self.assertEqual([30, 60], state.scores)
|
||||
|
||||
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(2, state.hand_number)
|
||||
self.assertEqual(1, state.dealer)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(2, state.auction.turn)
|
||||
self.assertIsNone(state.caller)
|
||||
self.assertIsNone(state.called_card)
|
||||
# The patta entry is kept in the audit trail.
|
||||
entry = state.hand_scores[-1]
|
||||
self.assertTrue(entry["patta"])
|
||||
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
||||
|
||||
def test_raised_target_disables_patta(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
declared_target=62,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
|
||||
def test_full_deal_scores_120(self) -> None:
|
||||
state = started_state()
|
||||
rng = random.Random(2026)
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 5000:
|
||||
if state.phase == PHASE_AUCTION:
|
||||
rules.auto_bid(state, rng)
|
||||
elif state.phase == PHASE_CALLING:
|
||||
rules.auto_call(state, rng)
|
||||
else:
|
||||
rules.auto_play(state, rng)
|
||||
moves += 1
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(120, sum(state.scores))
|
||||
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
|
||||
captured = sum(len(p.captured) for p in state.players)
|
||||
self.assertEqual(40, captured)
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_state_for_player_hides_other_hands(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
turn=0,
|
||||
)
|
||||
view = rules.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(["02D", "03C"], players[0]["hand"])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertEqual(1, players[1]["cards_left"])
|
||||
self.assertEqual("01D", view["called_card"])
|
||||
self.assertEqual(5, view["player_count"])
|
||||
|
||||
def test_spectator_sees_public_information_only(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=2,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
)
|
||||
view = rules.state_for_player(state, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[2])
|
||||
self.assertEqual("", roles[0])
|
||||
|
||||
def test_player_points_are_public(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
captured=[["01C", "03B"], ["10D"], [], [], []],
|
||||
)
|
||||
view = rules.state_for_player(state, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertEqual(21, players[0]["points"])
|
||||
self.assertEqual(4, players[1]["points"])
|
||||
self.assertEqual(2, players[0]["captured_count"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10)
|
||||
bid(state, 2, 7)
|
||||
restored = ChiamataState.from_json(state.to_json())
|
||||
self.assertEqual(state.phase, restored.phase)
|
||||
self.assertEqual(state.hand_number, restored.hand_number)
|
||||
self.assertEqual(state.turn, restored.turn)
|
||||
assert restored.auction is not None and state.auction is not None
|
||||
self.assertEqual(state.auction.active, restored.auction.active)
|
||||
self.assertEqual(state.auction.passed, restored.auction.passed)
|
||||
self.assertEqual(
|
||||
state.auction.history, restored.auction.history
|
||||
)
|
||||
self.assertEqual(
|
||||
[[c.code for c in p.hand] for p in state.players],
|
||||
[[c.code for c in p.hand] for p in restored.players],
|
||||
)
|
||||
|
||||
def test_legacy_state_defaults(self) -> None:
|
||||
state = started_state()
|
||||
data = state.to_json()
|
||||
del data["auction"]
|
||||
del data["called_card"]
|
||||
del data["declared_target"]
|
||||
restored = ChiamataState.from_json(data)
|
||||
self.assertIsNone(restored.auction)
|
||||
self.assertIsNone(restored.called_card)
|
||||
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
|
||||
self.assertFalse(restored.solo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Adapter tests: the platform contract over the briscola chiamata rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from tavolo.briscola import BriscolaChiamataEngine
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
ChiamataState,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameError,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
)
|
||||
|
||||
|
||||
def _session(engine, **options) -> GameSession:
|
||||
session = GameSession(
|
||||
id="c1",
|
||||
game_type=engine.id,
|
||||
join_code="CODE01",
|
||||
creator_sub="p0",
|
||||
players=[Seat(user_sub="p0", display_name="P0")],
|
||||
)
|
||||
engine.create(session, options)
|
||||
return session
|
||||
|
||||
|
||||
def _started(engine, **options) -> GameSession:
|
||||
session = _session(engine, **options)
|
||||
for i in range(1, engine.max_players):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
return session
|
||||
|
||||
|
||||
def _player_on_turn(session: GameSession):
|
||||
state = session.state
|
||||
return next(p for p in state.players if p.seat == state.turn)
|
||||
|
||||
|
||||
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < limit:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = _player_on_turn(session)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
# The last player standing must bid.
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(
|
||||
p for p in state.players if p.seat == state.caller
|
||||
)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session), "match did not finish"
|
||||
|
||||
|
||||
class MetadataTest(unittest.TestCase):
|
||||
def test_metadata(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
self.assertEqual("briscola_chiamata", engine.id)
|
||||
self.assertEqual("Briscola chiamata", engine.name)
|
||||
self.assertTrue(engine.description)
|
||||
self.assertEqual(5, engine.min_players)
|
||||
self.assertEqual(5, engine.max_players)
|
||||
self.assertEqual({}, engine.options_schema)
|
||||
|
||||
|
||||
class CreateJoinTest(unittest.TestCase):
|
||||
def test_create_seats_creator_without_a_team(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertIsNone(session.players[0].team)
|
||||
self.assertIsInstance(session.state, ChiamataState)
|
||||
self.assertEqual(PHASE_LOBBY, session.state.phase)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
for i in range(1, 4):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
engine.join(session, "p4", "P4")
|
||||
self.assertFalse(engine.in_lobby(session))
|
||||
self.assertEqual(PHASE_AUCTION, session.state.phase)
|
||||
self.assertEqual(5, len(session.players))
|
||||
for seat in session.players:
|
||||
self.assertIsNone(seat.team)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
engine.join(session, "p0", "P0")
|
||||
for i in range(1, 5):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join(session, "p5", "P5")
|
||||
|
||||
def test_timeouts_come_from_the_engine(self) -> None:
|
||||
engine = BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=7, hand_ack_timeout_seconds=9
|
||||
)
|
||||
session = _session(engine)
|
||||
self.assertEqual(7, session.state.turn_timeout)
|
||||
|
||||
|
||||
class ActionTest(unittest.TestCase):
|
||||
def test_unknown_action_rejected(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "dance", {})
|
||||
|
||||
def test_bid_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": "ace"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": True})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": False})
|
||||
# points cannot be raised before a bid of the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": 64})
|
||||
|
||||
def test_auction_flow_through_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
engine.handle_action(session, "p2", "pass", {})
|
||||
engine.handle_action(session, "p3", "bid", {"rank": 2})
|
||||
engine.handle_action(session, "p4", "bid", {"points": 64})
|
||||
engine.handle_action(session, "p0", "pass", {})
|
||||
engine.handle_action(session, "p1", "pass", {})
|
||||
engine.handle_action(session, "p3", "pass", {})
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(4, state.caller)
|
||||
self.assertEqual(64, state.declared_target)
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
self.assertEqual(2, state.auction.current.rank)
|
||||
|
||||
engine.handle_action(session, "p4", "call", {"card": "02S"})
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual("S", state.briscola_suit)
|
||||
|
||||
# Play the first card of the player on turn.
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
|
||||
def test_call_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": 42})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "nope"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "01D"})
|
||||
|
||||
def test_finished_match_rejects_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
with self.assertRaises(GameError):
|
||||
engine.handle_action(session, session.players[0].user_sub, "play", {})
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_view_hides_other_hands_and_roles(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertIn("hand", players[0])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
self.assertEqual(5, view["player_count"])
|
||||
# p1 opens the auction.
|
||||
auction = view["auction"]
|
||||
self.assertEqual(1, auction["turn"])
|
||||
self.assertIsNone(auction["current"])
|
||||
|
||||
def test_view_marks_the_acting_player(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p1")
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertIn("legal_bids", view)
|
||||
# The caller is labeled as soon as the auction is won.
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
view = engine.view_for(session, "p0")
|
||||
self.assertIn("callable_cards", engine.view_for(session, "p1"))
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[1])
|
||||
|
||||
def test_spectator_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
|
||||
def test_lobby_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
lobby = engine.lobby_view(session)
|
||||
self.assertEqual("lobby", lobby["phase"])
|
||||
self.assertEqual(61, lobby["declared_target"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 10})
|
||||
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||
self.assertIsInstance(restored, ChiamataState)
|
||||
self.assertEqual(session.state.phase, restored.phase)
|
||||
self.assertEqual(session.state.turn, restored.turn)
|
||||
assert restored.auction is not None and session.state.auction is not None
|
||||
self.assertEqual(session.state.auction.history, restored.auction.history)
|
||||
self.assertEqual(
|
||||
[p.sub for p in session.state.players],
|
||||
[p.sub for p in restored.players],
|
||||
)
|
||||
|
||||
|
||||
class DeadlineTest(unittest.TestCase):
|
||||
def test_no_deadline_in_lobby(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||
|
||||
def test_auction_deadline_and_revalidation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("auction", deadline.kind)
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, "auction", "auction:1:1:0")
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
# The auto-bid moved the auction on: a fresh deadline exists.
|
||||
state = session.state
|
||||
self.assertIn(
|
||||
state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING)
|
||||
)
|
||||
self.assertIsNotNone(engine.next_deadline(session))
|
||||
|
||||
def test_stale_auction_deadline_after_a_bid(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
|
||||
def test_call_deadline_auto_calls(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("call", deadline.kind)
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(2, state.called_card.rank)
|
||||
|
||||
def test_turn_deadline_auto_plays(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
engine.handle_action(session, "p1", "call", {"card": "02D"})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("turn", deadline.kind)
|
||||
state = session.state
|
||||
before = state.turn
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertNotEqual(before, state.turn)
|
||||
|
||||
|
||||
class ResultTest(unittest.TestCase):
|
||||
def test_result_of_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
state = session.state
|
||||
result = engine.result(session)
|
||||
sizes = sorted(len(team) for team in result.teams)
|
||||
self.assertIn(sizes, ([2, 3], [1, 4]))
|
||||
self.assertEqual(5, len(result.players))
|
||||
self.assertIn(result.winner_team, (0, 1))
|
||||
for player in result.players:
|
||||
self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER))
|
||||
self.assertEqual(
|
||||
player.won,
|
||||
(player.team in (ROLE_CALLER, ROLE_PARTNER))
|
||||
== (result.winner_team == 0),
|
||||
)
|
||||
self.assertIn("points", player.details)
|
||||
summary = result.summary
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
self.assertEqual(state.hand_number, summary["deals_played"])
|
||||
|
||||
def test_result_requires_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
with self.assertRaises(GameError):
|
||||
engine.result(_started(engine))
|
||||
|
||||
def test_solo_result_teams(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
# Force a finished solo match: seat 0 called its own card.
|
||||
state.caller = 0
|
||||
state.solo = True
|
||||
state.partner_seat = None
|
||||
state.called_played = True
|
||||
state.called_card = Card.parse("01D")
|
||||
# Caller side captures 71 (60 + 11), defenders 49.
|
||||
state.players[0].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)]
|
||||
+ ["01S"]
|
||||
]
|
||||
state.players[1].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)]
|
||||
]
|
||||
state.scores = [71, 49]
|
||||
state.winner = 0
|
||||
state.phase = PHASE_FINISHED
|
||||
result = engine.result(session)
|
||||
self.assertEqual([1, 4], sorted(len(team) for team in result.teams))
|
||||
# The lone caller won: exactly one winner.
|
||||
self.assertEqual(1, sum(1 for p in result.players if p.won))
|
||||
caller = next(p for p in result.players if p.team == ROLE_CALLER)
|
||||
self.assertTrue(caller.won)
|
||||
self.assertEqual(71.0, caller.score)
|
||||
|
||||
def test_game_over_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = 1
|
||||
state.scores = [55, 65]
|
||||
state.caller = 2
|
||||
state.solo = False
|
||||
state.partner_seat = 4
|
||||
state.called_card = Card.parse("03C")
|
||||
over = engine.game_over_view(session, "p0")
|
||||
self.assertEqual({"A": 55, "B": 65}, over["scores"])
|
||||
self.assertEqual("Difensori", over["winner"])
|
||||
self.assertEqual("03C", over["called_card"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user