Compare commits

...
Author SHA1 Message Date
woggioni 9eea293471 Give the page background a casino felt texture
CI / Build and push docker image (push) Successful in 59s
Replace the flat radial green with a layered body background: a soft
overhead light and an edge vignette over a fine grain tiled across the
green gradient.

The grain is a tiny seamless SVG tile (feTurbulence fractal noise mapped
to semi-transparent black speckles, stitchTiles so it repeats without
seams) committed under web/assets, so it rides the existing asset
pipeline and works offline; the palette and panel/card colors are
unchanged.
2026-10-09 09:27:05 +08:00
woggioni 4ba5ae1434 Give the match history table fixed columns
CI / Build and push docker image (push) Successful in 1m6s
Rows rendered only their own match's team labels while the header was
the union across all loaded matches. Scopone and briscola have two team
labels and briscola chiamata has three, so a mixed history produced a
five-column header above rows with two or three team cells - every cell
after them (score, winner, you, elo) landed under the wrong header, and
the columns changed with the set of game types loaded.

Replace the variable team columns with a single "Teams" column: the
table is now Finished | Game | Teams | Score | Winner | You | Elo for
every game. The cell lists each team or role on its own line, ordered
Chiamante/Socio/Difensori for chiamata and alphabetically (Team A,
Team B, ...) otherwise, with team-less players last; names read
"Bob, Carol & Dave".
2026-10-09 09:18:46 +08:00
woggioni 0f9fb9b6bb Make an absent chiamata bidder pass instead of bidding randomly
CI / Build and push docker image (push) Successful in 1m37s
The auction timeout bid a random legal rank, which could hand the
auction (and the call) to a disconnected player. An absent bidder now
always passes; only the last player standing before any bid cannot
pass, and is made to bid the ace - its suit is picked at random by the
existing call timeout. This keeps a deal from stalling without a caller
while never letting an absent player outbid someone.

The deadline log line now says which of the two happened.
2026-10-09 06:51:43 +08:00
woggioni 322ca27bf4 Stop the chiamata call picker from clipping on short windows
The call picker stacked the four 120px cards vertically, making the
dialog about 780px tall, and the overlay centered its content with no
scroll fallback. On typical laptop window heights that pushed the title
and options off-screen (the heading sat at y=-110 on a 560px viewport),
leaving what looked like a free-floating selection with no explanation
and no way to scroll.

Make dialogs scroll instead of clipping: the overlay scrolls and the
panel centers with `margin: auto`, which keeps every modal (the capture
picker and the scoring summaries too) centered when it fits and
reachable when it does not. Lay the call options out in a compact grid
(four across, 2x2 on phones) with 84px cards, name the won rank in the
title ("You won with the Re - name the card") and label each option
"Partner" or "Plays alone" so the choice reads at a glance.
2026-10-09 06:51:39 +08:00
woggioni 0674e06565 Redirect to login on 401 and return afterwards
The SPA now sends the user to a new /login page whenever an API call
answers 401, remembering the page they were on. The OIDC callback always
lands back on the configured post-login redirect, so the saved page is
restored client-side once a session exists.

- auth: save/restore the return path in sessionStorage; defer the
  navigation so the failing request can finish first
- api: centralize 401 handling (me() still treats 401 as logged out)
- login page + shared login card, extracted from the lobby
- guard async signal writes so an in-flight request cannot touch a page
  unmounted by the redirect
2026-10-09 05:53:21 +08:00
woggioni 353f012906 Add briscola chiamata
CI / Build and push docker image (push) Successful in 5m35s
Five-player briscola with a descending calling auction and a secret
partner. Starting with the player after the dealer, each player bids a
lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the
standing bid is the two the auction continues on points (62, 64, ...).
The last player standing must bid, and the winner names any card of the
won rank: its holder is the secret partner, or the caller plays alone
when it is one of their own cards, and its suit becomes the trump. The
calling side must reach 61 (or the raised target); a 60-60 deal with the
plain target is a patta and is replayed. A match is a single deal, so
the caller and partner (or the lone caller) against the other players
map onto the platform's two-team result, Elo and match history.

