Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user