Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s

New tavolo-briscola distribution implementing briscola on the platform:

- pure rules (state.py, engine.py): 3-card deals, a face-up trump that
  is the last card drawn, trump-first trick resolution (A > 3 > K > H >
  J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a
  configurable target score (default 61), deal-end acknowledgements and
  turn auto-play;
- BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine
  (briscola_a_quattro, 4 players in fixed partnerships) adapters;
- registered in tavolo-app, packaged in the Docker image, documented.

Platform fixes surfaced by a two-player lobby:

- the websocket state and snapshot payloads now carry seats_open (they
  were only in the REST lobby payload), so lobbies with player counts
  other than four render correctly; seats_open is clamped at zero;
- the web client logs unparseable server messages instead of silently
  dropping them.

Web client: briscola table for both variants (stock, face-up trump,
trick, last-trick banner), deal summary modal and game-over overlay.

Tests: tavolo-briscola rules/adapter suites, app integration tests
(lobby, websocket play, turn timeout, deal-end acks, persistence), a
platform seats_open test and frontend model parsing tests.
This commit is contained in:
2026-10-06 14:08:25 +08:00
parent b5219062f2
commit 7bb6b74fdc
34 changed files with 3610 additions and 90 deletions
+4 -3
View File
@@ -2,14 +2,15 @@
The deployable tavolo application: the composition root wiring
[`tavolo-platform`](../tavolo-platform/README.md) to the
[`tavolo-scopone`](../tavolo-scopone/README.md) game.
[`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md) games.
## Contents
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
mixins plus `PlatformMixin` and the deadline scheduler), registers the
`ScoponeEngine` with its configured timeouts, and exposes
`tavolo.app:app` for granian.
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
their configured timeouts, and exposes `tavolo.app:app` for granian.
- `config.py` — environment-driven frozen `Settings`.
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
meter/tracer/logger providers, the
+2 -1
View File
@@ -5,12 +5,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "tavolo-app"
version = "0.1.0"
description = "Tavolo multiplayer card-game application: platform + scopone game wiring"
description = "Tavolo multiplayer card-game application: platform + games wiring"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
"tavolo-scopone",
"tavolo-briscola",
"kaya-core",
"kaya-cors",
"kaya-session",
+14 -1
View File
@@ -39,6 +39,7 @@ 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.platform import GameRegistry, Platform, PlatformMixin
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
@@ -131,9 +132,21 @@ registry.register(
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaQuattroEngine(
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(
"scopone timeouts: hand_ack=%ds turn=%ds",
"game timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds,
settings.turn_timeout_seconds,
)
@@ -215,12 +215,12 @@ class Settings:
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway (wired into the ScoponeEngine;
# before dealing the next hand anyway (wired into the game engines;
# see tavolo.app).
hand_ack_timeout_seconds: int
# Seconds a player has to play before the server plays a random legal
# card for them, covering disconnects and idle players (wired into the
# ScoponeEngine; see tavolo.app).
# game engines; see tavolo.app).
turn_timeout_seconds: int
# Upper bound on how long the deadline consumer sleeps between polls
# when nothing else wakes it. Enqueued deadlines are announced over a
+58
View File
@@ -0,0 +1,58 @@
# tavolo-briscola
Briscola — the Italian trick-taking game — as a
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
Two variants share one rules core:
- `briscola` — the classic two-player game;
- `briscola_a_quattro` — four-player fixed-partnership briscola.
## Contents
- `state.py` — `BriscolaState` (pure game data: phases, players, hands,
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.
- `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`).
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
## Rules
- 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
led suit when no trump was played. The winner takes the trick and leads
the next one.
- After each trick everyone draws back up to three cards, starting with
the trick winner.
- Card points total 120 per deal; a 60–60 deal is a *patta*, its points
are discarded and the deal is replayed. The first team to the target
score wins; a tie at or above the target is broken by another deal.
- Between deals of an unfinished match every player acknowledges the
summary (or the hand-end timeout deals on), exactly like scopone.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
.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).
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-briscola"
version = "0.1.0"
description = "Briscola game implementation for the tavolo platform"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -0,0 +1,20 @@
"""Briscola: tavolo's trick-taking game implementation.
Two 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``).
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
:class:`~tavolo.platform.engine.GameEngine` contract so the
game-independent platform can host them.
"""
from __future__ import annotations
from .plugin import BriscolaEngine, BriscolaQuattroEngine
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
@@ -0,0 +1,469 @@
"""Pure rules engine for briscola.
Every function here is deterministic and I/O-free (the only side effect is
logging): it mutates (or reads)
:class:`~tavolo.briscola.state.BriscolaState` and raises
:class:`~tavolo.platform.errors.GameError` subclasses on rule violations.
This makes the whole rule set unit-testable without Redis, Postgres or
HTTP. The platform-facing adapter is
:class:`~tavolo.briscola.plugin.BriscolaEngine`.
Rules implemented
-----------------
* 40-card Italian deck (4 suits x ranks 1-10), three cards per player.
* The next card after the deal is turned face up: its suit is the
**briscola** (trump) for the whole deal, and it is the last card drawn
from the stock (the stock is drawn from the bottom up).
* Trick play: the player to the dealer's left leads; each other player
plays one card. The highest trump wins; when no trump was played, the
highest card of the led suit wins. The winner takes the trick and leads
the next one.
* After each trick every player draws back up to three cards, starting
with the trick winner and continuing in turn order; the face-up
briscola card is drawn last, so whoever's turn to draw when only it
remains takes it.
* Card points: ace 11, three 10, king 4, horse 3, jack 2, everything else
0 — 120 points per deal. Trick strength is unrelated to points:
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
* Match points accumulate across deals. A 60-60 deal is a **patta**: its
points are discarded and the deal is replayed (the dealer rotates).
The first team to reach the target score (default 61) wins; a tie at or
above the target is broken by another deal.
* Between deals of an unfinished match the game pauses in the
``hand_end`` phase for the deal summary, exactly like scopone: every
player acknowledges (or the hand-end timeout elapses) before the next
deal.
"""
from __future__ import annotations
import random
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 .errors import CardNotInHand
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
DEFAULT_TURN_TIMEOUT_SECONDS,
HAND_SIZE,
MAX_TARGET_SCORE,
MIN_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
PLAYER_COUNTS,
SUITS,
TEAM_NAMES,
BriscolaState,
Card,
PlayerState,
Trick,
TrickCard,
parse_card,
)
log = getLogger(__name__)
# Trick strength: higher wins. Ace > three > king > horse > jack, then the
# remaining ranks in descending order. All ranks are distinct within a
# suit, so a trick never ties.
TRICK_STRENGTH: Dict[int, int] = {
1: 9,
3: 8,
10: 7,
9: 6,
8: 5,
7: 4,
6: 3,
5: 2,
4: 1,
2: 0,
}
# Card points: 120 per deal in total.
CARD_POINTS: Dict[int, int] = {
1: 11,
3: 10,
10: 4,
9: 3,
8: 2,
}
_rng = random.SystemRandom()
def full_deck() -> List[Card]:
"""Return the 40 cards of the Italian deck in canonical order."""
return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)]
def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
"""Return a shuffled deck. Pass ``rng`` for deterministic tests."""
deck = full_deck()
(rng or _rng).shuffle(deck)
return deck
def card_points(card: Card) -> int:
"""The point value of a single card."""
return CARD_POINTS.get(card.rank, 0)
def create_game(
creator_sub: str,
creator_name: str,
player_count: int = 2,
target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> BriscolaState:
"""Create a lobby game with the creator seated first."""
if player_count not in PLAYER_COUNTS:
raise IllegalMove(f"player_count must be one of {PLAYER_COUNTS}")
if (
isinstance(target_score, bool)
or not isinstance(target_score, int)
or target_score < MIN_TARGET_SCORE
or target_score > MAX_TARGET_SCORE
):
raise IllegalMove(
f"target_score must be between {MIN_TARGET_SCORE} and {MAX_TARGET_SCORE}"
)
return BriscolaState(
player_count=player_count,
target_score=target_score,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
)
def join_game(state: BriscolaState, 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) >= state.player_count:
raise LobbyFull("game is full")
seat = len(state.players)
state.players.append(PlayerState(sub=sub, name=name, seat=seat))
if len(state.players) == state.player_count:
start_game(state)
def start_game(state: BriscolaState) -> None:
"""Deal the first hand and switch the game to playing."""
if len(state.players) != state.player_count:
raise GameNotStarted(
f"need exactly {state.player_count} players to start"
)
state.phase = PHASE_PLAYING
_deal(state)
def _set_turn_deadline(state: BriscolaState) -> None:
"""Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat()
def _deal(state: BriscolaState) -> None:
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
state.trick = []
state.last_trick = None
state.briscola_card = None
# Dealer rotates each deal; the first card is played by the player to
# the dealer's left.
state.turn = (state.dealer + 1) % state.player_count
state.trick_leader = state.turn
for _ in range(HAND_SIZE):
for offset in range(state.player_count):
player = _player_at(
state, (state.dealer + 1 + offset) % state.player_count
)
player.hand.append(deck.pop())
# The turned-up card is the last card drawn: keep it separate from the
# face-down stock and hand it out once the stock is exhausted.
state.briscola_card = deck.pop()
state.briscola_suit = state.briscola_card.suit
state.stock = deck
_set_turn_deadline(state)
log.debug(
"deal %d dealt (dealer seat %d, briscola %s)",
state.hand_number,
state.dealer,
state.briscola_card.code,
)
def _player_at(state: BriscolaState, seat: int) -> PlayerState:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def play(state: BriscolaState, 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_HAND_END:
raise IllegalMove("the deal is over; acknowledge the summary to continue")
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)
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),
state.player_count,
)
if len(state.trick) == state.player_count:
_resolve_trick(state)
else:
state.turn = (state.turn + 1) % state.player_count
_set_turn_deadline(state)
def _resolve_trick(state: BriscolaState) -> None:
"""Score the completed trick, draw and hand the lead to the winner."""
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
_draw(state, winner)
_set_turn_deadline(state)
def _trick_winner(state: BriscolaState) -> 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]
def _draw(state: BriscolaState, winner: int) -> None:
"""Draw back up to ``HAND_SIZE`` cards, winner first, then turn order."""
for offset in range(state.player_count):
player = _player_at(state, (winner + offset) % state.player_count)
if state.stock:
player.hand.append(state.stock.pop())
elif state.briscola_card is not None:
# The face-up briscola is the last card of the stock: the first
# player who needs a card when only it remains takes it.
player.hand.append(state.briscola_card)
state.briscola_card = None
def auto_play(state: BriscolaState, rng: Optional[random.Random] = None) -> None:
"""Play a random card for the player currently on turn.
Every card in hand is legal, so one is drawn at random. Delegates to
:func:`play`, so the move is fully validated and can end the deal or
the match. Pass ``rng`` for deterministic tests.
"""
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)
def _end_deal(state: BriscolaState) -> None:
"""Score the deal and either finish the match or pause for the summary.
A 60-60 deal is a patta: its points do not count and the deal is
replayed. If the match continues, the game pauses in the ``hand_end``
phase so every player can read the summary; the next deal is dealt by
:func:`acknowledge_hand` once all players have acknowledged (or by the
hand-end timeout fired by the platform's deadline scheduler).
"""
state.turn_deadline = None
points, details = deal_points(state)
patta = points[0] == points[1]
if not patta:
state.scores[0] += points[0]
state.scores[1] += points[1]
entry: Dict[str, Any] = {
"deal": state.hand_number,
"patta": patta,
"points": details["points"],
"cards": details["cards"],
"totals": {"A": state.scores[0], "B": state.scores[1]},
}
state.hand_scores.append(entry)
a, b = state.scores
log.debug(
"deal %d scored A+%d B+%d%s (totals %d-%d)",
state.hand_number,
points[0],
points[1],
" patta" if patta else "",
a,
b,
)
if not patta and max(a, b) >= state.target_score and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
log.info("match ended, team %s wins", TEAM_NAMES[state.winner])
return
# Pause for the deal summary instead of dealing immediately.
state.phase = PHASE_HAND_END
state.acked = []
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: BriscolaState, sub: str) -> None:
"""Record that ``sub`` has read the deal summary.
When every player has acknowledged, the next deal is dealt.
Acknowledging twice is a no-op; acknowledging outside the ``hand_end``
phase raises an error.
"""
if state.phase != PHASE_HAND_END:
raise IllegalMove("no deal summary is waiting for acknowledgement")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
if player.seat in state.acked:
return
state.acked.append(player.seat)
if len(state.acked) < state.player_count:
return
state.hand_number += 1
state.dealer = (state.dealer + 1) % state.player_count
state.acked = []
state.hand_end_deadline = None
state.phase = PHASE_PLAYING
_deal(state)
def deal_points(state: BriscolaState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the deal points for both teams (index 0 = team A)."""
piles: List[List[Card]] = [[], []]
for player in state.players:
piles[player.team].extend(player.captured)
points = [
sum(card_points(card) for card in pile) for pile in piles
]
details: Dict[str, object] = {
"points": {"A": points[0], "B": points[1]},
"cards": {"A": len(piles[0]), "B": len(piles[1])},
}
return points, details
def state_for_player(state: BriscolaState, sub: Optional[str]) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own
hand. Non-seated viewers — including spectators, for whom ``sub`` is
``None`` — simply see no hand at all. The platform merges the session
envelope (``id``, ``join_code`` for seated players, ``game_type``,
``spectator``) into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
for player in state.players:
view: Dict[str, object] = {
"sub": player.sub,
"name": player.name,
"seat": player.seat,
"team": TEAM_NAMES[player.team],
"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)
payload: Dict[str, object] = {
"phase": state.phase,
"target_score": state.target_score,
"player_count": state.player_count,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
"briscola_suit": state.briscola_suit,
"briscola_card": (
state.briscola_card.code if state.briscola_card is not None else None
),
"stock_count": len(state.stock),
"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,
"players": players,
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
"acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline,
"turn_deadline": state.turn_deadline,
}
if (
viewer is not None
and state.phase == PHASE_PLAYING
and viewer.seat == state.turn
):
payload["your_turn"] = True
return payload
@@ -0,0 +1,34 @@
"""Briscola errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely briscola-specific errors
are defined here.
"""
from __future__ import annotations
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
__all__ = [
"AlreadyJoined",
"CardNotInHand",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"IllegalMove",
"LobbyFull",
"NotYourTurn",
]
class CardNotInHand(GameError):
"""The played card is not held by the player."""
@@ -0,0 +1,279 @@
"""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,
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.
"""
from __future__ import annotations
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Mapping, Optional
from tavolo.platform.engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
DEFAULT_TURN_TIMEOUT_SECONDS,
MAX_TARGET_SCORE,
MIN_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
TEAM_NAMES,
BriscolaState,
)
log = getLogger(__name__)
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
class _BriscolaBase(GameEngine):
"""Shared platform adapter for the two briscola table sizes."""
#: 2 (classic) or 4 (fixed partnerships); set by the subclasses.
player_count: int
options_schema = {
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": MIN_TARGET_SCORE,
"maximum": MAX_TARGET_SCORE,
"default": DEFAULT_TARGET_SCORE,
"description": "Match points the winning team must reach.",
},
},
}
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
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
if isinstance(target_score, bool) or not isinstance(target_score, int):
raise IllegalMove("target_score must be an integer")
creator = session.players[0]
session.state = engine.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
player_count=self.player_count,
target_score=target_score,
hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout,
)
# The creator takes seat 0, i.e. team A.
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team=TEAM_NAMES[0],
)
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
seat = len(session.players)
engine.join_game(session.state, user_sub, display_name)
# join_game raises before appending on any violation, so the seat
# list stays in sync with the engine's players.
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team=TEAM_NAMES[seat % 2],
)
)
def in_lobby(self, session: GameSession) -> bool:
return session.state.phase == PHASE_LOBBY
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
state: BriscolaState = session.state
return {
"phase": state.phase,
"target_score": state.target_score,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: BriscolaState = session.state
if action == "play":
card = payload.get("card")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
try:
engine.play(state, user_sub, card)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "ack":
engine.acknowledge_hand(state, user_sub)
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return engine.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: BriscolaState = session.state
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
}
# -- (de)serialization -------------------------------------------------
def state_to_json(self, state: Any) -> Dict[str, Any]:
assert isinstance(state, BriscolaState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return BriscolaState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: BriscolaState = session.state
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}",
)
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
if due_ms is None:
return None
return Deadline(
kind="hand_end",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"hand_end:{state.hand_number}:{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 play landed in time, the deal was
# acknowledged, the deadline moved): nothing to do.
raise GameError("stale deadline")
state: BriscolaState = session.state
if kind == "turn":
seat = state.turn
engine.auto_play(state)
log.info(
"auto-played for seat %s (turn timeout, deal %d)",
seat,
state.hand_number,
)
elif kind == "hand_end":
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"deal %d auto-advanced after the acknowledgement timeout",
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: BriscolaState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
teams = [
[p.sub for p in state.players if p.team == 0],
[p.sub for p in state.players if p.team == 1],
]
return MatchResult(
teams=teams,
winner_team=state.winner,
players=[
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=player.team == state.winner,
team=TEAM_NAMES[player.team],
score=float(state.scores[player.team]),
details={"captured_cards": len(player.captured)},
)
for player in state.players
],
summary={
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[state.winner],
"target_score": state.target_score,
"deals_played": state.hand_number,
"deal_scores": state.hand_scores,
},
)
class BriscolaEngine(_BriscolaBase):
"""Classic two-player briscola as a platform game engine."""
id = "briscola"
name = "Briscola"
description = (
"The classic two-player Italian trick-taking game: three cards "
"each, a trump suit and 120 points per deal. First to the target "
"score wins."
)
min_players = 2
max_players = 2
player_count = 2
class BriscolaQuattroEngine(_BriscolaBase):
"""Four-player fixed-partnership briscola as a platform game engine."""
id = "briscola_a_quattro"
name = "Briscola a quattro"
description = (
"Briscola played in two fixed partnerships of two, three cards "
"each and a trump suit. First team to the target score wins."
)
min_players = 4
max_players = 4
player_count = 4
@@ -0,0 +1,279 @@
"""In-memory representation of a briscola game.
The whole mutable game lives in :class:`BriscolaState`, which is
serialized to and from plain JSON for storage inside the platform's
session envelope (see :mod:`tavolo.platform.store`). Keeping the
representation JSON-native means the store needs no custom codecs and the
state is inspectable with ``redis-cli``.
The state contains only game data: the platform owns the session envelope
(id, join code, seats, timestamps, stats persistence) — see
:class:`tavolo.platform.engine.GameSession`.
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
A card is rendered as ``RRSUIT`` (e.g. ``07D``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
SUITS = ("D", "C", "S", "B")
RANKS = tuple(range(1, 11))
# Teams are derived from the seat: seats 0 and 2 form team A (index 0),
# seats 1 and 3 form team B (index 1). In the two-player variant the teams
# are simply the two players; in briscola a quattro the partners sit
# opposite each other.
TEAM_A = 0
TEAM_B = 1
TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing"
# Between deals of an unfinished match: deal summary shown to every
# player; the next deal is dealt once all players acknowledge (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished"
# Supported table sizes: classic head-to-head and four-player partnerships.
PLAYER_COUNTS = (2, 4)
DEFAULT_TARGET_SCORE = 61
MIN_TARGET_SCORE = 61
MAX_TARGET_SCORE = 120
# Cards dealt to each player at the start of a deal.
HAND_SIZE = 3
# Default seconds the between-deals summary waits before dealing anyway.
# Games carry their own copy in ``BriscolaState.hand_ack_timeout``.
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# card for them. Games carry their own copy in
# ``BriscolaState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30
def team_of(seat: int) -> int:
return seat % 2
@dataclass(frozen=True)
class Card:
rank: int
suit: str
def __post_init__(self) -> None:
if self.suit not in SUITS:
raise ValueError(f"invalid suit: {self.suit!r}")
if self.rank not in RANKS:
raise ValueError(f"invalid rank: {self.rank!r}")
@property
def code(self) -> str:
return f"{self.rank:02d}{self.suit}"
@staticmethod
def parse(code: str) -> "Card":
code = str(code).upper()
if len(code) != 3 or not code[:2].isdigit():
raise ValueError(f"invalid card code: {code!r}")
return Card(rank=int(code[:2]), suit=code[2])
def to_json(self) -> str:
return self.code
@staticmethod
def from_json(value: Any) -> "Card":
return Card.parse(str(value))
def parse_card(code: Any) -> Card:
"""Parse a card code, raising :class:`ValueError` on malformed input."""
return Card.parse(str(code))
@dataclass
class TrickCard:
"""One card played in the current trick, in play order."""
seat: int
card: str
def to_json(self) -> Dict[str, Any]:
return {"seat": self.seat, "card": self.card}
@staticmethod
def from_json(data: Dict[str, Any]) -> "TrickCard":
return TrickCard(seat=int(data["seat"]), card=str(data["card"]))
@dataclass
class Trick:
"""A completed trick: the winner's seat and the cards it captured."""
winner: int
cards: List[TrickCard] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"winner": self.winner,
"cards": [c.to_json() for c in self.cards],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Trick":
return Trick(
winner=int(data["winner"]),
cards=[TrickCard.from_json(c) for c in data.get("cards", [])],
)
@dataclass
class PlayerState:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
@property
def team(self) -> int:
return team_of(self.seat)
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]) -> "PlayerState":
return PlayerState(
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 BriscolaState:
player_count: int = 2
target_score: int = DEFAULT_TARGET_SCORE
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
# Face-down draw pile; cards are drawn from the end.
stock: List[Card] = field(default_factory=list)
# The face-up card turned at deal time: its suit is the trump suit for
# the whole deal, and it is the last card drawn from the stock.
briscola_card: Optional[Card] = None
briscola_suit: str = ""
dealer: int = 0
turn: int = 0
# 1-based deal counter, used in the audit trail and deadlines.
hand_number: int = 1
# Cards of the trick in progress, in play order.
trick: List[TrickCard] = field(default_factory=list)
trick_leader: int = 0
# The most recently completed trick, for the table announcement.
last_trick: Optional[Trick] = None
# Match points accumulated across deals (patta deals do not count).
scores: List[int] = field(default_factory=lambda: [0, 0])
winner: Optional[int] = None
# Per-deal points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# While phase == "hand_end": seats that acknowledged the summary, and
# when the auto-continue timeout fires.
acked: List[int] = field(default_factory=list)
hand_end_deadline: Optional[str] = None
# Seconds the deal summary waits before dealing anyway.
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS
# While phase == "playing": when the server plays a random card for
# the player on turn.
turn_deadline: Optional[str] = None
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"player_count": self.player_count,
"target_score": self.target_score,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"stock": [c.to_json() for c in self.stock],
"briscola_card": (
self.briscola_card.to_json() if self.briscola_card else None
),
"briscola_suit": self.briscola_suit,
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"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,
"scores": list(self.scores),
"winner": self.winner,
"hand_scores": list(self.hand_scores),
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "BriscolaState":
return BriscolaState(
player_count=int(data.get("player_count", 2)),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
stock=[Card.from_json(c) for c in data.get("stock", [])],
briscola_card=(
Card.from_json(data["briscola_card"])
if data.get("briscola_card")
else None
),
briscola_suit=str(data.get("briscola_suit", "")),
dealer=int(data.get("dealer", 0)),
turn=int(data.get("turn", 0)),
hand_number=int(data.get("hand_number", 1)),
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
),
scores=[int(x) for x in data.get("scores", [0, 0])],
winner=data.get("winner"),
hand_scores=list(data.get("hand_scores", [])),
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(
data.get("hand_ack_timeout", DEFAULT_HAND_ACK_TIMEOUT_SECONDS)
),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
),
)
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
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
@@ -0,0 +1,691 @@
"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation."""
from __future__ import annotations
import random
import unittest
from tavolo.briscola import engine
from tavolo.briscola.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.briscola.state import (
HAND_SIZE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
Card,
PlayerState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
*,
briscola_suit: str = "S",
briscola_card: str | None = None,
stock=None,
captured=None,
turn: int = 0,
dealer: int = 0,
target: int = 61,
scores=None,
phase: str = PHASE_PLAYING,
) -> BriscolaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = BriscolaState(
player_count=len(hands),
target_score=target,
phase=phase,
dealer=dealer,
turn=turn,
briscola_suit=briscola_suit,
briscola_card=card(briscola_card) if briscola_card else None,
stock=[card(c) for c in (stock or [])],
scores=list(scores or [0, 0]),
)
for seat, hand in enumerate(hands):
state.players.append(
PlayerState(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 trick_cards(state: BriscolaState) -> list[str]:
return [t.card for t in state.trick]
def play_all(state: BriscolaState) -> None:
"""Play every card of the current trick in turn order."""
for _ in range(state.player_count):
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
class DeckTest(unittest.TestCase):
def test_full_deck_has_40_unique_cards(self) -> None:
deck = engine.full_deck()
self.assertEqual(40, len(deck))
self.assertEqual(40, len({c.code for c in deck}))
self.assertEqual(4, len({c.suit for c in deck}))
def test_shuffled_deck_is_permutation(self) -> None:
deck = engine.shuffled_deck()
self.assertEqual(
sorted(c.code for c in engine.full_deck()),
sorted(c.code for c in deck),
)
def test_card_points_add_up_to_120(self) -> None:
self.assertEqual(
120, sum(engine.card_points(c) for c in engine.full_deck())
)
self.assertEqual(11, engine.card_points(card("01D")))
self.assertEqual(10, engine.card_points(card("03C")))
self.assertEqual(4, engine.card_points(card("10S")))
self.assertEqual(3, engine.card_points(card("09B")))
self.assertEqual(2, engine.card_points(card("08D")))
self.assertEqual(0, engine.card_points(card("07C")))
class DealTest(unittest.TestCase):
def test_two_player_deal(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
# 40 - 3*2 - 1 face-up = 33 face-down.
self.assertEqual(33, len(state.stock))
self.assertIsNotNone(state.briscola_card)
assert state.briscola_card is not None
self.assertEqual(state.briscola_card.suit, state.briscola_suit)
self.assertEqual(1, state.turn) # dealer is seat 0
self.assertEqual([], state.trick)
def test_four_player_deal(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(40 - 3 * 4 - 1, len(state.stock))
self.assertEqual(1, state.turn)
def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
dealt = [c.code for p in state.players for c in p.hand]
assert state.briscola_card is not None
dealt.append(state.briscola_card.code)
dealt.extend(c.code for c in state.stock)
self.assertEqual(40, len(dealt))
self.assertEqual(40, len(set(dealt)))
def test_unsupported_player_count(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=3)
def test_target_score_bounds(self) -> None:
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=60)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=121)
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", target_score=True)
state = engine.create_game("p0", "p0", target_score=101)
self.assertEqual(101, state.target_score)
class TrickTest(unittest.TestCase):
def test_higher_card_of_led_suit_wins(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
self.assertEqual(1, state.last_trick.winner)
self.assertEqual(["02D", "05D"],
[c.code for c in state.players[1].captured])
def test_trump_beats_a_higher_led_card(self) -> None:
state = make_state([["07D"], ["02C"]], briscola_suit="C")
engine.play(state, "p0", "07D")
engine.play(state, "p1", "02C")
self.assertEqual(1, state.last_trick.winner)
def test_higher_trump_wins_among_trumps(self) -> None:
state = make_state([["02C"], ["05C"]], briscola_suit="C")
engine.play(state, "p0", "02C")
engine.play(state, "p1", "05C")
self.assertEqual(1, state.last_trick.winner)
def test_off_suit_non_trump_loses(self) -> None:
state = make_state([["04D"], ["01C"]], briscola_suit="B")
engine.play(state, "p0", "04D")
engine.play(state, "p1", "01C")
self.assertEqual(0, state.last_trick.winner)
def test_rank_strength_order(self) -> None:
# (winner card, loser card) pairs of the led suit, strongest first:
# ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
pairs = [
("01D", "03D"), # ace beats three
("03D", "10D"), # three beats king
("10D", "09D"), # king beats horse
("09D", "08D"), # horse beats jack
("08D", "07D"), # jack beats seven
("07D", "06D"),
("06D", "05D"),
("05D", "04D"),
("04D", "02D"),
]
for winner, loser in pairs:
state = make_state([["02D"], ["05D"]], briscola_suit="B")
state.players[0].hand = [card(winner)]
state.players[1].hand = [card(loser)]
engine.play(state, "p0", winner)
engine.play(state, "p1", loser)
self.assertEqual(
0, state.last_trick.winner, f"{winner} should beat {loser}"
)
def test_four_player_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.last_trick.winner)
def test_four_player_trump_beats_all(self) -> None:
state = make_state(
[["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C"
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(1, state.last_trick.winner)
def test_winner_leads_the_next_trick(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
for seat in range(4):
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
self.assertEqual(3, state.turn)
self.assertEqual(3, state.trick_leader)
self.assertEqual([], state.trick)
def test_turn_advances_within_a_trick(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
self.assertEqual(["02D"], trick_cards(state))
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_and_hand_end_reject_plays(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "02D")
class DrawTest(unittest.TestCase):
def test_winner_draws_first(self) -> None:
# The stock is drawn from its end: 04B first, then 03B, ...
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="S",
stock=["01B", "02B", "03B", "04B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
self.assertEqual(["09C", "03B"], hands[0])
self.assertEqual(["09B", "04B"], hands[1])
def test_face_up_briscola_is_drawn_last(self) -> None:
state = make_state(
[["02D", "09C"], ["05D", "09B"]],
briscola_suit="C",
briscola_card="05C",
stock=["03B"],
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
# The winner draws first and takes the last face-down card; the
# other player then takes the face-up briscola.
self.assertEqual(["09B", "03B"], hands[1])
self.assertEqual(["09C", "05C"], hands[0])
self.assertIsNone(state.briscola_card)
def test_no_draw_when_stock_is_empty(self) -> None:
state = make_state(
[["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[]
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
# p1 wins the trick; with no stock nobody draws and the hands
# shrink for the rest of the deal.
for player in state.players:
self.assertEqual(1, len(player.hand))
def test_hands_stay_at_three_until_stock_runs_out(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
while state.stock or state.briscola_card is not None:
play_all(state)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual(0, len(state.stock))
self.assertIsNone(state.briscola_card)
class ScoringTest(unittest.TestCase):
def test_deal_points_group_by_team(self) -> None:
state = make_state(
[[], [], [], []],
briscola_suit="S",
captured=[
["01D", "07C"], # seat 0, team A: 11
["03C", "08D"], # seat 1, team B: 10
["10S"], # seat 2, team A: 4
["09B"], # seat 3, team B: 3
],
)
points, details = engine.deal_points(state)
self.assertEqual([15, 15], points)
self.assertEqual({"A": 15, "B": 15}, details["points"])
self.assertEqual({"A": 3, "B": 3}, details["cards"])
def test_full_deal_scores_120(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
while state.phase == PHASE_PLAYING:
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, player.hand[0].code)
points, _ = engine.deal_points(state)
self.assertEqual(120, sum(points))
def test_match_ends_when_target_reached(self) -> None:
# p0 captures the ace of denari (11) on the last trick: 60+11 >= 61.
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], ["10C"]],
scores=[60, 50],
target=61,
)
# p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a
# higher card: 02D vs 05D -> p1 would win, so give p0 the five.
state.players[0].hand = [card("05D")]
state.players[1].hand = [card("02D")]
engine.play(state, "p0", "05D")
engine.play(state, "p1", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertEqual([71, 54], state.scores)
def test_tie_at_target_is_broken_by_another_deal(self) -> None:
# 65-55 before the deal; this deal gives A 55 and B 65: both reach
# 120 and tie, so another deal must be played.
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"]
b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [
f"{rank:02d}S" for rank in range(1, 11)
] + [f"{rank:02d}B" for rank in range(1, 11)]
state = make_state(
[[], []],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[65, 55],
target=120,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual([120, 120], state.scores)
self.assertIsNone(state.winner)
def test_patta_is_discarded_and_replayed(self) -> None:
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [
f"{rank:02d}C" for rank in range(1, 11)
]
b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [
f"{rank:02d}B" for rank in range(1, 11)
]
state = make_state(
[["02D"], ["02S"]],
briscola_suit="S",
stock=[],
captured=[a_cards, b_cards],
scores=[10, 20],
target=61,
)
engine._end_deal(state)
self.assertEqual(PHASE_HAND_END, state.phase)
# The 60-60 deal does not count: totals are unchanged.
self.assertEqual([10, 20], state.scores)
entry = state.hand_scores[-1]
self.assertTrue(entry["patta"])
self.assertEqual({"A": 60, "B": 60}, entry["points"])
# The next deal is played at the same cumulative totals.
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual([10, 20], state.scores)
def test_audit_trail_records_each_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
scores=[0, 0],
)
engine._end_deal(state)
entry = state.hand_scores[-1]
self.assertEqual(1, entry["deal"])
self.assertFalse(entry["patta"])
self.assertEqual({"A": 11, "B": 0}, entry["points"])
self.assertEqual({"A": 11, "B": 0}, entry["totals"])
self.assertEqual({"A": 1, "B": 0}, entry["cards"])
class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(1, len(state.players))
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
state = engine.create_game("p0", "p0", player_count=4)
for i in range(1, 3):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual("lobby", state.phase)
engine.join_game(state, "p3", "p3")
self.assertEqual(PHASE_PLAYING, state.phase)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_full_random_two_player_match(self) -> None:
self._random_match(player_count=2)
def test_full_random_four_player_match(self) -> None:
self._random_match(player_count=4)
def _random_match(self, player_count: int) -> None:
state = engine.create_game("p0", "p0", player_count=player_count)
for i in range(1, player_count):
engine.join_game(state, f"p{i}", f"p{i}")
rng = random.Random(2026)
moves = 0
while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
player = next(p for p in state.players if p.seat == state.turn)
engine.play(state, player.sub, rng.choice(player.hand).code)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIn(state.winner, (0, 1))
self.assertEqual([], state.trick)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
# Every counted deal totals 120 points, and the winner reached the
# target with a clear lead.
winner_score = state.scores[state.winner]
loser_score = state.scores[1 - state.winner]
self.assertGreaterEqual(winner_score, state.target_score)
self.assertGreater(winner_score, loser_score)
self.assertEqual(0, (winner_score + loser_score) % 120)
class AckTest(unittest.TestCase):
def _hand_end_state(self) -> BriscolaState:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
return state
def test_ack_all_deals_the_next_deal(self) -> None:
state = self._hand_end_state()
dealer_before = state.dealer
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
self.assertEqual(PHASE_HAND_END, state.phase)
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual((dealer_before + 1) % 2, state.dealer)
self.assertEqual((dealer_before + 2) % 2, state.turn)
self.assertEqual([], state.acked)
self.assertIsNone(state.hand_end_deadline)
for player in state.players:
self.assertEqual(HAND_SIZE, len(player.hand))
self.assertEqual([], player.captured)
def test_double_ack_is_idempotent(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
def test_ack_outside_hand_end_is_rejected(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_ack_by_non_player_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(NotYourTurn):
engine.acknowledge_hand(state, "mallory")
class AutoPlayTest(unittest.TestCase):
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
state = make_state(
[["02D", "03C"], ["05D"]],
briscola_suit="S",
stock=["01B", "02B"],
briscola_card="03B",
)
engine.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.players[0].hand))
self.assertEqual(1, len(state.trick))
def test_auto_play_can_end_the_deal(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
# Both players are on their last card: two auto-plays complete the
# trick and the deal, scored A=11 / B=0.
engine.auto_play(state)
engine.auto_play(state)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.turn_deadline)
self.assertTrue(state.hand_end_deadline)
def test_auto_play_requires_playing_phase(self) -> None:
state = make_state([["02D"], ["05D"]], briscola_suit="S")
state.phase = PHASE_HAND_END
with self.assertRaises(GameNotStarted):
engine.auto_play(state)
class ViewTest(unittest.TestCase):
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
briscola_suit="D",
briscola_card="07D",
stock=["01B", "02B"],
)
view = engine.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("D", view["briscola_suit"])
self.assertEqual("07D", view["briscola_card"])
self.assertEqual(2, view["stock_count"])
self.assertTrue(view.get("your_turn"))
def test_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
briscola_card="07D",
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual("07D", view["briscola_card"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_player_points_are_public(self) -> None:
state = make_state(
[["02D"], ["04D"]],
briscola_suit="D",
captured=[["01C", "03B"], ["10D"]],
)
view = engine.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"])
def test_hand_end_view_exposes_the_deal_summary(self) -> None:
state = make_state(
[["02D"], ["05D"]],
briscola_suit="S",
stock=[],
captured=[["01C"], []],
target=120,
)
engine._end_deal(state)
view = engine.state_for_player(state, "p0")
self.assertEqual(PHASE_HAND_END, view["phase"])
self.assertEqual(
{
"deal": 1,
"patta": False,
"points": {"A": 11, "B": 0},
"cards": {"A": 1, "B": 0},
"totals": {"A": 11, "B": 0},
},
view["last_deal"],
)
self.assertTrue(view["hand_end_deadline"])
def test_trick_is_public_in_play_order(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "05D")
view = engine.state_for_player(state, None)
self.assertEqual(
[{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}],
view["trick"],
)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game("p0", "p0", player_count=4, target_score=101)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
engine.play(state, state.players[1].sub, state.players[1].hand[0].code)
restored = BriscolaState.from_json(state.to_json())
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(state.hand_number, restored.hand_number)
self.assertEqual(state.target_score, restored.target_score)
self.assertEqual(state.briscola_suit, restored.briscola_suit)
self.assertEqual(
state.briscola_card.code if state.briscola_card else None,
restored.briscola_card.code if restored.briscola_card else None,
)
self.assertEqual(
[c.code for c in state.stock], [c.code for c in restored.stock]
)
self.assertEqual(
[(t.seat, t.card) for t in state.trick],
[(t.seat, t.card) for t in restored.trick],
)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
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 = make_state([["02D"], ["05D"]], briscola_suit="S")
data = state.to_json()
del data["player_count"]
del data["briscola_card"]
del data["last_trick"]
restored = BriscolaState.from_json(data)
self.assertEqual(2, restored.player_count)
self.assertIsNone(restored.briscola_card)
self.assertIsNone(restored.last_trick)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,349 @@
"""Adapter tests: the platform contract over the pure briscola rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotStarted,
IllegalMove,
)
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
from tavolo.briscola import engine as rules
from tavolo.briscola.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
BriscolaState,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="b1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for i in range(1, engine.max_players):
name = f"p{i}"
engine.join(session, name, name.capitalize())
return session
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_HAND_END:
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
class MetadataTest(unittest.TestCase):
def test_two_player_metadata(self) -> None:
engine = BriscolaEngine()
self.assertEqual("briscola", engine.id)
self.assertEqual("Briscola", engine.name)
self.assertTrue(engine.description)
self.assertEqual(2, engine.min_players)
self.assertEqual(2, engine.max_players)
target = engine.options_schema["properties"]["target_score"]
self.assertEqual("integer", target["type"])
self.assertEqual(61, target["minimum"])
self.assertEqual(120, target["maximum"])
self.assertEqual(61, target["default"])
def test_four_player_metadata(self) -> None:
engine = BriscolaQuattroEngine()
self.assertEqual("briscola_a_quattro", engine.id)
self.assertEqual("Briscola a quattro", engine.name)
self.assertTrue(engine.description)
self.assertEqual(4, engine.min_players)
self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"])
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_team_a(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, BriscolaState)
self.assertEqual(61, session.state.target_score)
self.assertEqual(2, session.state.player_count)
def test_create_options(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine, target_score=101)
self.assertEqual(101, session.state.target_score)
self.assertEqual(4, session.state.player_count)
def test_create_rejects_bad_options(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=60)
with self.assertRaises(IllegalMove):
_session(engine, target_score=121)
with self.assertRaises(IllegalMove):
_session(engine, target_score="sixtyone")
with self.assertRaises(IllegalMove):
_session(engine, target_score=True)
def test_timeouts_come_from_the_engine(self) -> None:
engine = BriscolaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_two_player_join_starts(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
engine.join(session, "bob", "Bob")
self.assertEqual("B", session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_four_player_teams_alternate(self) -> None:
engine = BriscolaQuattroEngine()
session = _session(engine)
teams = []
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
teams.append(session.players[-1].team)
self.assertEqual(["B", "A", "B"], teams)
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
engine = BriscolaEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
engine.join(session, "bob", "Bob")
with self.assertRaises(GameNotStarted):
engine.join(session, "carol", "Carol")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": "nope"})
def test_play_advances_the_trick(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
self.assertEqual(1, state.turn)
card = state.players[1].hand[0].code
engine.handle_action(session, "p1", "play", {"card": card})
self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"])
def test_play_out_of_turn_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
state = session.state
card = state.players[0].hand[0].code
with self.assertRaises(GameError):
engine.handle_action(session, "alice", "play", {"card": card})
def test_ack_outside_hand_end_rejected(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "ack", {})
def test_finished_match_rejects_actions(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
def test_ack_all_deals_next(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
# Force the deal-end phase: both players keep 3 cards, so the
# match continues after the summary.
state.phase = PHASE_HAND_END
state.acked = []
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
engine.handle_action(session, "alice", "ack", {})
self.assertEqual(PHASE_HAND_END, state.phase)
engine.handle_action(session, "p1", "ack", {})
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
class ViewTest(unittest.TestCase):
def test_view_for_hides_other_hands(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
view = engine.view_for(session, "alice")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
# The envelope is the platform's job, not the view's.
self.assertNotIn("id", view)
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
self.assertIn("briscola_suit", view)
self.assertIn("stock_count", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = BriscolaEngine()
session = _session(engine, target_score=101)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(101, lobby["target_score"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, BriscolaState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
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 = BriscolaEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code})
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = BriscolaEngine()
session = _started(engine, target_score=120)
state = session.state
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_match(self) -> None:
engine = BriscolaQuattroEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertEqual([2, 2], [len(team) for team in result.teams])
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(4, len(result.players))
winner_name = "A" if result.winner_team == 0 else "B"
for player in result.players:
self.assertEqual(player.won, player.team == winner_name)
self.assertIn("captured_cards", player.details)
self.assertIn("team_a_score", result.summary)
self.assertIn("team_b_score", result.summary)
self.assertIn("winner_team", result.summary)
self.assertIn("target_score", result.summary)
self.assertIn("deals_played", result.summary)
self.assertIn("deal_scores", result.summary)
def test_two_player_result(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
_drive_to_finish(engine, session)
result = engine.result(session)
self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams)
self.assertEqual(2, len(result.players))
def test_result_requires_finished_match(self) -> None:
engine = BriscolaEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = BriscolaEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [55, 65]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 55, "B": 65}, over["scores"])
self.assertEqual("B", over["winner"])
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -48,8 +48,8 @@ itself ships no game.
Implement `GameEngine` (see `engine.py` for the full contract), register
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
`KayaApp` — see the composition root in `tavolo.app`. The canonical
example is
[`tavolo-scopone`](../tavolo-scopone/README.md).
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md).
## Development (from `server/`)
@@ -71,7 +71,7 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
}
for index, seat in enumerate(session.players)
],
"seats_open": engine.max_players - len(session.players),
"seats_open": max(0, engine.max_players - len(session.players)),
**engine.lobby_view(session),
}
@@ -85,6 +85,9 @@ def _view_payload(
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
@@ -36,9 +36,10 @@ Client -> server messages are JSON objects::
For scopone scientifico the game actions are ``play`` (with ``card``
and optionally ``capture``) and ``ack`` — see
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
``action``) is handed to the engine as the payload, games define their
own fields freely.
:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card``
plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole
message (minus ``action``) is handed to the engine as the payload, games
define their own fields freely.
Mutations run under the per-game lock; after a successful action the new
session is saved and a change signal is published. Every connected
@@ -101,6 +102,9 @@ def _state_message(
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
@@ -132,6 +132,33 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("state", update["type"])
self.assertEqual(1, update["game"]["plays"])
@async_test
async def test_state_reports_open_seats(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
game_id = created.json()["id"]
code = created.json()["join_code"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(1, first["game"]["seats_open"])
# Once the second player joins the match starts and no seat is
# left open.
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(0, first["game"]["seats_open"])
@async_test
async def test_unknown_action_returns_error(self) -> None:
app, platform, _ = make_platform()