The rules live in tavolo.briscola.chiamata with the
BriscolaChiamataEngine adapter registered alongside the other briscola
variants; the partner's role and the side totals stay hidden until the
called card is played. The web client gets a five-seat table, the
auction panel with legal bids and point raises, the call picker, the
reveal bands and a chiamata-aware final summary.
2026-10-08 21:50:56 +08:00
31 changed files with 4670 additions and 217 deletions
+14 -5
View File
@@ -1,9 +1,10 @@
# tavolo
A platform for multiplayer card games. Two games are included:
A platform for multiplayer card games. Three games are included:
**scopone scientifico** — the four-player, fixed-partnership Italian card
game — and **briscola**, in its classic two-player and four-player
partnership (*briscola a quattro*) variants:
game — **briscola**, in its classic two-player and four-player
partnership (*briscola a quattro*) variants, and **briscola chiamata** —
the five-player variant with a calling auction and a secret partner:
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres.
@@ -45,7 +46,9 @@ pigliatutto when enabled), briscola players see the deal's points and any
*patta*; both see the running totals and must click "Understood" before
the next hand is dealt. If someone is away the next hand is dealt
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
match-ending hand is explained on the final screen.
match-ending hand is explained on the final screen. Briscola chiamata
plays a single deal per match, so it goes straight from the last trick to
the final scoring screen; a 60–60 *patta* is replayed immediately.
## On your turn
@@ -53,12 +56,18 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
player does not move — disconnected or fallen asleep — the server plays a
random legal card for them (choosing among the legal captures when the
rules require a capture), so one absent player cannot stall the table.
Briscola chiamata arms the same countdown for its auction turns and for
the auction winner's called-card choice: an absent bidder passes, the
last player standing with no bid is made to bid the ace, and a timed-out
call names a random suit of the won rank.
## Watching
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
spectators see the table, the score and every player's captured cards
(and scope for scopone), but never a hand and never the join code. The
(and scope for scopone), but never a hand and never the join code. In
briscola chiamata they also see no role labels and no side totals until
the called card is played, so the secret partner stays secret. The
"Copy watch link" button in the waiting room and in the game HUD copies
the URL to share. A spectator who opens the URL before the match starts
sees a "game not started yet" waiting room.
+57 -7
View File
@@ -16,9 +16,10 @@ and
scientifico, the four-player, fixed-partnership variant of the classic
Italian card game: pure rules plus the platform adapter.
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
Italian trick-taking game, in its classic two-player (`briscola`) and
four-player partnership (`briscola_a_quattro`) variants: pure rules
plus the platform adapter.
Italian trick-taking game, in its classic two-player (`briscola`),
four-player partnership (`briscola_a_quattro`) and five-player
called-partner (`briscola_chiamata`) variants: pure rules plus the
platform adapter.
- **`tavolo-app`** (`packages/tavolo-app/`) — the
composition root wiring the platform to the games, with the
environment-driven settings and the SPA shell.
@@ -236,7 +237,9 @@ aggregate with `max`, not `sum`.
as reported by the engine (`result` JSON — for scopone: both teams'
final scores, winner, target score, hands played and the per-hand
audit; for briscola: the final scores, winner, target score, deals
played and the per-deal audit).
played and the per-deal audit; for briscola chiamata: the calling side's
points against the defenders', the declared target, the called card and
whether the caller played alone).
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, game-defined team label (nullable), whether they won, the points
they scored, the Elo change the match produced (`elo_delta`) and
@@ -275,7 +278,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
@@ -341,6 +344,24 @@ For briscola (`briscola`, `briscola_a_quattro`) the game actions are
every player has acknowledged, or automatically after
`HAND_ACK_TIMEOUT_SECONDS`.
For briscola chiamata the game actions are the calling auction plus play:
```json
{"action": "bid", "rank": 3}
{"action": "bid", "points": 64}
{"action": "pass"}
{"action": "call", "card": "03D"}
{"action": "play", "card": "04C"}
```
- `bid` with a `rank` calls a lower rank than the standing bid
(`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the
target once the standing bid is the two.
- `pass` leaves the auction; the last player standing must bid.
- `call` names any card of the won rank; naming one of your own cards
means playing alone.
- `play` follows briscola: any card in hand is legal.
After every accepted move the new state is broadcast to every connected
client, spectators included.
@@ -353,7 +374,10 @@ is required), so a disconnected or idle player cannot stall the match. The
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
queue (see above), not from timers tied to client connections, so the
match keeps progressing even with every player disconnected.
match keeps progressing even with every player disconnected. Briscola
chiamata uses the same timeout for its auction turns (an absent bidder
passes; the last player standing with no bid is made to bid the ace) and
for the called-card choice (a random suit of the won rank).
### Hand-end summary
@@ -416,6 +440,31 @@ player must dismiss. A play attempted in this phase is rejected with an
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
opposite).
### Briscola chiamata
- 40-card Italian deck, eight cards each for five players: the whole deck
is dealt, so there is no stock and no face-up card.
- The calling auction starts with the player after the dealer: each
player either passes (leaving the auction) or bids a lower rank than
the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the
standing bid is the two, the auction continues on points (62, 64, …).
The last player standing must bid, so every deal gets a caller.
- The winner names any card of the won rank. If the winner holds it, the
caller plays alone against the other four; otherwise its holder is the
secret partner and the two form the calling side. The called card's
suit is the trump; the partner's identity stays hidden until the
called card is played.
- Play and trick resolution follow briscola; eight tricks exhaust every
hand. The calling side must reach the declared target (61 unless
raised); with the plain target a 60–60 deal is a *patta* and is
replayed with the next dealer. A match is a single deal, so the caller
and partner (or the lone caller) against the other players map onto
the platform's two-team result, Elo and history.
- Auction turns, the called-card choice and the plays all have their own
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
player standing with no bid is made to bid the ace, an absent winner's
call names a random suit of the won rank, and plays are auto-played.
## Development
```sh
@@ -493,8 +542,9 @@ packages/tavolo-briscola/ # briscola game (own pyproject + tests)
src/tavolo/briscola/
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
├── engine.py # pure briscola rules (2- and 4-player)
├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner)
├── errors.py # briscola-specific rule violations
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine
packages/tavolo-app/ # deployable app: composition root (own pyproject)
src/tavolo/
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
+11 -1
View File
@@ -39,7 +39,11 @@ from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.openapi import OpenAPIMixin
from kaya.session import SessionMixin
from redis.asyncio import Redis
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
from tavolo.briscola import (
BriscolaChiamataEngine,
BriscolaEngine,
BriscolaQuattroEngine,
)
from tavolo.platform import GameRegistry, Platform, PlatformMixin
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
@@ -144,6 +148,12 @@ registry.register(
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
registry.register(
BriscolaChiamataEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
log.debug(
"game timeouts: hand_ack=%ds turn=%ds",
+59 -17
View File
@@ -2,10 +2,12 @@
Briscola — the Italian trick-taking game — as a
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
Two variants share one rules core:
Three variants share one rules core:
- `briscola` — the classic two-player game;
- `briscola_a_quattro` — four-player fixed-partnership briscola.
- `briscola_a_quattro` — four-player fixed-partnership briscola;
- `briscola_chiamata` — five-player briscola with a calling auction and a
secret partner.
## Contents
@@ -13,27 +15,42 @@ Two variants share one rules core:
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, deals, trick resolution
(trump first, then the led suit; strength ace > three > king > horse >
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
jack 2), patta re-deals and the target-score match end, deal-end
acknowledgements and auto-play. Deterministic and I/O-free apart from
logging, so the whole rule set is unit-testable.
- `engine.py` — the pure rules engine for two- and four-player briscola:
deck, deals, trick resolution (trump first, then the led suit; strength
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2), the draw order
(trick winner first, the face-up briscola last), card points (ace 11,
three 10, king 4, horse 3, jack 2), patta re-deals and the
target-score match end, deal-end acknowledgements and auto-play.
Deterministic and I/O-free apart from logging, so the whole rule set is
unit-testable.
- `chiamata.py` — the pure rules engine for five-player briscola
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
round-trip), the 8-card deal with no stock, the descending calling
auction with point raises, the winner's call (including calling one's
own card, which means playing alone), the called-card reveal, single-
deal scoring with the 60–60 patta and the auto-actions for timeouts.
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
(`GameEngine`): the platform-facing adapters. They validate the
`target_score` creation option (61–120, default 61) and extract the
`MatchResult` (teams, winner, per-player scores and the match summary
persisted as the match's JSON `result`).
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
The first two validate the `target_score` creation option (61–120,
default 61) and extract the `MatchResult` (teams, winner, per-player
scores and the match summary persisted as the match's JSON `result`).
Briscola chiamata takes no creation options.
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
application composition root. Briscola chiamata uses the turn timeout
for its auction turns, the called-card choice and the plays: an absent
bidder passes, the last player standing with no bid is made to bid the
ace, and an absent winner's call names a random suit of the won rank. It
never enters the between-deals acknowledgement phase (a single deal ends
the match), so the hand-ack timeout is accepted but unused.
## Rules
### Briscola (two-player and four-player)
- Three cards per player; the next card is turned face up: its suit is
the trump (briscola) for the deal and it is the last card drawn.
- The winner of a trick is the highest trump, or the highest card of the
@@ -47,6 +64,31 @@ application composition root.
- Between deals of an unfinished match every player acknowledges the
summary (or the hand-end timeout deals on), exactly like scopone.
### Briscola chiamata (five-player)
- The whole 40-card deck is dealt out, eight cards each: there is no
stock and no face-up card.
- Starting with the player after the dealer, each player either passes
(leaving the auction for good) or bids a **lower** rank than the
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
the first bidder may bid any rank. Once the standing bid is the two,
the auction continues on points instead (62, 64, …): the calling side
then has to reach the raised target. The last player standing must
bid, so a deal always gets a caller.
- The auction winner names any card of the winning rank. If the winner
holds it, they **play alone** against the other four; otherwise its
holder is the **secret partner** and the two form the calling side.
The called card's suit is the trump for the deal. The partner's
identity is hidden until the called card is played.
- Play follows briscola: the player after the dealer leads, the highest
trump (or highest led-suit card) takes the trick and leads the next
one; eight tricks exhaust every hand.
- The calling side (caller plus partner, or the lone caller) must reach
the declared target — 61 unless the auction raised it. With the plain
target a 60–60 deal is a patta and is replayed with the next dealer.
A match is a single deal, so the two sides map onto the platform's
two-team result (Elo rating, match history).
## Development (from `server/`)
```sh
@@ -54,5 +96,5 @@ application composition root.
.venv/bin/python -m mypy -p tavolo.briscola
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
platform contract (actions, deadlines, serialization, results).
`test_engine.py`/`test_plugin.py` cover the two classic variants;
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
@@ -1,20 +1,31 @@
"""Briscola: tavolo's trick-taking game implementation.
Two variants share the same rules core and platform adapter:
Three variants share the same rules core and platform adapter:
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
briscola (``briscola``);
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
fixed-partnership briscola a quattro (``briscola_a_quattro``).
fixed-partnership briscola a quattro (``briscola_a_quattro``);
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
briscola chiamata (``briscola_chiamata``).
The pure rules (:mod:`tavolo.briscola.engine`,
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
:mod:`tavolo.briscola.plugin` adapts them to the platform's
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) know
nothing about HTTP, Redis or Postgres; :mod:`tavolo.briscola.plugin`
adapts them to the platform's
:class:`~tavolo.platform.engine.GameEngine` contract so the
game-independent platform can host them.
"""
from __future__ import annotations
from .plugin import BriscolaEngine, BriscolaQuattroEngine
from .plugin import (
BriscolaChiamataEngine,
BriscolaEngine,
BriscolaQuattroEngine,
)
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
__all__ = [
"BriscolaChiamataEngine",
"BriscolaEngine",
"BriscolaQuattroEngine",
]
@@ -0,0 +1,953 @@
"""Pure rules engine for briscola chiamata.
Briscola chiamata is the five-player variant of briscola. The 40-card
deck is dealt out entirely (8 cards each, no stock): there is no face-up
briscola card. Instead the players auction off the right to call a
partner:
* starting with the player after the dealer, each player either passes
(leaving the auction for good) or bids a rank lower than the standing
bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first
bidder may bid any rank. Once the standing bid is the 2, further bids
raise the points the calling side must reach instead (62, 64, ...).
* the last remaining bidder wins and names the exact card of the winning
rank (any suit). If the winner holds the named card they play alone
against the other four; otherwise its holder is the **secret partner**
and the two form the calling side. The called card's suit is the trump
(*briscola*) for the whole deal.
* play and scoring follow briscola. The calling side must reach the
declared target (61 unless raised); a 60-60 deal with the plain target
is a **patta** and is replayed.
A match is a single deal (patta replays aside), so the two sides map
onto the platform's two-team result (Elo rating, match history). The
partner's identity stays hidden until the called card is played, at
which point the roles are revealed.
Like :mod:`tavolo.briscola.engine` this module is deterministic and
I/O-free apart from logging: it mutates (or reads)
:class:`~tavolo.briscola.chiamata.ChiamataState` and raises
:class:`~tavolo.platform.errors.GameError` subclasses on rule
violations. The platform-facing adapter is
:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from logging import getLogger
from typing import Any, Dict, List, Optional, Tuple
from tavolo.platform.errors import (
AlreadyJoined,
GameFinished,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .engine import TRICK_STRENGTH, card_points, shuffled_deck
from .errors import CardNotInHand
from .state import (
DEFAULT_TURN_TIMEOUT_SECONDS,
PHASE_FINISHED,
PHASE_LOBBY,
PHASE_PLAYING,
SUITS,
Card,
Trick,
TrickCard,
parse_card,
)
log = getLogger(__name__)
# Five seats, the whole 40-card deck dealt out.
PLAYER_COUNT = 5
HAND_SIZE = 8
PHASE_AUCTION = "auction"
PHASE_CALLING = "calling"
# The two sides of the match: the caller (plus the partner, when there is
# one) against the other players.
SIDE_CALLER = 0
SIDE_DEFENDERS = 1
SIDE_NAMES = ("Chiamante", "Difensori")
# Role labels carried by the personalized view once they are public.
ROLE_CALLER = "Chiamante"
ROLE_PARTNER = "Socio"
ROLE_DEFENDER = "Difensori"
# Points the calling side must reach: 61 unless the auction raised it.
DEFAULT_TARGET = 61
MIN_TARGET = 61
MAX_TARGET = 120
# Rank ladder of the auction, strongest first: ace, three, king, horse,
# jack, then the plain ranks downwards.
BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)
_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)}
_rng = random.SystemRandom()
# -- state -----------------------------------------------------------------
@dataclass
class ChiamataPlayer:
sub: str
name: str
seat: int
hand: List[Card] = field(default_factory=list)
captured: List[Card] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": [c.to_json() for c in self.hand],
"captured": [c.to_json() for c in self.captured],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "ChiamataPlayer":
return ChiamataPlayer(
sub=str(data["sub"]),
name=str(data["name"]),
seat=int(data["seat"]),
hand=[Card.from_json(c) for c in data.get("hand", [])],
captured=[Card.from_json(c) for c in data.get("captured", [])],
)
@dataclass
class Bid:
"""One auction bid: a rank, or (at the 2) a raised points target."""
seat: int
rank: int
points: int
def to_json(self) -> Dict[str, Any]:
return {"seat": self.seat, "rank": self.rank, "points": self.points}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Bid":
return Bid(
seat=int(data["seat"]),
rank=int(data["rank"]),
points=int(data["points"]),
)
@dataclass
class Auction:
"""The state of the calling auction."""
# Seat to act; always one of ``active``.
turn: int = 0
active: List[int] = field(default_factory=list)
passed: List[int] = field(default_factory=list)
current: Optional[Bid] = None
history: List[Bid] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"turn": self.turn,
"active": list(self.active),
"passed": list(self.passed),
"current": self.current.to_json() if self.current else None,
"history": [b.to_json() for b in self.history],
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "Auction":
return Auction(
turn=int(data.get("turn", 0)),
active=[int(s) for s in data.get("active", [])],
passed=[int(s) for s in data.get("passed", [])],
current=(
Bid.from_json(data["current"]) if data.get("current") else None
),
history=[Bid.from_json(b) for b in data.get("history", [])],
)
@dataclass
class ChiamataState:
phase: str = PHASE_LOBBY
players: List[ChiamataPlayer] = field(default_factory=list)
dealer: int = 0
# 1-based deal counter; grows with patta replays.
hand_number: int = 1
# 0 = the calling side (caller plus partner, when there is one),
# 1 = the defenders. Only set once the match is finished.
winner: Optional[int] = None
scores: List[int] = field(default_factory=lambda: [0, 0])
# The calling auction of the current deal.
auction: Optional[Auction] = None
# Seat that won the auction (public from then on).
caller: Optional[int] = None
# The card named by the caller; its suit is the trump.
called_card: Optional[Card] = None
briscola_suit: str = ""
# Points target from the auction (61 unless raised).
declared_target: int = DEFAULT_TARGET
# True when the caller named a card of their own hand: no partner.
solo: bool = False
# True once the called card has been played; roles become public.
called_played: bool = False
# The partner's seat, recorded when the called card is played; stays
# ``None`` for a solo call.
partner_seat: Optional[int] = None
# Cards of the trick in progress, in play order.
trick: List[TrickCard] = field(default_factory=list)
trick_leader: int = 0
last_trick: Optional[Trick] = None
# Seat currently to act (auction bidder, caller, or player on turn).
turn: int = 0
# Per-deal audit trail, patta entries included.
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
# While phase == "auction"/"playing": when the server acts for the
# player on turn.
turn_deadline: Optional[str] = None
auction_deadline: Optional[str] = None
# While phase == "calling": when the server names a card for the
# auction winner.
call_deadline: Optional[str] = None
# Seconds a player has to act before the server acts for them.
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
# -- serialization ----------------------------------------------------
def to_json(self) -> Dict[str, Any]:
return {
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"dealer": self.dealer,
"hand_number": self.hand_number,
"winner": self.winner,
"scores": list(self.scores),
"auction": self.auction.to_json() if self.auction else None,
"caller": self.caller,
"called_card": (
self.called_card.to_json() if self.called_card else None
),
"briscola_suit": self.briscola_suit,
"declared_target": self.declared_target,
"solo": self.solo,
"called_played": self.called_played,
"partner_seat": self.partner_seat,
"trick": [t.to_json() for t in self.trick],
"trick_leader": self.trick_leader,
"last_trick": self.last_trick.to_json() if self.last_trick else None,
"turn": self.turn,
"hand_scores": list(self.hand_scores),
"turn_deadline": self.turn_deadline,
"auction_deadline": self.auction_deadline,
"call_deadline": self.call_deadline,
"turn_timeout": self.turn_timeout,
}
@staticmethod
def from_json(data: Dict[str, Any]) -> "ChiamataState":
return ChiamataState(
phase=str(data.get("phase", PHASE_LOBBY)),
players=[
ChiamataPlayer.from_json(p) for p in data.get("players", [])
],
dealer=int(data.get("dealer", 0)),
hand_number=int(data.get("hand_number", 1)),
winner=data.get("winner"),
scores=[int(x) for x in data.get("scores", [0, 0])],
auction=(
Auction.from_json(data["auction"]) if data.get("auction") else None
),
caller=data.get("caller"),
called_card=(
Card.from_json(data["called_card"])
if data.get("called_card")
else None
),
briscola_suit=str(data.get("briscola_suit", "")),
declared_target=int(data.get("declared_target", DEFAULT_TARGET)),
solo=bool(data.get("solo", False)),
called_played=bool(data.get("called_played", False)),
partner_seat=data.get("partner_seat"),
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
trick_leader=int(data.get("trick_leader", 0)),
last_trick=(
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
),
turn=int(data.get("turn", 0)),
hand_scores=list(data.get("hand_scores", [])),
turn_deadline=data.get("turn_deadline"),
auction_deadline=data.get("auction_deadline"),
call_deadline=data.get("call_deadline"),
turn_timeout=int(
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
),
)
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]:
for player in self.players:
if player.sub == sub:
return player
return None
def seated(self, sub: str) -> bool:
return self.player_for(sub) is not None
# -- game setup --------------------------------------------------------------
def create_game(
creator_sub: str,
creator_name: str,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> ChiamataState:
"""Create a lobby game with the creator seated first."""
return ChiamataState(
phase=PHASE_LOBBY,
players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)],
turn_timeout=turn_timeout,
)
def join_game(state: ChiamataState, sub: str, name: str) -> None:
"""Seat ``sub`` in the next free chair, starting the match when full."""
if state.phase != PHASE_LOBBY:
raise GameNotStarted("game has already started")
if state.seated(sub):
raise AlreadyJoined("already joined this game")
if len(state.players) >= PLAYER_COUNT:
raise LobbyFull("game is full")
seat = len(state.players)
state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat))
if len(state.players) == PLAYER_COUNT:
start_game(state)
def start_game(state: ChiamataState) -> None:
"""Deal the first hand and open the auction."""
if len(state.players) != PLAYER_COUNT:
raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start")
_deal(state)
def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer:
for player in state.players:
if player.seat == seat:
return player
raise IllegalMove(f"no player in seat {seat}")
def _set_deadline(seconds: int) -> str:
deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds)
return deadline.isoformat()
def _set_auction_deadline(state: ChiamataState) -> None:
state.auction_deadline = _set_deadline(state.turn_timeout)
def _set_call_deadline(state: ChiamataState) -> None:
state.call_deadline = _set_deadline(state.turn_timeout)
def _set_turn_deadline(state: ChiamataState) -> None:
state.turn_deadline = _set_deadline(state.turn_timeout)
def _deal(state: ChiamataState) -> None:
"""Deal 8 cards each and open the auction."""
deck = shuffled_deck()
for player in state.players:
player.hand = []
player.captured = []
state.trick = []
state.last_trick = None
state.turn_deadline = None
state.call_deadline = None
state.called_card = None
state.briscola_suit = ""
state.declared_target = DEFAULT_TARGET
state.solo = False
state.called_played = False
state.partner_seat = None
state.caller = None
state.winner = None
state.scores = [0, 0]
# The whole deck is dealt, one card at a time, starting with the
# player after the dealer.
for _ in range(HAND_SIZE):
for offset in range(PLAYER_COUNT):
_player_at(
state, (state.dealer + 1 + offset) % PLAYER_COUNT
).hand.append(deck.pop())
first = (state.dealer + 1) % PLAYER_COUNT
state.turn = first
state.trick_leader = first
state.auction = Auction(
turn=first,
active=[player.seat for player in state.players],
)
state.phase = PHASE_AUCTION
_set_auction_deadline(state)
log.debug(
"deal %d dealt (dealer seat %d), auction opens with seat %d",
state.hand_number,
state.dealer,
first,
)
# -- auction -----------------------------------------------------------------
def _require_auction(
state: ChiamataState, sub: str
) -> Tuple[Auction, ChiamataPlayer]:
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase != PHASE_AUCTION:
raise GameNotStarted("the auction is not open")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
auction = state.auction
if auction is None: # pragma: no cover - auction always exists here
raise GameNotStarted("the auction is not open")
if auction.turn != player.seat:
raise NotYourTurn("it is not your turn to bid")
return auction, player
def legal_bid_ranks(state: ChiamataState) -> List[int]:
"""The ranks the player on turn may bid, empty at the point stage."""
auction = state.auction
if state.phase != PHASE_AUCTION or auction is None:
return []
current = auction.current
if current is None:
return list(BID_LADDER)
if current.rank == 2:
return []
index = _BID_INDEX[current.rank]
return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index]
def legal_bid_points(state: ChiamataState) -> Optional[int]:
"""The lowest legal points raise, ``None`` when there is none.
Points may only be raised once the standing bid is the 2; any value
higher than the standing bid (up to :data:`MAX_TARGET`) is accepted.
"""
auction = state.auction
if state.phase != PHASE_AUCTION or auction is None:
return None
current = auction.current
if current is None or current.rank != 2:
return None
if current.points >= MAX_TARGET:
return None
return current.points + 2
def _finish_auction(state: ChiamataState) -> None:
"""The last active bidder wins and must name the called card."""
auction = state.auction
if auction is None: # pragma: no cover - guarded by callers
raise GameNotStarted("the auction is not open")
if len(auction.active) != 1 or auction.current is None: # pragma: no cover
raise IllegalMove("the auction cannot be concluded yet")
winner = auction.active[0]
state.caller = winner
state.declared_target = auction.current.points
state.turn = winner
state.phase = PHASE_CALLING
state.auction_deadline = None
_set_call_deadline(state)
log.debug(
"deal %d: seat %d wins the auction (rank %d, target %d)",
state.hand_number,
winner,
auction.current.rank,
auction.current.points,
)
def _advance_auction(state: ChiamataState) -> None:
auction = state.auction
if auction is None: # pragma: no cover - guarded by callers
return
for offset in range(1, PLAYER_COUNT + 1):
seat = (auction.turn + offset) % PLAYER_COUNT
if seat in auction.active:
auction.turn = seat
break
state.turn = auction.turn
_set_auction_deadline(state)
def _apply_bid(state: ChiamataState, bid: Bid) -> None:
auction = state.auction
if auction is None: # pragma: no cover - guarded by callers
raise GameNotStarted("the auction is not open")
auction.current = bid
auction.history.append(bid)
log.debug(
"deal %d: seat %d bids rank %d at %d",
state.hand_number,
bid.seat,
bid.rank,
bid.points,
)
if len(auction.active) == 1:
_finish_auction(state)
return
_advance_auction(state)
def bid_rank(state: ChiamataState, sub: str, rank: int) -> None:
"""Bid ``rank``; it must be weaker than the standing bid."""
auction, player = _require_auction(state, sub)
if rank not in _BID_INDEX:
raise IllegalMove(f"invalid rank: {rank!r}")
current = auction.current
if current is not None:
if current.rank == 2:
raise IllegalMove("the auction is at the 2: raise the points or pass")
if _BID_INDEX[rank] <= _BID_INDEX[current.rank]:
raise IllegalMove("a bid must call a lower rank than the standing bid")
_apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET))
def bid_points(state: ChiamataState, sub: str, points: int) -> None:
"""Raise the points target; only legal once the standing bid is the 2."""
auction, player = _require_auction(state, sub)
current = auction.current
if current is None or current.rank != 2:
raise IllegalMove("points can only be raised after a bid of the 2")
if points <= current.points:
raise IllegalMove(
f"the points must be higher than the standing bid ({current.points})"
)
if points > MAX_TARGET:
raise IllegalMove(f"the points must be at most {MAX_TARGET}")
_apply_bid(state, Bid(seat=player.seat, rank=2, points=points))
def pass_auction(state: ChiamataState, sub: str) -> None:
"""Leave the auction for good."""
auction, player = _require_auction(state, sub)
if player.seat not in auction.active: # pragma: no cover - turn implies active
raise IllegalMove("you already passed")
if len(auction.active) == 1 and auction.current is None:
# Everyone else passed before any bid: the last player must bid.
raise IllegalMove("you must bid: everyone else has passed")
auction.active.remove(player.seat)
auction.passed.append(player.seat)
log.debug("deal %d: seat %d passes", state.hand_number, player.seat)
if len(auction.active) == 1 and auction.current is not None:
_finish_auction(state)
return
_advance_auction(state)
# -- calling -------------------------------------------------------------------
def call(state: ChiamataState, sub: str, card_code: str) -> None:
"""The auction winner names the called card (any suit of the bid rank).
If the caller holds the named card they play alone; the card's suit
becomes the trump for the deal.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase != PHASE_CALLING:
raise GameNotStarted("no call is waiting")
caller = None if state.caller is None else _player_at(state, state.caller)
player = state.player_for(sub)
if player is None or caller is None or player.seat != caller.seat:
raise NotYourTurn("only the auction winner calls the card")
auction = state.auction
if auction is None or auction.current is None: # pragma: no cover
raise GameNotStarted("no call is waiting")
card = parse_card(card_code)
if card.rank != auction.current.rank:
raise IllegalMove(
"the called card must match the rank won in the auction"
)
state.called_card = card
state.briscola_suit = card.suit
state.solo = card in caller.hand
state.phase = PHASE_PLAYING
# The player after the dealer opens the play, whichever seat won the
# auction.
first = (state.dealer + 1) % PLAYER_COUNT
state.turn = first
state.trick_leader = first
state.call_deadline = None
_set_turn_deadline(state)
log.debug(
"deal %d: seat %d calls %s%s",
state.hand_number,
caller.seat,
card.code,
" (playing alone)" if state.solo else "",
)
# -- play ----------------------------------------------------------------------
def play(state: ChiamataState, sub: str, card_code: str) -> None:
"""Apply one move by the player identified by ``sub``.
Any card in hand is a legal play; only turn order and phase are
validated. Raises a :class:`~tavolo.platform.errors.GameError`
subclass on any violation.
"""
if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over")
if state.phase == PHASE_AUCTION:
raise IllegalMove("the auction is still open")
if state.phase == PHASE_CALLING:
raise IllegalMove("the called card has not been named yet")
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = state.player_for(sub)
if player is None or player.seat != state.turn:
raise NotYourTurn("it is not your turn")
card = parse_card(card_code)
if card not in player.hand:
raise CardNotInHand(f"card {card.code} is not in your hand")
player.hand.remove(card)
if state.called_card is not None and card == state.called_card:
state.called_played = True
state.partner_seat = (
None if player.seat == state.caller else player.seat
)
log.info(
"deal %d: the called card %s was played by seat %d (%s)",
state.hand_number,
card.code,
player.seat,
"the caller plays alone" if state.partner_seat is None else "partner revealed",
)
state.trick.append(TrickCard(seat=player.seat, card=card.code))
log.debug(
"deal %d: seat %d played %s (%d/%d in the trick)",
state.hand_number,
player.seat,
card.code,
len(state.trick),
PLAYER_COUNT,
)
if len(state.trick) == PLAYER_COUNT:
_resolve_trick(state)
else:
state.turn = (state.turn + 1) % PLAYER_COUNT
_set_turn_deadline(state)
def _resolve_trick(state: ChiamataState) -> None:
"""Score the completed trick, draw no cards (deck already exhausted)."""
winner = _trick_winner(state)
taken = [parse_card(t.card) for t in state.trick]
_player_at(state, winner).captured.extend(taken)
state.last_trick = Trick(winner=winner, cards=list(state.trick))
state.trick = []
state.trick_leader = winner
state.turn = winner
log.debug(
"deal %d: seat %d takes the trick with %s",
state.hand_number,
winner,
[t.card for t in state.last_trick.cards],
)
if all(not player.hand for player in state.players):
_end_deal(state)
return
_set_turn_deadline(state)
def _trick_winner(state: ChiamataState) -> int:
"""The seat winning the current (complete) trick."""
played = [(t.seat, parse_card(t.card)) for t in state.trick]
leader_suit = played[0][1].suit
trumps = [
(seat, card)
for seat, card in played
if card.suit == state.briscola_suit
]
pool = trumps or [
(seat, card) for seat, card in played if card.suit == leader_suit
]
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
# -- scoring -------------------------------------------------------------------
def side_points(state: ChiamataState) -> Tuple[int, int]:
"""The points captured by the calling side and the defenders.
The partner's pile only joins the caller's once the partner is known
(the called card has been played); until then this is only used to
score a finished deal, where the reveal has always happened.
"""
if state.caller is None:
return (0, 0)
caller_seats = {state.caller}
if state.partner_seat is not None:
caller_seats.add(state.partner_seat)
caller_points = 0
defender_points = 0
for player in state.players:
points = sum(card_points(card) for card in player.captured)
if player.seat in caller_seats:
caller_points += points
else:
defender_points += points
return (caller_points, defender_points)
def _end_deal(state: ChiamataState) -> None:
"""Score the deal: finish the match, or replay after a patta.
A 60-60 deal with the plain 61 target is a patta: its points do not
count, the dealer rotates and the deal is replayed (a new auction).
Otherwise the calling side wins by reaching the declared target.
"""
state.turn_deadline = None
caller_points, defender_points = side_points(state)
patta = (
state.declared_target == DEFAULT_TARGET
and caller_points == 60
and defender_points == 60
)
caller_name = (
_player_at(state, state.caller).name if state.caller is not None else None
)
caller_seats = {state.caller}
if state.partner_seat is not None:
caller_seats.add(state.partner_seat)
caller_cards = sum(
len(player.captured)
for player in state.players
if player.seat in caller_seats
)
entry: Dict[str, Any] = {
"deal": state.hand_number,
"patta": patta,
"points": {"A": caller_points, "B": defender_points},
"cards": {"A": caller_cards, "B": 40 - caller_cards},
"declared_target": state.declared_target,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"caller": caller_name,
"solo": state.solo,
}
state.hand_scores.append(entry)
log.debug(
"deal %d scored caller %d - defenders %d%s (target %d)",
state.hand_number,
caller_points,
defender_points,
" patta" if patta else "",
state.declared_target,
)
if patta:
state.hand_number += 1
state.dealer = (state.dealer + 1) % PLAYER_COUNT
_deal(state)
return
state.scores = [caller_points, defender_points]
state.winner = (
SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS
)
state.phase = PHASE_FINISHED
log.info(
"match ended, %s win %d-%d",
SIDE_NAMES[state.winner],
caller_points,
defender_points,
)
# -- server-side auto actions ---------------------------------------------------
def auto_bid(state: ChiamataState) -> None:
"""Act for the bidder on turn: always pass, unless forced to bid.
An absent player never outbids anyone: they leave the auction. Only
the last player standing before any bid cannot pass — they are made
to bid the ace, whose suit :func:`auto_call` picks at random if the
call itself times out too.
"""
if state.phase != PHASE_AUCTION:
raise GameNotStarted("the auction is not open")
auction = state.auction
if auction is None: # pragma: no cover - guarded by phase
raise GameNotStarted("the auction is not open")
player = _player_at(state, auction.turn)
if len(auction.active) == 1 and auction.current is None:
bid_rank(state, player.sub, 1)
return
pass_auction(state, player.sub)
def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
"""Name a random suit of the auction rank for the winner."""
if state.phase != PHASE_CALLING:
raise GameNotStarted("no call is waiting")
auction = state.auction
if auction is None or auction.current is None: # pragma: no cover
raise GameNotStarted("no call is waiting")
caller = _player_at(state, state.caller) if state.caller is not None else None
if caller is None: # pragma: no cover - guarded by phase
raise GameNotStarted("no call is waiting")
chooser = rng or _rng
card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS))
call(state, caller.sub, card.code)
def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
"""Play a random card for the player currently on turn."""
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = _player_at(state, state.turn)
if not player.hand:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
play(state, player.sub, card.code)
# -- views ---------------------------------------------------------------------
def _roles_public(state: ChiamataState) -> bool:
return state.called_played
def state_for_player(
state: ChiamataState, sub: Optional[str]
) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands and secret roles.
Hands are reduced to a count, except for the requesting player's own
hand. The partner's role only appears once the called card has been
played; before that only the caller is labeled. The calling side's
running total is withheld until then too: it would let onlookers
deduce the partner's identity from who captured the points.
"""
viewer = state.player_for(sub)
revealed = _roles_public(state)
players: List[Dict[str, object]] = []
for player in state.players:
role = ""
if state.caller is not None and player.seat == state.caller:
role = ROLE_CALLER
if revealed:
if state.partner_seat is not None and player.seat == state.partner_seat:
role = ROLE_PARTNER
elif player.seat != state.caller:
role = ROLE_DEFENDER
view: Dict[str, object] = {
"sub": player.sub,
"name": player.name,
"seat": player.seat,
"team": role,
"cards_left": len(player.hand),
"captured_count": len(player.captured),
"points": sum(card_points(card) for card in player.captured),
}
if viewer is not None and viewer.seat == player.seat:
view["hand"] = [c.code for c in player.hand]
players.append(view)
auction = state.auction
auction_view: Optional[Dict[str, object]] = None
if auction is not None:
auction_view = {
"turn": auction.turn,
"active": list(auction.active),
"passed": list(auction.passed),
"current": auction.current.to_json() if auction.current else None,
"history": [b.to_json() for b in auction.history],
}
payload: Dict[str, object] = {
"phase": state.phase,
"player_count": PLAYER_COUNT,
"dealer": state.dealer,
"hand_number": state.hand_number,
"turn": state.turn,
"auction": auction_view,
"caller": state.caller,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"briscola_suit": state.briscola_suit,
"declared_target": state.declared_target,
"called_played": state.called_played,
"solo": state.solo if revealed else False,
"partner": (
state.partner_seat if revealed and state.partner_seat is not None else None
),
"players": players,
"trick": [t.to_json() for t in state.trick],
"trick_leader": state.trick_leader,
"last_trick": state.last_trick.to_json() if state.last_trick else None,
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
"auction_deadline": state.auction_deadline,
"call_deadline": state.call_deadline,
"turn_deadline": state.turn_deadline,
"winner": (
None if state.winner is None else SIDE_NAMES[state.winner]
),
}
if revealed and state.caller is not None:
caller_points, defender_points = side_points(state)
payload["scores"] = {"A": caller_points, "B": defender_points}
if viewer is not None:
if state.phase == PHASE_AUCTION and auction is not None:
if auction.turn == viewer.seat:
payload["your_turn"] = True
payload["legal_bids"] = {
"ranks": legal_bid_ranks(state),
"points_min": legal_bid_points(state),
"points_max": MAX_TARGET,
}
elif state.phase == PHASE_CALLING and state.caller == viewer.seat:
payload["your_turn"] = True
current = auction.current if auction is not None else None
if current is not None:
payload["callable_cards"] = [
Card(rank=current.rank, suit=suit).code for suit in SUITS
]
elif state.phase == PHASE_PLAYING and state.turn == viewer.seat:
payload["your_turn"] = True
return payload
@@ -1,9 +1,11 @@
"""The platform-facing adapter for briscola.
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
rules core (:mod:`tavolo.briscola.engine`): classic two-player
:class:`BriscolaEngine` and four-player fixed-partnership
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
:class:`BriscolaEngine`, four-player fixed-partnership
:class:`BriscolaQuattroEngine` and five-player
:class:`BriscolaChiamataEngine`. They translate platform calls (create,
join, websocket actions, deadlines) into rules-engine calls and back,
keeping all briscola knowledge inside this package. Nothing outside
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
@@ -24,7 +26,18 @@ from tavolo.platform.engine import (
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from . import chiamata, engine
from .chiamata import (
PHASE_AUCTION,
PHASE_CALLING,
ROLE_CALLER,
ROLE_DEFENDER,
ROLE_PARTNER,
SIDE_NAMES,
ChiamataPlayer,
ChiamataState,
)
from .engine import card_points
from .state import (
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
DEFAULT_TARGET_SCORE,
@@ -277,3 +290,299 @@ class BriscolaQuattroEngine(_BriscolaBase):
min_players = 4
max_players = 4
player_count = 4
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
for player in state.players:
if player.seat == seat:
return player
raise GameError(f"no player in seat {seat}")
class BriscolaChiamataEngine(GameEngine):
"""Five-player briscola chiamata as a platform game engine.
One deal per match: the five players auction the call, the winner
names a card (playing alone when it is in their own hand) and the
calling side must reach the declared target (61 unless the auction
raised it). A 60-60 deal with the plain target is a patta and is
replayed. This keeps the match result a clean two-sided affair —
caller and secret partner against the other three, or the lone
caller against the other four — which the platform can rate,
persist and display like any other game.
"""
id = "briscola_chiamata"
name = "Briscola chiamata"
description = (
"Five-player briscola with a descending auction: the winner calls "
"a card, whose holder becomes the secret partner (or, when the "
"caller holds it, the caller plays alone) and whose suit becomes "
"the trump. The calling side must reach 61 points; one deal per "
"match, replayed after a patta."
)
min_players = 5
max_players = 5
options_schema: Mapping[str, Any] = {}
def __init__(
self,
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> None:
self._turn_timeout = turn_timeout_seconds
# A chiamata match is a single deal, so there is no between-deals
# acknowledgement phase to time out; the parameter is accepted
# for symmetry with the other briscola engines and wired by the
# application root.
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
creator = session.players[0]
session.state = chiamata.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
turn_timeout=self._turn_timeout,
)
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
chiamata.join_game(session.state, user_sub, display_name)
session.players.append(
Seat(user_sub=user_sub, display_name=display_name)
)
def in_lobby(self, session: GameSession) -> bool:
return session.state.phase == PHASE_LOBBY
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
state: ChiamataState = session.state
return {
"phase": state.phase,
"declared_target": state.declared_target,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: ChiamataState = session.state
if action == "bid":
rank = payload.get("rank")
points = payload.get("points")
if rank is not None:
if isinstance(rank, bool) or not isinstance(rank, int):
raise IllegalMove("'rank' must be an integer")
chiamata.bid_rank(state, user_sub, rank)
elif points is not None:
if isinstance(points, bool) or not isinstance(points, int):
raise IllegalMove("'points' must be an integer")
chiamata.bid_points(state, user_sub, points)
else:
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
elif action == "pass":
chiamata.pass_auction(state, user_sub)
elif action == "call":
card = payload.get("card")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
try:
chiamata.call(state, user_sub, card)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "play":
card = payload.get("card")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
try:
chiamata.play(state, user_sub, card)
except ValueError:
raise IllegalMove("invalid card code")
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
return chiamata.state_for_player(session.state, user_sub)
def is_finished(self, session: GameSession) -> bool:
return session.state.phase == PHASE_FINISHED
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
state: ChiamataState = session.state
caller = (
_chiamata_player(state, state.caller) if state.caller is not None else None
)
partner = (
_chiamata_player(state, state.partner_seat)
if state.partner_seat is not None
else None
)
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": (
None if state.winner is None else SIDE_NAMES[state.winner]
),
"declared_target": state.declared_target,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"caller": None if caller is None else caller.name,
"partner": None if partner is None else partner.name,
"solo": state.solo,
}
# -- (de)serialization -------------------------------------------------
def state_to_json(self, state: Any) -> Dict[str, Any]:
assert isinstance(state, ChiamataState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return ChiamataState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: ChiamataState = session.state
if state.phase == PHASE_AUCTION and state.auction_deadline:
due_ms = _deadline_ms(state.auction_deadline)
if due_ms is None:
return None
return Deadline(
kind="auction",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
)
if state.phase == PHASE_CALLING and state.call_deadline:
due_ms = _deadline_ms(state.call_deadline)
if due_ms is None:
return None
return Deadline(
kind="call",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"call:{state.hand_number}:{due_ms}",
)
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
if due_ms is None:
return None
return Deadline(
kind="turn",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
)
return None
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
# Overtaken by events (a bid, call or play landed in time):
# nothing to do.
raise GameError("stale deadline")
state: ChiamataState = session.state
if kind == "auction":
seat = state.turn
auction = state.auction
passed_before = len(auction.passed) if auction is not None else 0
chiamata.auto_bid(state)
action = (
"auto-passed"
if auction is not None and len(auction.passed) > passed_before
else "forced to bid the ace"
)
log.info(
"seat %s %s (auction timeout, deal %d)",
seat,
action,
state.hand_number,
)
elif kind == "call":
caller = state.caller
chiamata.auto_call(state)
log.info(
"auto-called for seat %s (call timeout, deal %d)",
caller,
state.hand_number,
)
elif kind == "turn":
seat = state.turn
chiamata.auto_play(state)
log.info(
"auto-played for seat %s (turn timeout, deal %d)",
seat,
state.hand_number,
)
else: # pragma: no cover - next_deadline never emits other kinds
raise GameError(f"unknown deadline kind: {kind!r}")
# -- results -------------------------------------------------------------
def result(self, session: GameSession) -> MatchResult:
state: ChiamataState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
if state.caller is None: # pragma: no cover - a winner implies a caller
raise GameError("no result: the caller is unknown")
caller = state.caller
partner = state.partner_seat
caller_seats = [caller] + ([partner] if partner is not None else [])
teams = [
[_chiamata_player(state, seat).sub for seat in caller_seats],
[
player.sub
for player in state.players
if player.seat not in caller_seats
],
]
caller_points, defender_points = chiamata.side_points(state)
side_scores = [caller_points, defender_points]
players = []
for player in state.players:
if player.seat == caller:
role = ROLE_CALLER
side = 0
elif partner is not None and player.seat == partner:
role = ROLE_PARTNER
side = 0
else:
role = ROLE_DEFENDER
side = 1
players.append(
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=side == state.winner,
team=role,
score=float(side_scores[side]),
details={
"points": sum(card_points(card) for card in player.captured),
"captured_cards": len(player.captured),
},
)
)
return MatchResult(
teams=teams,
winner_team=state.winner,
players=players,
summary={
"team_a_score": caller_points,
"team_b_score": defender_points,
"winner_team": SIDE_NAMES[state.winner],
"declared_target": state.declared_target,
"called_card": (
state.called_card.code if state.called_card is not None else None
),
"caller": _chiamata_player(state, caller).name,
"partner": (
_chiamata_player(state, partner).name
if partner is not None
else None
),
"solo": state.solo,
"deals_played": state.hand_number,
"deal_scores": state.hand_scores,
},
)
@@ -0,0 +1,667 @@
"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
from __future__ import annotations
import random
import unittest
from tavolo.briscola import chiamata as rules
from tavolo.briscola.chiamata import (
BID_LADDER,
Bid,
ChiamataPlayer,
ChiamataState,
PHASE_AUCTION,
PHASE_CALLING,
PHASE_FINISHED,
PHASE_PLAYING,
ROLE_CALLER,
ROLE_DEFENDER,
ROLE_PARTNER,
SIDE_CALLER,
SIDE_DEFENDERS,
)
from tavolo.briscola.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.briscola.state import Card
def card(code: str) -> Card:
return Card.parse(code)
def started_state() -> ChiamataState:
"""A five-player game whose auction has just opened (dealer seat 0)."""
state = rules.create_game("p0", "p0")
for i in range(1, 5):
rules.join_game(state, f"p{i}", f"p{i}")
return state
def bid(state: ChiamataState, seat: int, rank: int) -> None:
rules.bid_rank(state, f"p{seat}", rank)
def make_state(
hands,
*,
briscola_suit: str = "S",
called_card: str | None = None,
caller: int | None = None,
solo: bool = False,
called_played: bool = False,
partner_seat: int | None = None,
captured=None,
turn: int = 0,
dealer: int = 0,
declared_target: int = rules.DEFAULT_TARGET,
phase: str = PHASE_PLAYING,
hand_number: int = 1,
) -> ChiamataState:
"""Build a controlled playing state directly (bypassing the auction)."""
state = ChiamataState(
phase=phase,
dealer=dealer,
hand_number=hand_number,
declared_target=declared_target,
briscola_suit=briscola_suit,
called_card=card(called_card) if called_card else None,
caller=caller,
solo=solo,
called_played=called_played,
partner_seat=partner_seat,
turn=turn,
)
for seat, hand in enumerate(hands):
state.players.append(
ChiamataPlayer(
sub=f"p{seat}", name=f"p{seat}", seat=seat,
hand=[card(c) for c in hand],
)
)
if captured is not None:
for player, codes in zip(state.players, captured):
player.captured = [card(c) for c in codes]
return state
def play_all(state: ChiamataState) -> None:
"""Play the first card of each hand in turn order until the trick resolves."""
for _ in range(rules.PLAYER_COUNT):
player = next(p for p in state.players if p.seat == state.turn)
rules.play(state, player.sub, player.hand[0].code)
class SetupTest(unittest.TestCase):
def test_five_player_deal(self) -> None:
state = started_state()
self.assertEqual(PHASE_AUCTION, state.phase)
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
for player in state.players:
self.assertEqual(rules.HAND_SIZE, len(player.hand))
# The whole deck is dealt: no stock, no face-up card.
dealt = [c.code for p in state.players for c in p.hand]
self.assertEqual(40, len(dealt))
self.assertEqual(40, len(set(dealt)))
# The auction opens with the player after the dealer.
assert state.auction is not None
self.assertEqual(1, state.auction.turn)
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
self.assertIsNone(state.auction.current)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
state = rules.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
rules.join_game(state, "p0", "p0")
for i in range(1, 5):
rules.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_AUCTION, state.phase)
with self.assertRaises(GameNotStarted):
rules.join_game(state, "p5", "p5")
lobby = rules.create_game("p0", "p0")
for i in range(1, 5):
rules.join_game(lobby, f"p{i}", f"p{i}")
# Joining a sixth player is impossible once the fifth started the
# match; exercise LobbyFull on an artificially unfilled start.
unfilled = rules.create_game("p0", "p0")
unfilled.phase = rules.PHASE_LOBBY
for i in range(1, 5):
unfilled.players.append(
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
)
with self.assertRaises(LobbyFull):
rules.join_game(unfilled, "p5", "p5")
class AuctionTest(unittest.TestCase):
def test_first_bid_accepts_any_rank(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 5)
assert state.auction is not None
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
def test_bids_must_descend(self) -> None:
state = started_state()
bid(state, 1, 1) # ace
with self.assertRaises(IllegalMove):
bid(state, 2, 1) # same rank
bid(state, 2, 3) # three, weaker than the ace
bid(state, 3, 10) # king, weaker than the three
with self.assertRaises(IllegalMove):
bid(state, 4, 3) # three is stronger than the king
bid(state, 4, 9) # horse
def test_bid_ladder_ordering(self) -> None:
# A bid must be weaker than the standing bid: descending strength
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
state = started_state()
bid(state, 1, 10) # king
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
rules.bid_rank(state, f"p{opponent}", rank)
# 3 and A are stronger than 6: rejected.
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p1", 3)
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p1", 1)
rules.bid_rank(state, "p1", 5)
def test_not_your_turn(self) -> None:
state = started_state()
with self.assertRaises(NotYourTurn):
bid(state, 2, 1)
def test_pass_eliminates_and_last_standing_bids(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
assert state.auction is not None
# Only seat 0 remains and nobody has bid: it must bid.
self.assertEqual([0], state.auction.active)
with self.assertRaises(IllegalMove):
rules.pass_auction(state, "p0")
rules.bid_rank(state, "p0", 2)
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(0, state.caller)
self.assertEqual(61, state.declared_target)
def test_auction_ends_when_one_bidder_remains(self) -> None:
state = started_state()
bid(state, 1, 1) # ace
rules.pass_auction(state, "p2")
bid(state, 3, 10) # king
rules.pass_auction(state, "p4")
rules.pass_auction(state, "p0")
rules.pass_auction(state, "p1")
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(3, state.caller)
self.assertEqual(61, state.declared_target)
def test_point_raises_only_after_the_two(self) -> None:
state = started_state()
bid(state, 1, 1)
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p2", 62)
bid(state, 2, 2)
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p3", 2) # no ranks below the 2
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p3", 61) # not higher
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p3", 121) # above the cap
rules.bid_points(state, "p3", 63)
assert state.auction is not None
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
state = started_state()
bid(state, 1, 10) # king
view = rules.state_for_player(state, "p2")
self.assertTrue(view["your_turn"])
legal = view["legal_bids"]
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
self.assertIsNone(legal["points_min"])
# A player who is not on turn gets no legal bids.
other = rules.state_for_player(state, "p3")
self.assertNotIn("legal_bids", other)
self.assertNotIn("your_turn", other)
def test_legal_points_exposed_at_the_two(self) -> None:
state = started_state()
bid(state, 1, 2)
view = rules.state_for_player(state, "p2")
self.assertEqual([], view["legal_bids"]["ranks"])
self.assertEqual(63, view["legal_bids"]["points_min"])
self.assertEqual(120, view["legal_bids"]["points_max"])
def test_auto_bid_always_passes_when_legal(self) -> None:
state = started_state()
assert state.auction is not None
rules.auto_bid(state)
# Seat 1 passes: nobody has bid and the turn moves on.
self.assertEqual([1], state.auction.passed)
self.assertIsNone(state.auction.current)
self.assertEqual(2, state.auction.turn)
# An absent bidder still passes when there is a standing bid.
rules.bid_rank(state, "p2", 10)
rules.auto_bid(state)
self.assertEqual([1, 3], state.auction.passed)
assert state.auction.current is not None
self.assertEqual(10, state.auction.current.rank)
def test_auto_bid_passes_at_the_point_stage(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 2)
rules.bid_points(state, "p2", 62)
rules.auto_bid(state) # seat 3
assert state.auction is not None
self.assertIn(3, state.auction.passed)
assert state.auction.current is not None
self.assertEqual(62, state.auction.current.points)
def test_auto_bid_forced_to_call_the_ace(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
# Seat 0 is the last one standing and nobody has bid yet: the
# forced auto-bid calls the ace.
rules.auto_bid(state)
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(0, state.caller)
assert state.auction is not None and state.auction.current is not None
self.assertEqual(1, state.auction.current.rank)
self.assertEqual(61, state.declared_target)
class CallTest(unittest.TestCase):
def _called_state(self, rank: int = 1) -> ChiamataState:
state = started_state()
bid(state, 1, rank)
for seat in (2, 3, 4, 0):
rules.pass_auction(state, f"p{seat}")
return state
def test_call_must_match_the_winning_rank(self) -> None:
state = self._called_state(1)
with self.assertRaises(IllegalMove):
rules.call(state, "p1", "03D")
# Any suit of the winning rank is fine.
rules.call(state, "p1", "01D")
self.assertEqual("D", state.briscola_suit)
self.assertEqual(PHASE_PLAYING, state.phase)
def test_only_the_winner_calls(self) -> None:
state = self._called_state()
with self.assertRaises(NotYourTurn):
rules.call(state, "p2", "01D")
def test_call_in_hand_means_playing_alone(self) -> None:
state = self._called_state(1)
# Seat 1 calls a card it holds: solo.
state.players[1].hand[0] = card("01S")
rules.call(state, "p1", "01S")
self.assertTrue(state.solo)
self.assertIsNone(state.partner_seat)
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
state = self._called_state(1)
rules.call(state, "p1", "01D")
self.assertEqual(1, state.turn)
self.assertNotEqual(state.caller, None)
def test_auto_call_names_the_bid_rank(self) -> None:
state = self._called_state(10)
rules.auto_call(state, random.Random(3))
assert state.called_card is not None
self.assertEqual(10, state.called_card.rank)
self.assertEqual(PHASE_PLAYING, state.phase)
def test_calling_phase_view(self) -> None:
state = self._called_state(1)
view = rules.state_for_player(state, "p1")
self.assertEqual(PHASE_CALLING, view["phase"])
self.assertTrue(view["your_turn"])
self.assertEqual(
["01D", "01C", "01S", "01B"], view["callable_cards"]
)
other = rules.state_for_player(state, "p2")
self.assertNotIn("callable_cards", other)
class PlayTest(unittest.TestCase):
def test_five_player_trick_resolution(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
called_card="01D",
called_played=True,
partner_seat=4,
turn=0,
)
play_all(state)
assert state.last_trick is not None
self.assertEqual(3, state.last_trick.winner)
def test_trump_wins(self) -> None:
state = make_state(
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
briscola_suit="C",
caller=0,
called_card="01D",
called_played=True,
partner_seat=4,
turn=0,
)
play_all(state)
assert state.last_trick is not None
self.assertEqual(3, state.last_trick.winner)
def test_called_card_reveals_the_partner(self) -> None:
state = make_state(
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="D",
caller=0,
called_card="01S",
turn=1,
)
self.assertFalse(state.called_played)
view = rules.state_for_player(state, "p2")
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertNotIn("scores", view)
rules.play(state, "p1", "01D")
# The called card has not been played yet.
self.assertFalse(state.called_played)
# Drive the trick until the called card appears: give it to seat 3.
state.players[3].hand = [card("01S")]
state.turn = 3
rules.play(state, "p3", "01S")
self.assertTrue(state.called_played)
self.assertEqual(3, state.partner_seat)
view = rules.state_for_player(state, "p2")
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertEqual(ROLE_PARTNER, roles[3])
self.assertEqual(ROLE_DEFENDER, roles[1])
self.assertIn("scores", view)
def test_called_card_played_by_the_caller_is_solo(self) -> None:
state = make_state(
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
briscola_suit="S",
caller=0,
called_card="01S",
solo=True,
turn=0,
)
rules.play(state, "p0", "01S")
self.assertTrue(state.called_played)
self.assertIsNone(state.partner_seat)
view = rules.state_for_player(state, "p4")
self.assertTrue(view["solo"])
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertEqual(ROLE_DEFENDER, roles[1])
def test_play_validations(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
turn=0,
)
with self.assertRaises(NotYourTurn):
rules.play(state, "p1", "05D")
with self.assertRaises(CardNotInHand):
rules.play(state, "p0", "01D")
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
rules.play(state, "p0", "02D")
def test_play_rejected_before_the_call(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
caller=0,
turn=0,
phase=PHASE_CALLING,
)
with self.assertRaises(IllegalMove):
rules.play(state, "p0", "02D")
def test_auto_play_advances(self) -> None:
state = make_state(
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
called_card="09B",
turn=0,
)
rules.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.trick))
self.assertEqual(1, len(state.players[0].hand))
class ScoringTest(unittest.TestCase):
def _piles(self):
# 60 points in denari + coppe, 60 in spade + bastoni.
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
]
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
]
return a_cards, b_cards
def test_side_points_split_caller_side_and_defenders(self) -> None:
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=3,
called_played=True,
captured=[
["01D"], # caller, side A: 11
["03C", "01S"], # defender: 21
["10D"], # defender: 4
["01C"], # partner, side A: 11
["03S"], # defender: 10
],
)
caller_points, defender_points = rules.side_points(state)
self.assertEqual(22, caller_points)
self.assertEqual(35, defender_points)
def test_solo_side_points(self) -> None:
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
solo=True,
called_played=True,
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
)
caller_points, defender_points = rules.side_points(state)
self.assertEqual(11, caller_points)
self.assertEqual(35, defender_points)
def test_calling_side_wins_at_the_target(self) -> None:
a_cards, b_cards = self._piles()
b_rest = [c for c in b_cards if c != "01S"]
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards, ["01S"], [], b_rest, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_CALLER, state.winner)
self.assertEqual([71, 49], state.scores)
def test_defenders_win_below_the_declared_target(self) -> None:
a_cards, b_cards = self._piles()
# Calling side only 30 points of the 61 needed.
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards[:5], [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_DEFENDERS, state.winner)
self.assertEqual([30, 60], state.scores)
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
a_cards, b_cards = self._piles()
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards, [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_AUCTION, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual(1, state.dealer)
assert state.auction is not None
self.assertEqual(2, state.auction.turn)
self.assertIsNone(state.caller)
self.assertIsNone(state.called_card)
# The patta entry is kept in the audit trail.
entry = state.hand_scores[-1]
self.assertTrue(entry["patta"])
self.assertEqual({"A": 60, "B": 60}, entry["points"])
def test_raised_target_disables_patta(self) -> None:
a_cards, b_cards = self._piles()
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
declared_target=62,
captured=[a_cards, [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_DEFENDERS, state.winner)
def test_full_deal_scores_120(self) -> None:
state = started_state()
rng = random.Random(2026)
moves = 0
while state.phase != PHASE_FINISHED and moves < 5000:
if state.phase == PHASE_AUCTION:
rules.auto_bid(state)
elif state.phase == PHASE_CALLING:
rules.auto_call(state, rng)
else:
rules.auto_play(state, rng)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(120, sum(state.scores))
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
captured = sum(len(p.captured) for p in state.players)
self.assertEqual(40, captured)
class ViewTest(unittest.TestCase):
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=0,
called_card="01D",
called_played=False,
turn=0,
)
view = rules.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(["02D", "03C"], players[0]["hand"])
self.assertNotIn("hand", players[1])
self.assertEqual(1, players[1]["cards_left"])
self.assertEqual("01D", view["called_card"])
self.assertEqual(5, view["player_count"])
def test_spectator_sees_public_information_only(self) -> None:
state = make_state(
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=2,
called_card="01D",
called_played=False,
)
view = rules.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("scores", view)
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[2])
self.assertEqual("", roles[0])
def test_player_points_are_public(self) -> None:
state = make_state(
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=0,
captured=[["01C", "03B"], ["10D"], [], [], []],
)
view = rules.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(21, players[0]["points"])
self.assertEqual(4, players[1]["points"])
self.assertEqual(2, players[0]["captured_count"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = started_state()
bid(state, 1, 10)
bid(state, 2, 7)
restored = ChiamataState.from_json(state.to_json())
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.hand_number, restored.hand_number)
self.assertEqual(state.turn, restored.turn)
assert restored.auction is not None and state.auction is not None
self.assertEqual(state.auction.active, restored.auction.active)
self.assertEqual(state.auction.passed, restored.auction.passed)
self.assertEqual(
state.auction.history, restored.auction.history
)
self.assertEqual(
[[c.code for c in p.hand] for p in state.players],
[[c.code for c in p.hand] for p in restored.players],
)
def test_legacy_state_defaults(self) -> None:
state = started_state()
data = state.to_json()
del data["auction"]
del data["called_card"]
del data["declared_target"]
restored = ChiamataState.from_json(data)
self.assertIsNone(restored.auction)
self.assertIsNone(restored.called_card)
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
self.assertFalse(restored.solo)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,421 @@
"""Adapter tests: the platform contract over the briscola chiamata rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.briscola import BriscolaChiamataEngine
from tavolo.briscola import chiamata as rules
from tavolo.briscola.chiamata import (
PHASE_AUCTION,
PHASE_CALLING,
PHASE_FINISHED,
PHASE_LOBBY,
PHASE_PLAYING,
ROLE_CALLER,
ROLE_DEFENDER,
ROLE_PARTNER,
ChiamataState,
)
from tavolo.briscola.state import Card
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameNotStarted,
IllegalMove,
)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="c1",
game_type=engine.id,
join_code="CODE01",
creator_sub="p0",
players=[Seat(user_sub="p0", display_name="P0")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for i in range(1, engine.max_players):
engine.join(session, f"p{i}", f"P{i}")
return session
def _player_on_turn(session: GameSession):
state = session.state
return next(p for p in state.players if p.seat == state.turn)
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
moves = 0
while not engine.is_finished(session) and moves < limit:
state = session.state
if state.phase == PHASE_AUCTION:
player = _player_on_turn(session)
try:
engine.handle_action(session, player.sub, "pass", {})
except IllegalMove:
# The last player standing must bid.
engine.handle_action(session, player.sub, "bid", {"rank": 2})
elif state.phase == PHASE_CALLING:
assert state.auction is not None and state.auction.current is not None
caller = next(
p for p in state.players if p.seat == state.caller
)
card = Card(rank=state.auction.current.rank, suit="D")
engine.handle_action(session, caller.sub, "call", {"card": card.code})
else:
player = _player_on_turn(session)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
assert engine.is_finished(session), "match did not finish"
class MetadataTest(unittest.TestCase):
def test_metadata(self) -> None:
engine = BriscolaChiamataEngine()
self.assertEqual("briscola_chiamata", engine.id)
self.assertEqual("Briscola chiamata", engine.name)
self.assertTrue(engine.description)
self.assertEqual(5, engine.min_players)
self.assertEqual(5, engine.max_players)
self.assertEqual({}, engine.options_schema)
class CreateJoinTest(unittest.TestCase):
def test_create_seats_creator_without_a_team(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
self.assertIsNone(session.players[0].team)
self.assertIsInstance(session.state, ChiamataState)
self.assertEqual(PHASE_LOBBY, session.state.phase)
self.assertTrue(engine.in_lobby(session))
def test_join_starts_when_full(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
for i in range(1, 4):
engine.join(session, f"p{i}", f"P{i}")
self.assertTrue(engine.in_lobby(session))
engine.join(session, "p4", "P4")
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_AUCTION, session.state.phase)
self.assertEqual(5, len(session.players))
for seat in session.players:
self.assertIsNone(seat.team)
def test_join_errors(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "p0", "P0")
for i in range(1, 5):
engine.join(session, f"p{i}", f"P{i}")
with self.assertRaises(GameNotStarted):
engine.join(session, "p5", "P5")
def test_timeouts_come_from_the_engine(self) -> None:
engine = BriscolaChiamataEngine(
turn_timeout_seconds=7, hand_ack_timeout_seconds=9
)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "dance", {})
def test_bid_payload_validation(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"rank": "ace"})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"rank": True})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"points": False})
# points cannot be raised before a bid of the 2
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "bid", {"points": 64})
def test_auction_flow_through_actions(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 1})
engine.handle_action(session, "p2", "pass", {})
engine.handle_action(session, "p3", "bid", {"rank": 2})
engine.handle_action(session, "p4", "bid", {"points": 64})
engine.handle_action(session, "p0", "pass", {})
engine.handle_action(session, "p1", "pass", {})
engine.handle_action(session, "p3", "pass", {})
state = session.state
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(4, state.caller)
self.assertEqual(64, state.declared_target)
assert state.auction is not None and state.auction.current is not None
self.assertEqual(2, state.auction.current.rank)
engine.handle_action(session, "p4", "call", {"card": "02S"})
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual("S", state.briscola_suit)
# Play the first card of the player on turn.
player = _player_on_turn(session)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
self.assertEqual(1, len(state.trick))
def test_call_payload_validation(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 2})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "call", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "call", {"card": "nope"})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "call", {"card": "01D"})
def test_finished_match_rejects_actions(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
_drive_to_finish(engine, session)
with self.assertRaises(GameError):
engine.handle_action(session, session.players[0].user_sub, "play", {})
class ViewTest(unittest.TestCase):
def test_view_hides_other_hands_and_roles(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
view = engine.view_for(session, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
self.assertNotIn("your_turn", view)
self.assertNotIn("scores", view)
self.assertEqual(5, view["player_count"])
# p1 opens the auction.
auction = view["auction"]
self.assertEqual(1, auction["turn"])
self.assertIsNone(auction["current"])
def test_view_marks_the_acting_player(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
view = engine.view_for(session, "p1")
self.assertTrue(view["your_turn"])
self.assertIn("legal_bids", view)
# The caller is labeled as soon as the auction is won.
engine.handle_action(session, "p1", "bid", {"rank": 1})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
view = engine.view_for(session, "p0")
self.assertIn("callable_cards", engine.view_for(session, "p1"))
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[1])
def test_spectator_view(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
def test_lobby_view(self) -> None:
engine = BriscolaChiamataEngine()
session = _session(engine)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(61, lobby["declared_target"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 10})
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, ChiamataState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
assert restored.auction is not None and session.state.auction is not None
self.assertEqual(session.state.auction.history, restored.auction.history)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = BriscolaChiamataEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_auction_deadline_and_revalidation(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("auction", deadline.kind)
with self.assertRaises(GameError):
engine.fire_deadline(session, "auction", "auction:1:1:0")
engine.fire_deadline(session, deadline.kind, deadline.token)
# The auto-bid moved the auction on: a fresh deadline exists.
state = session.state
self.assertIn(
state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING)
)
self.assertIsNotNone(engine.next_deadline(session))
def test_stale_auction_deadline_after_a_bid(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
engine.handle_action(session, "p1", "bid", {"rank": 1})
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_call_deadline_auto_calls(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 2})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("call", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
state = session.state
self.assertEqual(PHASE_PLAYING, state.phase)
assert state.called_card is not None
self.assertEqual(2, state.called_card.rank)
def test_turn_deadline_auto_plays(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
engine.handle_action(session, "p1", "bid", {"rank": 2})
for i in (2, 3, 4, 0):
engine.handle_action(session, f"p{i}", "pass", {})
engine.handle_action(session, "p1", "call", {"card": "02D"})
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
state = session.state
before = state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(1, len(state.trick))
self.assertNotEqual(before, state.turn)
class ResultTest(unittest.TestCase):
def test_result_of_finished_match(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
_drive_to_finish(engine, session)
state = session.state
result = engine.result(session)
sizes = sorted(len(team) for team in result.teams)
self.assertIn(sizes, ([2, 3], [1, 4]))
self.assertEqual(5, len(result.players))
self.assertIn(result.winner_team, (0, 1))
for player in result.players:
self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER))
self.assertEqual(
player.won,
(player.team in (ROLE_CALLER, ROLE_PARTNER))
== (result.winner_team == 0),
)
self.assertIn("points", player.details)
summary = result.summary
for key in (
"team_a_score",
"team_b_score",
"winner_team",
"declared_target",
"called_card",
"caller",
"solo",
"deals_played",
"deal_scores",
):
self.assertIn(key, summary)
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
self.assertEqual(state.hand_number, summary["deals_played"])
def test_result_requires_finished_match(self) -> None:
engine = BriscolaChiamataEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_solo_result_teams(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
state = session.state
# Force a finished solo match: seat 0 called its own card.
state.caller = 0
state.solo = True
state.partner_seat = None
state.called_played = True
state.called_card = Card.parse("01D")
# Caller side captures 71 (60 + 11), defenders 49.
state.players[0].captured = [
Card.parse(code)
for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)]
+ [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)]
+ ["01S"]
]
state.players[1].captured = [
Card.parse(code)
for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)]
+ [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)]
]
state.scores = [71, 49]
state.winner = 0
state.phase = PHASE_FINISHED
result = engine.result(session)
self.assertEqual([1, 4], sorted(len(team) for team in result.teams))
# The lone caller won: exactly one winner.
self.assertEqual(1, sum(1 for p in result.players if p.won))
caller = next(p for p in result.players if p.team == ROLE_CALLER)
self.assertTrue(caller.won)
self.assertEqual(71.0, caller.score)
def test_game_over_view(self) -> None:
engine = BriscolaChiamataEngine()
session = _started(engine)
state = session.state
state.phase = PHASE_FINISHED
state.winner = 1
state.scores = [55, 65]
state.caller = 2
state.solo = False
state.partner_seat = 4
state.called_card = Card.parse("03C")
over = engine.game_over_view(session, "p0")
self.assertEqual({"A": 55, "B": 65}, over["scores"])
self.assertEqual("Difensori", over["winner"])
self.assertEqual("03C", over["called_card"])
if __name__ == "__main__":
unittest.main()
+390
View File
@@ -0,0 +1,390 @@
"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts.
The per-package suite covers the rules and the engine contract; these
tests wire the game through the full platform stack (REST, WebSocket,
deadline scheduler).
"""
from __future__ import annotations
import asyncio
import unittest
from datetime import datetime, timedelta, timezone
from httpx import ASGITransport, AsyncClient
from httpx_ws import aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from tavolo.app import app, game_store, platform
from tavolo.briscola.chiamata import (
PHASE_AUCTION,
PHASE_CALLING,
PHASE_PLAYING,
Auction,
Bid,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
async def _create_and_join(client: AsyncClient) -> dict:
"""Create a chiamata game and seat all five players; return the view."""
with oidc_user(PLAYERS[0]):
created = await client.post("/api/games", json={"game_type": "briscola_chiamata"})
assert created.status_code == 201, created.text
code = created.json()["join_code"]
body = created.json()
for player in PLAYERS[1:]:
with oidc_user(player):
joined = await client.post("/api/games/join", json={"code": code})
assert joined.status_code == 200, joined.text
body = joined.json()
return body
class ChiamataRoutesTest(unittest.TestCase):
@async_test
async def test_create_and_seat_five_players(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(PLAYERS[0]):
created = await client.post(
"/api/games", json={"game_type": "briscola_chiamata"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("briscola_chiamata", body["game_type"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(4, body["seats_open"])
self.assertIsNone(body["players"][0]["team"])
code = body["join_code"]
for player in PLAYERS[1:4]:
with oidc_user(player):
joined = await client.post("/api/games/join", json={"code": code})
self.assertEqual("lobby", joined.json()["phase"])
self.assertEqual(
4 - (PLAYERS.index(player)), joined.json()["seats_open"]
)
with oidc_user(PLAYERS[4]):
started = await client.post("/api/games/join", json={"code": code})
state = started.json()
self.assertEqual(PHASE_AUCTION, state["phase"])
self.assertEqual(0, state["seats_open"])
self.assertEqual(5, len(state["players"]))
for seat in state["players"]:
# Roles are hidden until the called card is played.
self.assertEqual("", seat["team"])
self.assertEqual(8, seat["cards_left"])
# The auction opens with the player after the dealer (seat 0).
self.assertEqual(1, state["auction"]["turn"])
self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4])
self.assertIsNone(state["auction"]["current"])
self.assertIsNotNone(state["auction_deadline"])
# The last joiner's personalized view carries their own hand.
self.assertIn("hand", state["players"][4])
self.assertEqual(8, len(state["players"][4]["hand"]))
self.assertNotIn("hand", state["players"][0])
# There is no stock in this variant.
self.assertNotIn("stock_count", state)
# A non-player watches without hands or the join code.
with oidc_user("mallory"):
watched = await client.get(f"/api/games/{state['id']}")
self.assertEqual(200, watched.status_code)
spectator = watched.json()
self.assertTrue(spectator["spectator"])
self.assertNotIn("join_code", spectator)
for seat in spectator["players"]:
self.assertNotIn("hand", seat)
@async_test
async def test_preview_lists_chiamata(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games", json={"game_type": "briscola_chiamata"}
)
code = created.json()["join_code"]
with oidc_user("bob"):
preview = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, preview.status_code)
body = preview.json()
self.assertEqual("briscola_chiamata", body["game_type"])
self.assertEqual("Briscola chiamata", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(4, body["seats_open"])
class ChiamataWebSocketTest(unittest.TestCase):
@async_test
async def test_auction_call_and_play_over_the_socket(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await _create_and_join(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob"), make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
self.assertTrue(first["game"].get("your_turn"))
self.assertIn("legal_bids", first["game"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
# Bob (seat 1) opens the bidding with the two.
await bob_ws.send_json({"action": "bid", "rank": 2})
update = await bob_ws.receive_json()
self.assertEqual("state", update["type"])
current = update["game"]["auction"]["current"]
self.assertEqual(
{"seat": 1, "rank": 2, "points": 61}, current
)
self.assertEqual(2, update["game"]["auction"]["turn"])
# Alice receives the broadcast of Bob's bid.
broadcast = await alice_ws.receive_json()
self.assertEqual("state", broadcast["type"])
# Alice (seat 0) is not on turn: her bid is an error.
await alice_ws.send_json({"action": "bid", "rank": 3})
error = await alice_ws.receive_json()
self.assertEqual("error", error["type"])
# The remaining players pass; Bob wins the auction and is
# asked to name the card.
engine = platform.registry.require("briscola_chiamata")
session = await game_store.load(game_id)
assert session is not None
for sub in ("carol", "dave", "erin", "alice"):
engine.handle_action(session, sub, "pass", {})
await game_store.save(session)
self.assertEqual(PHASE_CALLING, session.state.phase)
self.assertEqual(1, session.state.caller)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
self.assertTrue(first["game"].get("your_turn"))
self.assertEqual(
["02D", "02C", "02S", "02B"],
first["game"]["callable_cards"],
)
await ws.send_json(
{"action": "call", "card": "02D"}
)
update = await ws.receive_json()
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
self.assertEqual("D", update["game"]["briscola_suit"])
self.assertEqual("02D", update["game"]["called_card"])
self.assertEqual(1, update["game"]["turn"])
# Bob is first to play (player after the dealer).
bob = next(
p for p in update["game"]["players"] if p["seat"] == 1
)
card = bob["hand"][0]
await ws.send_json({"action": "play", "card": card})
update = await ws.receive_json()
self.assertEqual(
[{"seat": 1, "card": card}], update["game"]["trick"]
)
@async_test
async def test_spectator_cannot_act(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await _create_and_join(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
for seat in first["game"]["players"]:
self.assertNotIn("hand", seat)
await ws.send_json({"action": "bid", "rank": 1})
error = await ws.receive_json()
self.assertEqual("forbidden", error["code"])
class ChiamataTimeoutTest(unittest.TestCase):
async def _seed(self, game_id: str, join_code: str, state_mutator) -> str:
from tavolo.briscola import chiamata as rules
session = GameSession(
id=game_id,
game_type="briscola_chiamata",
join_code=join_code,
creator_sub=PLAYERS[0],
players=[
Seat(user_sub=name, display_name=name.capitalize())
for name in PLAYERS
],
)
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
for name in PLAYERS[1:]:
rules.join_game(session.state, name, name.capitalize())
state_mutator(session.state)
await game_store.save(session)
return session.id
@async_test
async def test_auction_timeout_auto_passes(self) -> None:
game_id = await self._seed(
"chiamata-auction-timeout-1", "CH0001", lambda state: None
)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
self.assertIsNotNone(first["game"]["auction_deadline"])
update = None
for _ in range(20):
try:
update = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
update.get("type") == "state"
and 1 in update["game"]["auction"]["passed"]
):
break
self.assertIsNotNone(update)
assert update is not None
# The absent seat 1 passed: nobody bid and the turn
# moved to seat 2.
auction = update["game"]["auction"]
self.assertIn(1, auction["passed"])
self.assertIsNone(auction["current"])
self.assertEqual(2, auction["turn"])
@async_test
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
def seed(state) -> None:
state.phase = PHASE_AUCTION
state.auction = Auction(
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
)
state.turn = 0
state.auction_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=1)
).isoformat()
state.call_deadline = None
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
# The auction times out: seat 0 is forced to bid the
# ace; its call then times out too and a random ace
# suit is named.
played = None
for _ in range(20):
try:
played = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
played.get("type") == "state"
and played["game"]["phase"] == PHASE_PLAYING
):
break
self.assertIsNotNone(played)
assert played is not None
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
self.assertEqual(0, played["game"]["caller"])
card = played["game"]["called_card"]
self.assertIsNotNone(card)
self.assertTrue(card.startswith("01"))
@async_test
async def test_call_and_turn_timeouts(self) -> None:
def seed(state) -> None:
state.phase = PHASE_CALLING
state.auction = Auction(
turn=1,
active=[1],
passed=[0, 2, 3, 4],
current=Bid(seat=1, rank=2, points=61),
)
state.caller = 1
state.turn = 1
state.auction_deadline = None
state.call_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=1)
).isoformat()
game_id = await self._seed(
"chiamata-call-timeout-1", "CH0002", seed
)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
called = None
for _ in range(20):
try:
called = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
called.get("type") == "state"
and called["game"]["phase"] == PHASE_PLAYING
):
break
self.assertIsNotNone(called)
assert called is not None
self.assertEqual(PHASE_PLAYING, called["game"]["phase"])
card = called["game"]["called_card"]
self.assertIsNotNone(card)
self.assertTrue(card.startswith("02"))
played = None
for _ in range(20):
try:
played = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
played.get("type") == "state"
and len(played["game"]["trick"]) == 1
):
break
self.assertIsNotNone(played)
assert played is not None
# Seat 1 was on turn and was auto-played for.
self.assertEqual(1, played["game"]["trick"][0]["seat"])
if __name__ == "__main__":
unittest.main()
+12 -1
View File
@@ -300,7 +300,12 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(
["scopone_scientifico", "briscola", "briscola_a_quattro"],
[
"scopone_scientifico",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
],
[g["id"] for g in results],
)
scopone = results[0]
@@ -325,6 +330,12 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(4, quattro["min_players"])
self.assertEqual(4, quattro["max_players"])
self.assertIn("target_score", quattro["options_schema"]["properties"])
chiamata = results[3]
self.assertEqual("Briscola chiamata", chiamata["name"])
self.assertTrue(chiamata["description"])
self.assertEqual(5, chiamata["min_players"])
self.assertEqual(5, chiamata["max_players"])
self.assertEqual({}, chiamata["options_schema"])
@async_test
async def test_create_defaults_game_type(self) -> None:
+96
View File
@@ -12,8 +12,11 @@ import unittest
from httpx import ASGITransport, AsyncClient
from tavolo.app import app, platform, tortoise_mixin
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
from tavolo.briscola.state import Card
from tavolo.platform import GameSession, Seat
from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.errors import IllegalMove
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from tavolo.scopone import engine as rules
@@ -222,5 +225,98 @@ class BriscolaStatsTest(unittest.TestCase):
self.assertEqual(2, len(results[0]["players"]))
async def _finished_chiamata_session() -> GameSession:
engine = platform.registry.require("briscola_chiamata")
session = GameSession(
id="stats-chiamata-1",
game_type=engine.id,
join_code="BC0001",
creator_sub="alice",
players=[
Seat(user_sub=name, display_name=name.capitalize())
for name in ("alice", "bob", "carol", "dave", "erin")
],
)
engine.create(session, {})
for name in ("bob", "carol", "dave", "erin"):
engine.join(session, name, name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == PHASE_AUCTION:
player = next(p for p in state.players if p.seat == state.turn)
try:
engine.handle_action(session, player.sub, "pass", {})
except IllegalMove:
engine.handle_action(session, player.sub, "bid", {"rank": 2})
elif state.phase == PHASE_CALLING:
assert state.auction is not None and state.auction.current is not None
caller = next(p for p in state.players if p.seat == state.caller)
card = Card(rank=state.auction.current.rank, suit="D")
engine.handle_action(session, caller.sub, "call", {"card": card.code})
else:
player = next(p for p in state.players if p.seat == state.turn)
engine.handle_action(
session, player.sub, "play", {"card": player.hand[0].code}
)
moves += 1
assert engine.is_finished(session)
return session
class ChiamataStatsTest(unittest.TestCase):
@async_test
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("briscola_chiamata")
session = await _finished_chiamata_session()
with ctx:
await save_match_result(session, engine)
self.assertEqual(1, await Match.all().count())
self.assertEqual(5, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
self.assertEqual("briscola_chiamata", match.game_type)
summary = match.result
for key in (
"team_a_score",
"team_b_score",
"winner_team",
"declared_target",
"called_card",
"caller",
"solo",
"deals_played",
"deal_scores",
):
self.assertIn(key, summary)
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
players = await MatchPlayer.all()
roles = {p.team for p in players}
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
# Roles match the sides: exactly one caller, the rest defenders
# plus at most one partner.
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
winner_team = summary["winner_team"]
for row in players:
on_caller_side = row.team in ("Chiamante", "Socio")
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
self.assertEqual(5, len(ratings))
winners = {p.user_sub for p in players if p.won}
self.assertEqual(
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
)
self.assertEqual(
{INITIAL_RATING - 16},
{ratings[sub] for sub in ratings if sub not in winners},
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -13,7 +13,7 @@ serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
futures = "0.3"
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] }
js-sys = "0.3"
gloo-timers = "0.3"
console_error_panic_hook = "0.1"
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<!-- Seamless felt grain: fractal noise mapped to semi-transparent black
speckles, tiled as a CSS background over the green table gradient.
stitchTiles + a filter region equal to the tile keep the seams
invisible. -->
<filter id="felt" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" result="noise"/>
<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.3 0.3 0 -0.3"/>
</filter>
<rect width="240" height="240" filter="url(#felt)"/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

+32 -25
View File
@@ -1,13 +1,32 @@
//! REST client for the tavolo backend. Same-origin requests carry the
//! session cookie automatically.
use crate::auth;
use crate::model::*;
use gloo_net::http::Request;
use gloo_net::http::{Request, Response};
fn server_error(status: u16) -> String {
format!("server returned {status}")
}
/// Map a failed response to an error. A `401` additionally starts the
/// login flow: the session is gone (or the caller was never logged in),
/// and the user is returned to this page after logging in.
fn check(resp: &Response) -> Result<(), String> {
if resp.status() == 401 {
auth::redirect_to_login();
return Err("login required".to_string());
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
Ok(())
}
/// Fetch the current user; `None` when unauthenticated (401).
///
/// Deliberately bypasses [`check`]: an unauthenticated answer is how the
/// app renders its logged-out state, so it must not trigger the login
/// redirect.
pub async fn me() -> Result<Option<User>, String> {
let resp = Request::get("/api/me")
.send()
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.providers)
}
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 401 {
auth::redirect_to_login();
return Err("login required".to_string());
}
if !resp.ok() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let message = body
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
if resp.status() == 404 {
return Ok(None);
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map(Some).map_err(|e| e.to_string())
}
@@ -135,9 +150,7 @@ pub async fn my_games() -> Result<Vec<MyGame>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
@@ -148,9 +161,7 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
None => "/api/me/matches?limit=10".to_string(),
};
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
+74
View File
@@ -0,0 +1,74 @@
//! Client-side login redirect handling.
//!
//! The OIDC callback always returns the browser to the configured
//! post-login redirect (usually `/`), and the backend's login flow has no
//! per-request `next` support. So when the API answers `401`, the page the
//! user is on is saved in `sessionStorage` and restored once a session
//! exists again.
use sycamore_router::navigate_replace;
/// `sessionStorage` key holding the page to return to after login.
const RETURN_TO_KEY: &str = "tavolo.login.return_to";
fn session_storage() -> Option<web_sys::Storage> {
web_sys::window()?.session_storage().ok().flatten()
}
/// The current page (path plus query), the URL to come back to after
/// login.
pub fn current_path() -> String {
let Some(location) = web_sys::window().map(|w| w.location()) else {
return String::new();
};
format!(
"{}{}",
location.pathname().unwrap_or_default(),
location.search().unwrap_or_default()
)
}
/// Whether a path is a page worth returning to: not the login flow
/// itself, and not an absolute/protocol-relative URL.
fn is_restorable(path: &str) -> bool {
path.starts_with('/')
&& !path.starts_with("//")
&& !path.starts_with("/auth/")
&& !path.starts_with("/api/")
&& path != "/login"
}
/// Handle an expired session: remember the current page and go to the
/// login page.
///
/// The navigation is deferred to a timer so the caller can finish handling
/// the failed response (e.g. set an error) before its page is unmounted.
pub fn redirect_to_login() {
let path = current_path();
if is_restorable(&path) {
if let Some(storage) = session_storage() {
let _ = storage.set_item(RETURN_TO_KEY, &path);
}
}
gloo_timers::callback::Timeout::new(0, move || {
// Several in-flight requests may fail at once; only the first
// takes us to the login page.
if current_path() != "/login" {
navigate_replace("/login");
}
})
.forget();
}
/// The page saved by [`redirect_to_login`], if any.
pub fn saved_return_to() -> Option<String> {
let storage = session_storage()?;
let path = storage.get_item(RETURN_TO_KEY).ok().flatten()?;
is_restorable(&path).then_some(path)
}
/// Drop the saved page, e.g. after it has been restored.
pub fn clear_return_to() {
if let Some(storage) = session_storage() {
let _ = storage.remove_item(RETURN_TO_KEY);
}
}
+35 -9
View File
@@ -7,14 +7,16 @@
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{is_individual_game, DealSummary, GameView};
use crate::model::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
use crate::ws::GameSocket;
/// Names of the two sides: the players' names for games without
/// partnerships (classic two-player briscola), "Team A"/"Team B"
/// otherwise.
/// Names of the two sides: the caller and partner for briscola chiamata,
/// the players' names for games without partnerships (classic two-player
/// briscola), "Team A"/"Team B" otherwise.
fn side_names(game: &GameView) -> (String, String) {
if is_individual_game(&game.game_type) {
if is_chiamata(&game.game_type) {
game.chiamata_sides()
} else if is_individual_game(&game.game_type) {
(
game.player_name_for_team("A"),
game.player_name_for_team("B"),
@@ -66,6 +68,25 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
"60–60: patta — the deal is replayed and its points do not count."
}
});
let called_row = if is_chiamata(&game.game_type) {
game.called_card.clone().map(|code| {
let label = card_label(&code);
let text = if game.solo {
format!("{name_a} called {label} in hand and plays alone.")
} else {
format!("{label}: its holder is the secret partner; its suit is trump.")
};
summary_row(
card_img(code, "score-mini"),
"Chiamata",
text,
"score-row chiamata-row",
label,
)
})
} else {
None
};
let points_row = summary_row(
card_img("01D".to_string(), "score-mini"),
"Deal points",
@@ -84,6 +105,7 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
});
view! {
div(class="score-rows") {
(called_row)
(points_row)
(patta_note)
(cards)
@@ -94,9 +116,13 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
/// Running totals with progress toward the target, including the points
/// gained in the deal just played (zero for a patta).
fn totals(game: &GameView, summary: &DealSummary) -> View {
// Briscola chiamata plays a single deal: there is no running total.
let Some(running) = summary.totals else {
return view! {};
};
let target = game.target_score.max(1);
let pct_a = (100 * summary.totals.a / target).min(100);
let pct_b = (100 * summary.totals.b / target).min(100);
let pct_a = (100 * running.a / target).min(100);
let pct_b = (100 * running.b / target).min(100);
let (gained_a, gained_b) = if summary.patta {
(0, 0)
} else {
@@ -111,7 +137,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
span(class="total-value") {
(summary.totals.a) " / " (target)
(running.a) " / " (target)
span(class="gained") { "+" (gained_a) }
}
}
@@ -121,7 +147,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
span(class="total-value") {
(summary.totals.b) " / " (target)
(running.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Login card, shared by the lobby's logged-out state and the `/login`
//! page.
use sycamore::prelude::*;
use crate::model::AuthProvider;
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Card offering one login button per configured provider.
///
/// `providers` is filled by the caller from `GET /api/auth/providers`;
/// while it is empty (or the fetch failed) the card falls back to the
/// generic `/auth/login` route, which redirects to the first configured
/// provider.
pub fn login_card(providers: Signal<Vec<AuthProvider>>) -> View {
view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3 mb-3") {
span(class="text-warning") { "♠" }
" tavolo"
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the generic
// route redirects to the first configured
// provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod briscola;
pub mod card;
pub mod copy;
pub mod login;
pub mod nav;
pub mod summary;
pub mod toast;
+33 -1
View File
@@ -1,22 +1,27 @@
//! Application entry point.
mod api;
mod auth;
mod components;
mod model;
mod pages;
mod ws;
use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router};
use sycamore_router::{navigate, HistoryIntegration, Route, Router};
use wasm_bindgen_futures::spawn_local;
use pages::game::GamePage;
use pages::history::HistoryPage;
use pages::leaderboard::LeaderboardPage;
use pages::lobby::LobbyPage;
use pages::login::LoginPage;
#[derive(Route, Clone)]
enum AppRoutes {
#[to("/")]
Lobby,
#[to("/login")]
Login,
#[to("/game/<id>")]
Game { id: String },
#[to("/history")]
@@ -27,6 +32,31 @@ enum AppRoutes {
NotFound,
}
/// After the OIDC callback lands the SPA back on the configured
/// post-login redirect, forward the user to the page they were on before
/// being sent to the login page (see [`auth`]) — but only once a session
/// exists, so a cancelled login never bounces into a stale page.
fn restore_page_after_login() {
let Some(path) = auth::saved_return_to() else {
return;
};
spawn_local(async move {
match api::me().await {
Ok(Some(_)) => {
auth::clear_return_to();
if auth::current_path() != path {
navigate(&path);
}
}
// Not logged in: keep the target for the next successful
// login (a reload of the login page must not lose it).
Ok(None) => {}
// The probe failed (offline?): keep the target, too.
Err(_) => {}
}
});
}
fn main() {
console_error_panic_hook::set_once();
sycamore::render(|| {
@@ -38,6 +68,7 @@ fn main() {
div(class="app") {
(match route.get_clone() {
AppRoutes::Lobby => view! { LobbyPage() },
AppRoutes::Login => view! { LoginPage() },
AppRoutes::Game { id } => view! { GamePage(id=id) },
AppRoutes::History => view! { HistoryPage() },
AppRoutes::Leaderboard => view! { LeaderboardPage() },
@@ -60,4 +91,5 @@ fn main() {
)
}
});
restore_page_after_login();
}
+310 -6
View File
@@ -19,7 +19,10 @@ pub struct PlayerView {
pub sub: String,
pub name: String,
pub seat: usize,
pub team: String,
/// Game-defined role/team label; empty before briscola chiamata
/// reveals the roles.
#[serde(default)]
pub team: Option<String>,
#[serde(default)]
pub cards_left: usize,
#[serde(default)]
@@ -84,9 +87,21 @@ pub struct DealSummary {
#[serde(default)]
pub patta: bool,
pub points: Scores,
pub totals: Scores,
/// Running match totals; absent for briscola chiamata, whose match is
/// a single deal.
#[serde(default)]
pub totals: Option<Scores>,
#[serde(default)]
pub cards: Option<TeamCounts>,
/// Briscola chiamata: the points target of the played deal.
#[serde(default)]
pub declared_target: Option<i32>,
/// Briscola chiamata: the card named by the caller.
#[serde(default)]
pub called_card: Option<String>,
/// Briscola chiamata: the caller played without a partner.
#[serde(default)]
pub solo: bool,
}
#[derive(Debug, Clone, Copy, Deserialize)]
@@ -227,6 +242,83 @@ pub struct GameView {
/// Briscola: scoring breakdown of the most recent deal.
#[serde(default)]
pub last_deal: Option<DealSummary>,
/// Briscola chiamata: the calling auction of the current deal.
#[serde(default)]
pub auction: Option<AuctionView>,
/// Briscola chiamata: seat that won the auction.
#[serde(default)]
pub caller: Option<usize>,
/// Briscola chiamata: the named card; its suit is the trump.
#[serde(default)]
pub called_card: Option<String>,
/// Briscola chiamata: points the calling side must reach.
#[serde(default)]
pub declared_target: i32,
/// Briscola chiamata: the called card has been played; roles are
/// public from then on.
#[serde(default)]
pub called_played: bool,
/// Briscola chiamata: the partner's seat, once revealed.
#[serde(default)]
pub partner: Option<usize>,
/// Briscola chiamata: the caller named one of their own cards.
#[serde(default)]
pub solo: bool,
/// Briscola chiamata: legal bids for the player on turn.
#[serde(default)]
pub legal_bids: Option<LegalBids>,
/// Briscola chiamata: the cards the auction winner may name.
#[serde(default)]
pub callable_cards: Option<Vec<String>>,
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
#[serde(default)]
pub auction_deadline: Option<String>,
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
#[serde(default)]
pub call_deadline: Option<String>,
}
/// One bid of the briscola chiamata auction.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct AuctionBid {
pub seat: usize,
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
pub rank: i32,
/// Points target the bid commits its side to.
#[serde(default)]
pub points: i32,
}
/// The briscola chiamata calling auction.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct AuctionView {
/// Seat to act.
pub turn: usize,
#[serde(default)]
pub active: Vec<usize>,
#[serde(default)]
pub passed: Vec<usize>,
/// The standing bid, if any.
#[serde(default)]
pub current: Option<AuctionBid>,
#[serde(default)]
pub history: Vec<AuctionBid>,
}
/// The bids available to the player on turn.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LegalBids {
/// Ranks that may be bid, strongest first (empty at the point stage).
#[serde(default)]
pub ranks: Vec<i32>,
/// Lowest legal points raise, when the standing bid is the two.
#[serde(default)]
pub points_min: Option<i32>,
#[serde(default)]
pub points_max: Option<i32>,
}
impl GameView {
@@ -235,10 +327,38 @@ impl GameView {
pub fn player_name_for_team(&self, team: &str) -> String {
self.players
.iter()
.find(|p| p.team == team)
.find(|p| p.team.as_deref() == Some(team))
.map(|p| p.name.clone())
.unwrap_or_else(|| team.to_string())
}
/// Display name of the player in `seat`.
pub fn player_name_for_seat(&self, seat: usize) -> String {
self.players
.iter()
.find(|p| p.seat == seat)
.map(|p| p.name.clone())
.unwrap_or_else(|| format!("Seat {seat}"))
}
/// Side labels for briscola chiamata: the caller (with the partner's
/// name once revealed, or "(solo)") against the defenders.
pub fn chiamata_sides(&self) -> (String, String) {
let side_a = match self.caller {
Some(caller) => {
let caller_name = self.player_name_for_seat(caller);
match self.partner {
Some(partner) => {
format!("{caller_name} & {}", self.player_name_for_seat(partner))
}
None if self.called_played => format!("{caller_name} (solo)"),
None => caller_name,
}
}
None => "Chiamante".to_string(),
};
(side_a, "Difensori".to_string())
}
}
#[derive(Debug, Deserialize)]
@@ -449,6 +569,14 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola_chiamata".to_string(),
name: "Briscola chiamata".to_string(),
description: String::new(),
min_players: 5,
max_players: 5,
options_schema: serde_json::Value::Null,
},
]
}
@@ -735,6 +863,30 @@ pub fn is_briscola(game_type: &str) -> bool {
game_type == "briscola" || game_type == "briscola_a_quattro"
}
/// Whether a game type is five-player briscola chiamata, which has its
/// own table view (auction, secret partner, five seats).
pub fn is_chiamata(game_type: &str) -> bool {
game_type == "briscola_chiamata"
}
/// The Italian rank name used by the auction UI: `1` -> "Asso", `3` ->
/// "Tre", `10` -> "Re", and so on.
pub fn rank_name(rank: i32) -> String {
match rank {
1 => "Asso".to_string(),
2 => "Due".to_string(),
3 => "Tre".to_string(),
4 => "Quattro".to_string(),
5 => "Cinque".to_string(),
6 => "Sei".to_string(),
7 => "Sette".to_string(),
8 => "Fante".to_string(),
9 => "Cavallo".to_string(),
10 => "Re".to_string(),
other => other.to_string(),
}
}
/// Whether a game is played by two individuals rather than partnerships
/// (classic two-player briscola): team labels and team messages are
/// replaced by the players' names.
@@ -1033,7 +1185,7 @@ mod tests {
assert_eq!(16, g.target_score);
assert!(!g.napola);
assert!(g.asso_pigliatutto);
assert_eq!("A", g.players[0].team);
assert_eq!(Some("A".to_string()), g.players[0].team);
}
#[test]
@@ -1245,7 +1397,10 @@ mod tests {
fn is_briscola_matches_both_variants() {
assert!(is_briscola("briscola"));
assert!(is_briscola("briscola_a_quattro"));
assert!(!is_briscola("briscola_chiamata"));
assert!(!is_briscola("scopone_scientifico"));
assert!(is_chiamata("briscola_chiamata"));
assert!(!is_chiamata("briscola"));
assert_eq!("denari", suit_name("D"));
assert_eq!("coppe", suit_name("C"));
assert_eq!("spade", suit_name("S"));
@@ -1253,6 +1408,16 @@ mod tests {
assert_eq!("?", suit_name("?"));
}
#[test]
fn rank_names_cover_the_ladder() {
assert_eq!("Asso", rank_name(1));
assert_eq!("Due", rank_name(2));
assert_eq!("Tre", rank_name(3));
assert_eq!("Fante", rank_name(8));
assert_eq!("Cavallo", rank_name(9));
assert_eq!("Re", rank_name(10));
}
#[test]
fn is_individual_game_only_matches_two_player_briscola() {
assert!(is_individual_game("briscola"));
@@ -1318,11 +1483,17 @@ mod tests {
let types = fallback_game_types();
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
assert_eq!(
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
vec![
"scopone_scientifico",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
],
ids
);
// The lobby's first game (the creation default) must exist.
assert_eq!("scopone_scientifico", types[0].id);
assert_eq!(5, types[3].max_players);
}
#[test]
@@ -1400,7 +1571,7 @@ mod tests {
assert_eq!(1, deal.deal);
assert!(!deal.patta);
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
let cards = deal.cards.unwrap();
assert_eq!(19, cards.a);
assert_eq!(21, cards.b);
@@ -1420,4 +1591,137 @@ mod tests {
assert_eq!(0, d.deal);
assert!(d.cards.is_none());
}
#[test]
fn chiamata_deal_summary_has_no_totals() {
let d: DealSummary = serde_json::from_value(json!({
"deal": 2,
"patta": true,
"points": {"A": 60, "B": 60},
"declared_target": 61,
"called_card": "01D",
"caller": "Alice",
"solo": false,
}))
.unwrap();
assert!(d.patta);
assert_eq!(2, d.deal);
assert!(d.totals.is_none());
assert!(d.cards.is_none());
assert_eq!(Some(61), d.declared_target);
assert_eq!(Some("01D".to_string()), d.called_card);
assert!(!d.solo);
}
#[test]
fn game_view_parses_chiamata_auction() {
let g: GameView = serde_json::from_value(json!({
"id": "c1",
"join_code": "ABC123",
"game_type": "briscola_chiamata",
"phase": "auction",
"player_count": 5,
"dealer": 0,
"hand_number": 1,
"turn": 1,
"declared_target": 61,
"auction": {
"turn": 2,
"active": [0, 1, 2, 3, 4],
"passed": [],
"current": {"seat": 1, "rank": 10, "points": 61},
"history": [{"seat": 1, "rank": 10, "points": 61}],
},
"your_turn": true,
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
"cards_left": 8, "captured_count": 0, "points": 0},
],
"auction_deadline": "2026-01-01T00:00:30+00:00",
"call_deadline": null,
"turn_deadline": null,
}))
.unwrap();
assert!(is_chiamata(&g.game_type));
assert!(!is_briscola(&g.game_type));
let auction = g.auction.unwrap();
assert_eq!(2, auction.turn);
assert_eq!(5, auction.active.len());
let current = auction.current.unwrap();
assert_eq!(1, current.seat);
assert_eq!(10, current.rank);
assert_eq!(61, current.points);
assert_eq!(1, auction.history.len());
let bids = g.legal_bids.unwrap();
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
assert!(bids.points_min.is_none());
assert_eq!(Some(120), bids.points_max);
assert_eq!(Some("".to_string()), g.players[0].team);
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
}
#[test]
fn game_view_parses_chiamata_playing_and_reveal() {
let g: GameView = serde_json::from_value(json!({
"id": "c2",
"game_type": "briscola_chiamata",
"phase": "playing",
"player_count": 5,
"dealer": 0,
"hand_number": 1,
"turn": 3,
"caller": 1,
"called_card": "03C",
"briscola_suit": "C",
"declared_target": 64,
"called_played": true,
"partner": 3,
"solo": false,
"scores": {"A": 45, "B": 30},
"players": [
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
],
}))
.unwrap();
assert_eq!(Some(1), g.caller);
assert_eq!(Some("03C".to_string()), g.called_card);
assert_eq!("C", g.briscola_suit);
assert_eq!(64, g.declared_target);
assert!(g.called_played);
assert_eq!(Some(3), g.partner);
assert!(!g.solo);
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
}
#[test]
fn chiamata_sides_label_the_solo_caller() {
let g: GameView = serde_json::from_value(json!({
"id": "c3",
"game_type": "briscola_chiamata",
"phase": "finished",
"scores": {"A": 70, "B": 50},
"winner": "Chiamante",
"caller": 2,
"called_played": true,
"solo": true,
"players": [
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
],
}))
.unwrap();
let (a, b) = g.chiamata_sides();
assert_eq!("Carol (solo)", a);
assert_eq!("Difensori", b);
assert_eq!(Some("Chiamante".to_string()), g.winner);
}
}
+5 -4
View File
@@ -27,10 +27,11 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
if active { " active" } else { "" }
);
let name = player.name.clone();
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
let team = player.team.clone();
view! { span(class="team-badge") { "Team " (team) } }
});
let team_badge = (!is_individual_game(&game.game_type))
.then(|| player.team.clone())
.flatten()
.filter(|team| !team.is_empty())
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
let points = player.points;
let captured = player.captured_count;
let backs = (0..player.cards_left)
+529
View File
@@ -0,0 +1,529 @@
//! Briscola chiamata table: five seats, the calling auction and the
//! secret-partner reveal.
//!
//! The auction starts with the player after the dealer: each player bids
//! a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 >
//! 4 > 2) or passes, and once the standing bid is the two the bidding
//! continues on points. The last bidder names a card of the won rank:
//! its holder is the secret partner, or the caller plays alone when it
//! is one of their own cards, and its suit is the trump. The partner's
//! identity is only revealed when the called card is played.
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::model::{
card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView,
};
use crate::pages::game::watch_link;
use crate::ws::GameSocket;
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
fn player_name(game: &GameView, seat: usize) -> String {
player_for_seat(game, seat)
.map(|p| p.name)
.unwrap_or_else(|| format!("Seat {seat}"))
}
/// Human label of one auction bid.
fn bid_label(bid: &AuctionBid) -> String {
if bid.rank == 2 {
format!("2 at {} pts", bid.points)
} else {
rank_name(bid.rank)
}
}
/// Panel for one player seat (name, revealed role, hidden cards, points).
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
let Some(player) = player_for_seat(&game, seat) else {
return view! {};
};
let active = game.phase == "playing" && game.turn == seat;
let cls = format!(
"seat seat-{position}{}",
if active { " active" } else { "" }
);
let name = player.name.clone();
// Role labels only appear once the server makes them public.
let role_badge = player
.team
.clone()
.filter(|role| !role.is_empty())
.map(|role| view! { span(class="team-badge role-badge") { (role) } });
let points = player.points;
let captured = player.captured_count;
let backs = (0..player.cards_left)
.map(|_| card_back("mini"))
.collect::<Vec<_>>();
view! {
div(class=cls) {
div(class="seat-name") {
(name)
(role_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(points) " points · " (captured) " cards"
}
}
}
}
/// The cards of the trick in progress, in play order, with player names.
fn trick_view(game: &GameView) -> View {
if game.trick.is_empty() {
return view! { p(class="table-empty") { "Empty table" } };
}
let cards = game
.trick
.iter()
.map(|tc| {
let name = player_name(game, tc.seat);
let card = card_img(tc.card.clone(), "table-card");
view! {
div(class="trick-slot") {
span(class="trick-name") { (name) }
(card)
}
}
})
.collect::<Vec<_>>();
view! { (cards) }
}
/// Announcement strip for the most recently completed trick.
fn last_trick_banner(game: &GameView) -> View {
let Some(trick) = game.last_trick.clone() else {
return view! {};
};
let name = player_name(game, trick.winner);
let cards = trick
.cards
.iter()
.map(|tc| card_img(tc.card.clone(), "move-card"))
.collect::<Vec<_>>();
view! {
div(class="move-banner") {
strong { (name) }
span { " takes the trick " }
(cards)
}
}
}
/// "Patta — the deal is replayed" after a 60-60 deal.
fn patta_banner(game: &GameView) -> View {
let patta = game
.last_deal
.as_ref()
.is_some_and(|deal| deal.patta);
if !patta || game.phase != "auction" {
return view! {};
}
view! {
div(class="move-banner patta-banner") {
strong { "Patta!" }
span { " 60–60 — the deal is replayed." }
}
}
}
/// Announcement of the partner (or the solo caller) once the called card
/// has hit the table.
fn reveal_banner(game: &GameView) -> View {
if !game.called_played || game.phase == "auction" || game.phase == "calling" {
return view! {};
}
let caller = game
.caller
.map(|seat| player_name(game, seat))
.unwrap_or_default();
let text = if game.solo {
format!("{caller} is playing alone")
} else {
let partner = game
.partner
.map(|seat| player_name(game, seat))
.unwrap_or_default();
format!("{partner} is the partner of {caller}")
};
view! {
div(class="move-banner reveal-banner") {
span(class="reveal-text") { (text) }
}
}
}
/// The calling auction panel: standing bid, trail and, on turn, the bids.
fn auction_panel(
game: &GameView,
on_bid_rank: impl Fn(i32) + Copy + 'static,
on_bid_points: impl Fn(i32) + Copy + 'static,
on_pass: impl Fn() + Copy + 'static,
) -> View {
let Some(auction) = game.auction.clone() else {
return view! {};
};
let standing = match &auction.current {
Some(bid) => format!(
"Standing bid: {} — {}",
player_name(game, bid.seat),
bid_label(bid)
),
None => "No bids yet".to_string(),
};
let waiting_for = player_name(game, auction.turn);
let history = auction
.history
.iter()
.map(|bid| {
let text = format!("{}: {}", player_name(game, bid.seat), bid_label(bid));
view! { span(class="auction-chip") { (text) } }
})
.collect::<Vec<_>>();
let actions = if game.your_turn == Some(true) {
let legal = game.legal_bids.clone();
let (rank_buttons, point_buttons) = match legal {
Some(bids) => {
let ranks = bids
.ranks
.iter()
.map(|rank| {
let rank = *rank;
let name = rank_name(rank);
view! {
button(
class="bid-button",
on:click=move |_| on_bid_rank(rank),
) { (name) }
}
})
.collect::<Vec<_>>();
let points = match (bids.points_min, bids.points_max) {
(Some(min), Some(max)) => (0..=2)
.map(|step| min + 2 * step)
.filter(|points| *points <= max)
.map(|points| {
view! {
button(
class="bid-button points",
on:click=move |_| on_bid_points(points),
) { (points) " pts" }
}
})
.collect::<Vec<_>>(),
_ => Vec::new(),
};
(ranks, points)
}
None => (Vec::new(), Vec::new()),
};
view! {
div(class="bid-options") {
(rank_buttons)
(point_buttons)
button(class="pass-button", on:click=move |_| on_pass()) { "Pass" }
}
}
} else {
view! {
p(class="text-secondary small mb-0") {
"Waiting for " (waiting_for) " to bid…"
}
}
};
view! {
div(class="auction-panel") {
div(class="auction-standing") { (standing) }
div(class="auction-history") { (history) }
(actions)
}
}
}
/// The main briscola chiamata table.
pub fn chiamata_table(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
on_bid_rank: impl Fn(i32) + Copy + 'static,
on_bid_points: impl Fn(i32) + Copy + 'static,
on_pass: impl Fn() + Copy + 'static,
now: Signal<f64>,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat)
.unwrap_or(0);
let my_turn = game.your_turn == Some(true);
let can_play = game.phase == "playing" && my_turn;
let turn_note = if game.phase == "finished" {
"Match finished".to_string()
} else if game.phase == "auction" {
if my_turn {
"Your bid".to_string()
} else {
let name = game
.auction
.as_ref()
.map(|a| player_name(&game, a.turn))
.unwrap_or_default();
format!("{name} to bid")
}
} else if game.phase == "calling" {
if my_turn {
"Name the called card".to_string()
} else {
let name = game
.caller
.map(|seat| player_name(&game, seat))
.unwrap_or_default();
format!("{name} is calling")
}
} else if my_turn {
"Your turn".to_string()
} else {
format!("{}'s turn", player_name(&game, game.turn))
};
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
let deadline = if game.phase == "auction" {
game.auction_deadline.clone()
} else if game.phase == "calling" {
game.call_deadline.clone()
} else {
game.turn_deadline.clone()
};
let countdown = deadline.map(|deadline| {
// A dynamic closure so only the ticking number re-renders.
let deadline_ms = js_sys::Date::parse(&deadline);
view! {
span(class="turn-timer") {
"Auto in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let score_line = match game.scores {
Some(scores) => {
let (side_a, side_b) = game.chiamata_sides();
format!(
"{side_a} {} — {} {side_b} (target {})",
scores.a, scores.b, game.declared_target
)
}
// Before the called card is played the side totals would leak the
// partner's identity; only the target is shown.
None => format!("Target {}", game.declared_target),
};
// Four opponents around the table: next player on the left, then
// clockwise up to the player before us on the right.
let left = seat_panel(game.clone(), (viewer_seat + 1) % 5, "left");
let top_left = seat_panel(game.clone(), (viewer_seat + 2) % 5, "tl");
let top_right = seat_panel(game.clone(), (viewer_seat + 3) % 5, "tr");
let right = seat_panel(game.clone(), (viewer_seat + 4) % 5, "right");
let patta = patta_banner(&game);
let reveal = reveal_banner(&game);
let center_content = match game.phase.as_str() {
"auction" => auction_panel(&game, on_bid_rank, on_bid_points, on_pass),
"calling" => {
let caller = game
.caller
.map(|seat| player_name(&game, seat))
.unwrap_or_default();
view! {
div(class="auction-panel") {
div(class="auction-standing") { (caller) " won the auction" }
p(class="text-secondary small mb-0") {
"Choosing the called card…"
}
}
}
}
_ => {
let banner = last_trick_banner(&game);
let trick = trick_view(&game);
view! {
(banner)
div(class="table-cards") { (trick) }
}
}
};
let viewer = player_for_seat(&game, viewer_seat);
let my_points = viewer.as_ref().map(|p| p.points).unwrap_or(0);
let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default();
hand.sort_by_key(|c| hand_sort_key(c));
let hand_cards = hand
.into_iter()
.map(|code| {
let card_view = card_img(code.clone(), "hand-card");
let click_code = code.clone();
view! {
button(class="hand-slot", on:click=move |_| {
if can_play {
on_hand_card(click_code.clone());
}
}) { (card_view) }
}
})
.collect::<Vec<_>>();
let hint = if can_play {
Some(view! {
p(class="text-secondary small text-center") {
"Click a card to play it."
}
})
} else if game.phase == "auction" {
Some(view! {
p(class="text-secondary small text-center") {
"Bid or pass, then play the cards."
}
})
} else if game.phase == "calling" && my_turn {
Some(view! {
p(class="text-secondary small text-center") {
"Name a card: its holder will be your secret partner."
}
})
} else {
None
};
// Spectators have no hand: render the pivot seat as a regular panel so
// its points stay visible where the hand would be.
let bottom = if game.spectator {
seat_panel(game.clone(), viewer_seat, "bottom")
} else {
view! {
div(class="seat-bottom") {
div(class="seat-stats own-stats") { (my_points) " points" }
div(class="hand") { (hand_cards) }
(hint)
}
}
};
let spectator_badge = game.spectator.then(|| view! {
span(class="spectator-badge") { "Spectating" }
});
let watch_link = watch_link(&game.id);
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
let suit = suit_name(&game.briscola_suit);
view! { span(class="briscola-badge") { "Briscola: " (suit) } }
});
let called_badge = game.called_card.clone().map(|code| {
let label = card_label(&code);
view! { span(class="called-badge") { "Chiamata: " (label) } }
});
let deal = game.hand_number;
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Deal " (deal) }
(trump_badge)
(called_badge)
(spectator_badge)
span(class="hud-scores") { (score_line) }
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
div(class="table-grid cinque") {
(top_left)
(top_right)
(left)
div(class="center") {
(patta)
(reveal)
(center_content)
}
(right)
(bottom)
}
}
}
}
/// Overlay for the auction winner: name the called card.
///
/// The winning bid fixes the rank, so the choice is which of the four
/// suits to call: its holder becomes the secret partner, unless the
/// caller holds it too — then that option means playing alone.
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
return view! {};
};
let rank = cards
.first()
.and_then(|code| code.get(..2))
.and_then(|rank| rank.parse::<i32>().ok())
.map(rank_name)
.unwrap_or_default();
let title = if rank.is_empty() {
"Name the called card".to_string()
} else {
format!("You won with the {rank} — name the card")
};
let own_hand = game
.players
.iter()
.find(|p| p.hand.is_some())
.and_then(|p| p.hand.clone())
.unwrap_or_default();
let options = cards
.into_iter()
.map(|code| {
let held = own_hand.contains(&code);
let label = card_label(&code);
let img = card_img(code.clone(), "call-card");
let note = if held {
view! { span(class="call-note solo") { "Plays alone" } }
} else {
view! { span(class="call-note partner") { "Partner" } }
};
let click_code = code.clone();
view! {
button(class="call-option", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.call_card(&click_code);
}
}) {
(img)
span(class="call-label") { (label) }
(note)
}
}
})
.collect::<Vec<_>>();
view! {
div(class="overlay") {
div(class="picker call-picker") {
h3 { (title) }
p(class="text-secondary small") {
"Its holder becomes your secret partner; name one of your "
"own cards to play alone."
}
div(class="call-options") { (options) }
}
}
}
}
+57 -10
View File
@@ -12,10 +12,11 @@ use crate::components::copy::CopyButton;
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, GameView, MoveView,
PlayerView, Scores, ServerMessage,
};
use crate::pages::briscola::briscola_table;
use crate::pages::chiamata::{call_picker, chiamata_table};
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
@@ -43,7 +44,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
if active { " active" } else { "" }
);
let name = player.name.clone();
let team = player.team.clone();
let team = player.team.clone().unwrap_or_default();
let captured = player.captured_count;
let scope = player.scope;
let backs = (0..player.cards_left)
@@ -324,6 +325,32 @@ pub fn GamePage(id: String) -> View {
}
};
// Briscola chiamata: auction and call actions.
let on_bid_rank = move |rank: i32| {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.bid_rank(rank);
}
};
let on_bid_points = move |points: i32| {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.bid_points(points);
}
};
let on_pass = move || {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.pass();
}
};
let reconnect_banner = reconnect.clone();
view! {
div(class="game-page") {
@@ -392,6 +419,11 @@ pub fn GamePage(id: String) -> View {
}
}
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
// Briscola chiamata has its own five-seat table with the
// calling auction and the secret-partner reveal.
Some(g) if is_chiamata(&g.game_type) => {
chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now)
}
// Briscola has its own table view for both variants.
Some(g) if is_briscola(&g.game_type) => {
briscola_table(g, on_hand_card, now)
@@ -406,6 +438,17 @@ pub fn GamePage(id: String) -> View {
(move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice)
}))
// Briscola chiamata: the auction winner names the called card.
(move || match game.get_clone() {
Some(g)
if is_chiamata(&g.game_type)
&& g.phase == "calling"
&& g.your_turn == Some(true) =>
{
call_picker(g, socket)
}
_ => view! {},
})
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
hand_summary_modal(g, socket, now)
@@ -440,10 +483,11 @@ fn LobbyView(game: GameView) -> View {
.map(|p| {
let name = p.name.clone();
let seat = p.seat;
let team_badge = show_teams.then(|| {
let team = p.team.clone();
view! { span(class="badge text-bg-info") { "Team " (team) } }
});
let team_badge = show_teams
.then(|| p.team.clone())
.flatten()
.filter(|team| !team.is_empty())
.map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } });
view! {
li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) }
@@ -734,8 +778,9 @@ fn capture_picker(
/// End-of-match overlay: the final hand/deal breakdown and the winner.
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" && !briscola {
if g.game_type != "scopone_scientifico" && !briscola && !chiamata {
return view! {};
}
}
@@ -749,18 +794,20 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
// Two-player briscola has no teams: name the winner instead.
// Briscola chiamata names the caller's side (or the defenders).
let winner_label = match game.as_ref() {
Some(g) if is_individual_game(&g.game_type) => {
g.player_name_for_team(&winner)
}
Some(g) if is_chiamata(&g.game_type) => winner.clone(),
_ => format!("Team {winner}"),
};
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
// Spectators have no match history to look at.
let spectator = game.as_ref().is_some_and(|g| g.spectator);
// Explain the final hand's (scopone) or deal's (briscola)
// scoring before the result.
let final_summary: View = if briscola {
// Explain the final hand's (scopone) or deal's (briscola,
// briscola chiamata) scoring before the result.
let final_summary: View = if briscola || chiamata {
match game.as_ref() {
Some(g) => match g.last_deal.clone() {
Some(deal) => deal_rows(g, &deal),
+177 -49
View File
@@ -12,34 +12,58 @@ use crate::api;
use crate::components::toast::toast;
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
/// Distinct team labels in seat order; players without a team render in
/// a shared "Players" column.
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
let mut teams = BTreeSet::new();
/// The match's teams, each as a `(label, names)` pair, in a readable
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
/// then other team labels alphabetically (A, B, ...), with team-less
/// players last.
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
for p in &m.players {
teams.insert(p.team.clone());
labels.insert(p.team.clone());
}
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
// Seated teams first (in first-seat order), then the team-less column.
ordered.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
ordered.sort_by_key(team_sort_key);
ordered
.into_iter()
.map(|label| (team_header(&label), team_names(m, &label)))
.collect()
}
/// Sort key for team labels: chiamata roles in play order, other labels
/// alphabetically, team-less players last.
fn team_sort_key(label: &Option<String>) -> (u8, String) {
match label.as_deref() {
Some("Chiamante") => (0, String::new()),
Some("Socio") => (1, String::new()),
Some("Difensori") => (2, String::new()),
Some(other) => (0, other.to_string()),
None => (9, String::new()),
}
}
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
m.players
let names = m
.players
.iter()
.filter(|p| &p.team == team)
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ")
.collect::<Vec<_>>();
match names.len() {
0 => String::new(),
1 => names[0].clone(),
_ => {
let (last, rest) = names.split_last().expect("non-empty");
format!("{} & {}", rest.join(", "), last)
}
}
}
fn team_header(team: &Option<String>) -> String {
match team {
Some(t) => format!("Team {t}"),
// Single-letter labels are the A/B team names; longer labels
// (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own.
Some(t) if t.len() <= 2 => format!("Team {t}"),
Some(t) => t.clone(),
None => "Players".to_string(),
}
}
@@ -55,7 +79,11 @@ fn score_text(m: &MatchSummary) -> String {
/// "Team A" from `winner_team`, a plain `winner`, or "—".
fn winner_text(m: &MatchSummary) -> String {
if let Some(team) = m.result_str("winner_team") {
return format!("Team {team}");
return if team.len() <= 2 {
format!("Team {team}")
} else {
team
};
}
m.result_str("winner").unwrap_or_else(|| "—".to_string())
}
@@ -98,18 +126,29 @@ pub fn HistoryPage() -> View {
spawn_local(async move {
match api::my_matches(next.as_deref()).await {
Ok(p) => {
// The page can unmount (e.g. the login redirect) while
// the request is in flight.
if !page.is_alive() {
return;
}
cursor.set(p.next_cursor.clone());
rows.update(|acc| acc.extend(p.results.iter().cloned()));
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
Err(e) => {
if error.is_alive() {
error.set(Some(e));
}
}
}
});
};
spawn_local(async move {
if let Ok(types) = api::game_types().await {
game_types.set(types);
if game_types.is_alive() {
game_types.set(types);
}
}
});
@@ -117,11 +156,18 @@ pub fn HistoryPage() -> View {
spawn_local(async move {
match api::my_matches(None).await {
Ok(p) => {
if !page.is_alive() {
return;
}
cursor.set(p.next_cursor.clone());
rows.set(p.results.clone());
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
Err(e) => {
if error.is_alive() {
error.set(Some(e));
}
}
}
});
@@ -150,10 +196,18 @@ pub fn HistoryPage() -> View {
.map(|m| {
let finished = local_finished(&m.finished_at);
let game = game_name(&names, &m.game_type);
let mut cells: Vec<View> = team_columns(&m)
.iter()
.map(|t| team_names(&m, t))
.map(|c| view! { td { (c) } })
let teams: Vec<View> = team_groups(&m)
.into_iter()
.map(|(label, names)| {
view! {
div(class="match-team") {
span(class="text-secondary") {
(label) ": "
}
(names)
}
}
})
.collect();
let score = score_text(&m);
let winner = winner_text(&m);
@@ -173,37 +227,18 @@ pub fn HistoryPage() -> View {
} else {
"text-secondary"
};
let mut row_cells = vec![
let row_cells = vec![
view! { td { (finished) } },
view! { td { (game) } },
view! { td { (teams) } },
view! { td { (score) } },
view! { td { (winner) } },
view! { td(class=cls) { (outcome) } },
view! { td(class=cls) { (elo_delta) } },
];
row_cells.append(&mut cells);
row_cells.push(view! { td { (score) } });
row_cells.push(view! { td { (winner) } });
row_cells.push(view! { td(class=cls) { (outcome) } });
row_cells.push(view! { td(class=cls) { (elo_delta) } });
view! { tr { (row_cells) } }
})
.collect::<Vec<_>>();
// Team columns are per-row (matches may mix games), so
// the header shows the union across the loaded rows.
let mut header_teams = BTreeSet::new();
for m in rows.get_clone().iter() {
for t in team_columns(m) {
header_teams.insert(t);
}
}
let mut header_teams: Vec<Option<String>> =
header_teams.into_iter().collect();
header_teams.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let header_cells: Vec<View> = header_teams
.iter()
.map(team_header)
.map(|h| view! { th { (h) } })
.collect();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
@@ -211,7 +246,7 @@ pub fn HistoryPage() -> View {
tr {
th { "Finished" }
th { "Game" }
(header_cells)
th { "Teams" }
th { "Score" }
th { "Winner" }
th { "You" }
@@ -236,3 +271,96 @@ pub fn HistoryPage() -> View {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn match_summary(players: serde_json::Value) -> MatchSummary {
serde_json::from_value(json!({
"id": "m1",
"started_at": "2026-01-01T00:00:00+00:00",
"finished_at": "2026-01-01T01:00:00+00:00",
"players": players,
}))
.unwrap()
}
#[test]
fn team_groups_order_chiamata_roles() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "Difensori", "won": false},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "Chiamante", "won": true},
{"user_sub": "c", "display_name": "Carol", "seat": 2,
"team": "Difensori", "won": false},
{"user_sub": "d", "display_name": "Dave", "seat": 3,
"team": "Socio", "won": true},
{"user_sub": "e", "display_name": "Erin", "seat": 4,
"team": "Difensori", "won": false},
]));
assert_eq!(
vec![
("Chiamante".to_string(), "Bob".to_string()),
("Socio".to_string(), "Dave".to_string()),
(
"Difensori".to_string(),
"Alice, Carol & Erin".to_string()
),
],
team_groups(&m)
);
}
#[test]
fn team_groups_keep_a_and_b_in_order() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "A", "won": true},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "B", "won": false},
]));
assert_eq!(
vec![
("Team A".to_string(), "Alice".to_string()),
("Team B".to_string(), "Bob".to_string()),
],
team_groups(&m)
);
}
#[test]
fn team_groups_put_teamless_players_last() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": null, "won": false},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "A", "won": true},
]));
assert_eq!(
vec![
("Team A".to_string(), "Bob".to_string()),
("Players".to_string(), "Alice".to_string()),
],
team_groups(&m)
);
}
#[test]
fn team_names_join_three_names_readably() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "A", "won": true},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "A", "won": true},
{"user_sub": "c", "display_name": "Carol", "seat": 2,
"team": "A", "won": true},
]));
assert_eq!(
"Alice, Bob & Carol",
team_names(&m, &Some("A".to_string()))
);
}
}
+25 -62
View File
@@ -13,21 +13,13 @@ use sycamore_router::navigate;
use crate::api;
use crate::components::card::card_back;
use crate::components::login::login_card;
use crate::components::toast::toast;
use crate::model::{
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
MyGame, OptionField, OptionKind, PlayerRating, User,
};
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn json_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
@@ -386,18 +378,30 @@ pub fn LobbyPage() -> View {
if me.is_some() {
spawn_local(async move {
if let Ok(p) = api::my_ratings().await {
ratings.set(p.results);
if ratings.is_alive() {
ratings.set(p.results);
}
}
if let Ok(games) = api::my_games().await {
my_games.set(games);
if my_games.is_alive() {
my_games.set(games);
}
}
});
}
user.set(Some(me));
// The page may have been unmounted (e.g. by the login
// redirect triggered while this request was in flight).
if user.is_alive() {
user.set(Some(me));
}
}
Err(e) => {
error.set(Some(e));
user.set(Some(None));
if error.is_alive() {
error.set(Some(e));
}
if user.is_alive() {
user.set(Some(None));
}
}
}
});
@@ -405,6 +409,9 @@ pub fn LobbyPage() -> View {
spawn_local(async move {
match api::game_types().await {
Ok(types) if !types.is_empty() => {
if !game_types.is_alive() {
return;
}
selected_game.set(types[0].id.clone());
game_types.set(types);
}
@@ -414,7 +421,9 @@ pub fn LobbyPage() -> View {
spawn_local(async move {
if let Ok(list) = api::auth_providers().await {
auth_providers.set(list);
if auth_providers.is_alive() {
auth_providers.set(list);
}
}
});
@@ -470,53 +479,7 @@ pub fn LobbyPage() -> View {
}
}
},
Some(None) => view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3 mb-3") {
span(class="text-warning") { "♠" }
" tavolo"
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = auth_providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the
// generic route redirects to the first
// configured provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}
},
Some(None) => view! { (login_card(auth_providers)) },
Some(Some(_me)) => {
view! {
(crate::components::nav::app_nav("/", move || {
+41
View File
@@ -0,0 +1,41 @@
//! Login page, shown after the API answered `401` (see [`crate::auth`]):
//! the session expired, or the visitor tried to open a page that needs an
//! account.
//!
//! The backend's OIDC callback lands the SPA back on the configured
//! post-login redirect; [`crate::auth`] then returns the user to the page
//! they were on before this one.
use sycamore::prelude::*;
use sycamore_router::navigate_replace;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::auth;
use crate::components::login::login_card;
use crate::model::AuthProvider;
#[component]
pub fn LoginPage() -> View {
let providers = create_signal(Vec::<AuthProvider>::new());
spawn_local(async move {
// Already signed in: no business here — return to where the user
// was headed, or the lobby.
if matches!(api::me().await, Ok(Some(_))) {
let target = auth::saved_return_to().unwrap_or_else(|| "/".to_string());
auth::clear_return_to();
navigate_replace(&target);
return;
}
if let Ok(list) = api::auth_providers().await {
// The page can unmount while the fetch is in flight.
if providers.is_alive() {
providers.set(list);
}
}
});
view! {
(login_card(providers))
}
}
+2
View File
@@ -1,5 +1,7 @@
pub mod briscola;
pub mod chiamata;
pub mod game;
pub mod history;
pub mod leaderboard;
pub mod lobby;
pub mod login;
+20
View File
@@ -45,6 +45,26 @@ impl GameSocket {
self.send_json(serde_json::json!({ "action": "ack" }));
}
/// Briscola chiamata: bid a rank in the calling auction.
pub fn bid_rank(&self, rank: i32) {
self.send_json(serde_json::json!({ "action": "bid", "rank": rank }));
}
/// Briscola chiamata: raise the points target (standing bid is the 2).
pub fn bid_points(&self, points: i32) {
self.send_json(serde_json::json!({ "action": "bid", "points": points }));
}
/// Briscola chiamata: leave the auction.
pub fn pass(&self) {
self.send_json(serde_json::json!({ "action": "pass" }));
}
/// Briscola chiamata: name the called card.
pub fn call_card(&self, card: &str) {
self.send_json(serde_json::json!({ "action": "call", "card": card }));
}
fn send_json(&self, value: serde_json::Value) {
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
}
+215 -3
View File
@@ -292,8 +292,12 @@ body {
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
/* Tall dialogs (the chiamata call picker, the scoring summaries) can
* exceed short windows: allow scrolling instead of clipping them. */
overflow-y: auto;
padding: 1rem;
z-index: 20;
}
@@ -302,8 +306,11 @@ body {
border-radius: 14px;
padding: 1.5rem 2rem;
text-align: center;
min-width: min(320px, calc(100vw - 1.5rem));
max-width: calc(100vw - 1.5rem);
/* Centers the dialog when it fits and keeps it reachable (top
* aligned, scrollable via the overlay) when it does not. */
margin: auto;
min-width: min(320px, calc(100vw - 3rem));
max-width: calc(100vw - 3rem);
}
.capture-options {
@@ -667,3 +674,208 @@ body {
gap: 0.4rem;
}
}
/* ---------- briscola chiamata ---------- */
/* Five seats: two across the top and one on each side of the viewer. */
.table-grid.cinque {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
grid-template-areas:
"tl . tr"
"left center right"
"bottom bottom bottom";
}
.seat-tl {
grid-area: tl;
}
.seat-tr {
grid-area: tr;
}
.called-badge {
background: var(--panel-light);
color: var(--accent);
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
border: 1px solid var(--accent);
}
.role-badge {
background: var(--accent);
color: var(--felt-dark);
}
.reveal-banner {
background: rgba(232, 197, 71, 0.15);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.35rem 0.75rem;
}
.patta-banner {
background: rgba(217, 83, 79, 0.25);
}
/* The calling auction: standing bid, trail and the bid buttons. */
.auction-panel {
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
max-width: 32rem;
}
.auction-standing {
font-weight: 600;
text-align: center;
}
.auction-history {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
justify-content: center;
}
.auction-chip {
background: var(--panel-light);
color: var(--muted);
border-radius: 4px;
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
}
.bid-options {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: center;
}
.bid-button {
background: var(--panel-light);
color: var(--text);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.bid-button:hover {
background: var(--accent);
color: var(--felt-dark);
}
.bid-button.points {
border-color: var(--team-a);
}
.pass-button {
background: transparent;
color: var(--muted);
border: 1px solid var(--muted);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.pass-button:hover {
color: var(--text);
border-color: var(--text);
}
/* The auction winner's call picker: the four cards of the won rank,
* laid out in a compact grid so the dialog stays short. */
.call-picker {
max-width: min(34rem, calc(100vw - 3rem));
}
.call-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin: 1rem 0 0;
}
@media (min-width: 576px) {
.call-options {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.call-option {
background: var(--panel-light);
border: 1px solid #4c6a54;
border-radius: 10px;
padding: 0.4rem 0.3rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.call-option:hover {
border-color: var(--accent);
}
/* Smaller than a hand card: the picker must fit short laptop windows. */
.playing-card.call-card {
height: 84px;
margin: 0 auto;
}
.call-label {
display: block;
font-size: 0.8rem;
color: var(--text);
margin-top: 0.15rem;
}
.call-note {
display: block;
font-size: 0.7rem;
}
.call-note.partner {
color: var(--team-a);
}
.call-note.solo {
color: var(--accent);
}
.chiamata-row .score-title {
color: var(--accent);
}
@media (max-width: 576px) {
.table-grid.cinque {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"tl tr"
"left right"
"center center"
"bottom bottom";
}
.auction-panel {
padding: 0.5rem;
max-width: 100%;
}
}
/* ---------- match history ---------- */
/* One line per team inside the "Teams" cell of the history table. */
.match-team {
font-size: 0.85rem;
line-height: 1.4;
}
+21 -5
View File
@@ -40,11 +40,27 @@
body {
min-height: 100vh;
background-color: var(--felt-dark);
background-image: radial-gradient(
ellipse at 50% -10%,
var(--felt) 0%,
var(--felt-dark) 65%
);
/* Casino-table felt: a fine grain texture tiled over the green
* gradient, lit from above and darkened towards the edges. The first
* layer is the topmost one. */
background-image:
radial-gradient(
ellipse at 50% 45%,
transparent 55%,
rgba(0, 0, 0, 0.38) 100%
),
radial-gradient(
ellipse at 50% -10%,
rgba(255, 255, 255, 0.07),
transparent 60%
),
url("/assets/felt.svg"),
radial-gradient(
ellipse at 50% -10%,
var(--felt) 0%,
var(--felt-dark) 65%
);
background-repeat: no-repeat, no-repeat, repeat, no-repeat;
background-attachment: fixed;
color: var(--text);
}