diff --git a/README.md b/README.md index 9ab0a32..1871a3a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # tavolo -A platform for multiplayer card games. The first game is **scopone -scientifico** — the four-player, fixed-partnership Italian card game: +A platform for multiplayer card games. Two 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: - **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya) framework, OIDC login, live games in Redis, match statistics in Postgres. - A monorepo of three distributions sharing the `tavolo` namespace: the - game-independent `tavolo-platform`, the scopone game `tavolo-scopone`, - and the `tavolo-app` composition root wiring them together. - See [server/README.md](server/README.md). + A monorepo of four distributions sharing the `tavolo` namespace: the + game-independent `tavolo-platform`, the `tavolo-scopone` and + `tavolo-briscola` games, and the `tavolo-app` composition root wiring + them together. See [server/README.md](server/README.md). - **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with [Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under @@ -37,29 +39,29 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts ## Between hands When a hand ends but the match is not decided, the game pauses on a -**scoring summary screen**: every player sees how each category was won +**scoring summary screen**: scopone players see how each category was won (carte, denara, settebello, primiera, scope — plus napola and asso -pigliatutto when enabled) -with 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. +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. ## On your turn 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 (randomizing among the legal captures when the +random legal card for them (choosing among the legal captures when the rules require a capture), so one absent player cannot stall the table. ## Watching Anyone who has the game URL (`/game/`) can watch, logged in or not: -spectators see the table, the score and every player's captured cards and -scope, but never a hand and never the join code. 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. +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 +"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. ## Development @@ -69,10 +71,11 @@ Backend (from `server/`): cd server python3 -m venv .venv .venv/bin/pip install -r requirements.txt -.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]' +.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]' .venv/bin/python -m unittest discover -s tests -t . .venv/bin/python -m unittest discover -s packages/tavolo-platform/tests .venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests +.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests ``` Frontend (from `web/`), with the backend running on :8080: diff --git a/server/Dockerfile b/server/Dockerfile index 8522511..eda6d65 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -58,13 +58,13 @@ RUN --mount=type=cache,target=/root/.cache/pip \ && /opt/venv/bin/pip install --upgrade pip \ && /opt/venv/bin/pip install -r requirements.txt -# The three sibling distributions, installed from the local tree (PEP 420 +# The four sibling distributions, installed from the local tree (PEP 420 # namespace packages sharing the `tavolo` namespace). --no-deps: every # third-party dependency — including the tavolo-app `otel` extra — is # already pinned in requirements.txt. COPY server/packages/ ./packages/ RUN --mount=type=cache,target=/root/.cache/pip \ - /opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-app + /opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-briscola ./packages/tavolo-app # server/pyproject.toml only carries the [tool.aerich] section (the # db-migrate compose service runs `aerich upgrade` with working_dir=/app, # next to ./migrations). diff --git a/server/README.md b/server/README.md index f67fcad..2b26110 100644 --- a/server/README.md +++ b/server/README.md @@ -1,10 +1,12 @@ # tavolo (backend) The backend for a multiplayer card-game platform, built on the -[kaya](../kaya) framework. It is a monorepo of three distributions +[kaya](../kaya) framework. It is a monorepo of four distributions sharing the `tavolo` namespace (see -[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md) -and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)): +[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md), +[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md) +and +[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)): - **`tavolo-platform`** (`packages/tavolo-platform/`) — everything game-independent: the lobby API, the live-game store, the WebSocket @@ -13,8 +15,12 @@ and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)): - **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone 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. - **`tavolo-app`** (`packages/tavolo-app/`) — the - composition root wiring the platform to the scopone game, with the + composition root wiring the platform to the games, with the environment-driven settings and the SPA shell. Players authenticate with the configured **OIDC** provider. Live game @@ -74,8 +80,8 @@ All configuration comes from environment variables (see `.env.example`): | `OIDC__POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login | | `OIDC__POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout | | `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | -| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) | -| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) | +| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements (wired into the game engines) | +| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them (wired into the game engines) | | `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker | | `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console | | `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set | @@ -212,7 +218,7 @@ aggregate with `max`, not `sum`. signals; every open WebSocket reloads the session and pushes the personalized view to its player. - `tavolo:deadlines` — a sorted set (score = due timestamp) of pending - timeouts (e.g. scopone's turn auto-plays and hand-end auto-continues). + timeouts (e.g. the games' turn auto-plays and hand-end auto-continues). Every worker runs a consumer that fires due entries under the per-game lock by calling the engine's `fire_deadline`, so timeouts do not depend on any player being connected and survive the death of any worker @@ -229,7 +235,8 @@ aggregate with `max`, not `sum`. scoped per game), start/finish timestamps, and the game-specific outcome as reported by the engine (`result` JSON — for scopone: both teams' final scores, winner, target score, hands played and the per-hand - audit). + audit; for briscola: the final scores, winner, target score, deals + played and the per-deal audit). - `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 @@ -268,7 +275,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}}`. 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}`. 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 | @@ -291,7 +298,8 @@ Server → client messages are JSON objects with a `type`: hand is included; other players expose only their card counts. Spectators get no hand at all, `spectator: true` marks the view and `join_code` is omitted for them. `your_turn: true` is present when it - is your move. + is your move. `seats_open` reports the free chairs (0 once the match + started). - `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}` - `{"type": "error", "code": "illegal_move", "message": "..."}` @@ -319,6 +327,19 @@ scopone scientifico: after `HAND_ACK_TIMEOUT_SECONDS`. - `state` asks for a fresh snapshot. +For briscola (`briscola`, `briscola_a_quattro`) the game actions are +`play` (with `card` only — every card in hand is legal) and `ack`: + +```json +{"action": "play", "card": "04C"} +{"action": "ack"} +``` + +- `card` uses the same `RRSUIT` convention. +- `ack` acknowledges the deal-end summary; the next deal is dealt once + every player has acknowledged, or automatically after + `HAND_ACK_TIMEOUT_SECONDS`. + After every accepted move the new state is broadcast to every connected client, spectators included. @@ -336,15 +357,18 @@ match keeps progressing even with every player disconnected. ### Hand-end summary When a hand finishes but the match continues, the game enters the -`hand_end` phase instead of dealing immediately: the state carries -`last_hand` (a full scoring breakdown with an `award` map naming the team -that won each category), the `acknowledged` seats and a -`hand_end_deadline`. The frontend renders this as a screen every player -must dismiss. A play attempted in this phase is rejected with an +`hand_end` phase instead of dealing immediately: the state carries the +scoring breakdown of the hand just played (`last_hand` with an `award` +map naming the team that won each scopone category; `last_deal` with the +deal's points and running totals for briscola), the `acknowledged` seats +and a `hand_end_deadline`. The frontend renders this as a screen every +player must dismiss. A play attempted in this phase is rejected with an `illegal_move` error. ## Rules implemented +### Scopone scientifico + - 40-card Italian deck, ten cards per player, empty table at hand start. - A card captures a **single card of equal rank** (mandatory when present) or a **combination of table cards whose ranks sum to its own**. @@ -368,20 +392,45 @@ must dismiss. A play attempted in this phase is rejected with an configurable per game via the `target_score` creation option) with a clear lead; a tie at or above the target is broken by another hand. +### Briscola + +- 40-card Italian deck, three cards per player. The card turned up after + the deal fixes the trump (*briscola*) suit for the whole deal and is the + last card drawn from the stock. +- Trick play: the highest trump wins; when no trump was played, the + highest card of the led suit wins. The winner takes the trick and leads + the next one. +- After each trick every player draws back up to three cards, starting + with the trick winner; the face-up briscola card goes to whoever's turn + to draw it is. +- Card points: ace 11, three 10, king 4, horse 3, jack 2 — 120 points per + deal. Trick strength is unrelated to points: ace > three > king > horse + > jack > 7 > 6 > 5 > 4 > 2. +- Match points accumulate across deals; a 60–60 deal is a *patta*: its + points are discarded and the deal is replayed. The match ends when a + team reaches the target score (default 61, configurable per game via + the `target_score` creation option) with a clear lead; a tie at or + above the target is broken by another deal. +- `briscola` is the classic two-player game; `briscola_a_quattro` seats + four players in two fixed partnerships (seats 0/2 vs 1/3, partners + opposite). + ## Development ```sh python3 -m venv .venv .venv/bin/pip install -r requirements.txt -.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]' +.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]' # app integration suite: .venv/bin/python -m unittest discover -s tests -t . # per-package suites (each package is self-contained): .venv/bin/python -m unittest discover -s packages/tavolo-platform/tests .venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests +.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests # type checks (see packages/tavolo-app/README.md for the app modules): .venv/bin/python -m mypy -p tavolo.platform .venv/bin/python -m mypy -p tavolo.scopone +.venv/bin/python -m mypy -p tavolo.briscola ``` Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores @@ -439,6 +488,12 @@ packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests) ├── engine.py # pure scopone scientifico rules ├── errors.py # scopone-specific rule violations └── plugin.py # ScoponeEngine: the GameEngine adapter +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) + ├── errors.py # briscola-specific rule violations + └── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters packages/tavolo-app/ # deployable app: composition root (own pyproject) src/tavolo/ ├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins @@ -449,5 +504,5 @@ packages/tavolo-app/ # deployable app: composition root (own pyproject) └── static.py # SPA shell hosting pyproject.toml # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD migrations/ # aerich migrations for the platform models -tests/ # app integration suite (platform + scopone wired together) +tests/ # app integration suite (platform + games wired together) ``` diff --git a/server/packages/tavolo-app/README.md b/server/packages/tavolo-app/README.md index 6e7cbf8..20b15ba 100644 --- a/server/packages/tavolo-app/README.md +++ b/server/packages/tavolo-app/README.md @@ -2,14 +2,15 @@ The deployable tavolo application: the composition root wiring [`tavolo-platform`](../tavolo-platform/README.md) to the -[`tavolo-scopone`](../tavolo-scopone/README.md) game. +[`tavolo-scopone`](../tavolo-scopone/README.md) and +[`tavolo-briscola`](../tavolo-briscola/README.md) games. ## Contents - `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI mixins plus `PlatformMixin` and the deadline scheduler), registers the - `ScoponeEngine` with its configured timeouts, and exposes - `tavolo.app:app` for granian. + `ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with + their configured timeouts, and exposes `tavolo.app:app` for granian. - `config.py` — environment-driven frozen `Settings`. - `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel: meter/tracer/logger providers, the diff --git a/server/packages/tavolo-app/pyproject.toml b/server/packages/tavolo-app/pyproject.toml index bfc8905..44a471c 100644 --- a/server/packages/tavolo-app/pyproject.toml +++ b/server/packages/tavolo-app/pyproject.toml @@ -5,12 +5,13 @@ build-backend = "setuptools.build_meta" [project] name = "tavolo-app" version = "0.1.0" -description = "Tavolo multiplayer card-game application: platform + scopone game wiring" +description = "Tavolo multiplayer card-game application: platform + games wiring" readme = "README.md" requires-python = ">=3.10" dependencies = [ "tavolo-platform", "tavolo-scopone", + "tavolo-briscola", "kaya-core", "kaya-cors", "kaya-session", diff --git a/server/packages/tavolo-app/src/tavolo/app.py b/server/packages/tavolo-app/src/tavolo/app.py index da2f90d..29fe799 100644 --- a/server/packages/tavolo-app/src/tavolo/app.py +++ b/server/packages/tavolo-app/src/tavolo/app.py @@ -39,6 +39,7 @@ from kaya.oidc import OIDCConfig, OIDCProviders from kaya.openapi import OpenAPIMixin from kaya.session import SessionMixin from redis.asyncio import Redis +from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine from tavolo.platform import GameRegistry, Platform, PlatformMixin from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore @@ -131,9 +132,21 @@ registry.register( hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds, ) ) +registry.register( + BriscolaEngine( + turn_timeout_seconds=settings.turn_timeout_seconds, + hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds, + ) +) +registry.register( + BriscolaQuattroEngine( + turn_timeout_seconds=settings.turn_timeout_seconds, + hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds, + ) +) log.info("registered games: %s", ", ".join(e.id for e in registry.all())) log.debug( - "scopone timeouts: hand_ack=%ds turn=%ds", + "game timeouts: hand_ack=%ds turn=%ds", settings.hand_ack_timeout_seconds, settings.turn_timeout_seconds, ) diff --git a/server/packages/tavolo-app/src/tavolo/config.py b/server/packages/tavolo-app/src/tavolo/config.py index ea1dd12..d9b8eff 100644 --- a/server/packages/tavolo-app/src/tavolo/config.py +++ b/server/packages/tavolo-app/src/tavolo/config.py @@ -215,12 +215,12 @@ class Settings: # are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars). static_dir: str # Seconds the between-hands scoring summary waits for acknowledgements - # before dealing the next hand anyway (wired into the ScoponeEngine; + # before dealing the next hand anyway (wired into the game engines; # see tavolo.app). hand_ack_timeout_seconds: int # Seconds a player has to play before the server plays a random legal # card for them, covering disconnects and idle players (wired into the - # ScoponeEngine; see tavolo.app). + # game engines; see tavolo.app). turn_timeout_seconds: int # Upper bound on how long the deadline consumer sleeps between polls # when nothing else wakes it. Enqueued deadlines are announced over a diff --git a/server/packages/tavolo-briscola/README.md b/server/packages/tavolo-briscola/README.md new file mode 100644 index 0000000..9845366 --- /dev/null +++ b/server/packages/tavolo-briscola/README.md @@ -0,0 +1,58 @@ +# tavolo-briscola + +Briscola — the Italian trick-taking game — as a +[`tavolo-platform`](../tavolo-platform/README.md) game implementation. +Two variants share one rules core: + +- `briscola` — the classic two-player game; +- `briscola_a_quattro` — four-player fixed-partnership briscola. + +## Contents + +- `state.py` — `BriscolaState` (pure game data: phases, players, hands, + stock, face-up briscola, tricks, scores, deadlines), `PlayerState`, + `Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session + envelope, no transport, no I/O. +- `engine.py` — the pure rules engine: deck, deals, trick resolution + (trump first, then the led suit; strength ace > three > king > horse > + jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the + face-up briscola last), card points (ace 11, three 10, king 4, horse 3, + jack 2), patta re-deals and the target-score match end, deal-end + acknowledgements and auto-play. Deterministic and I/O-free apart from + logging, so the whole rule set is unit-testable. +- `errors.py` — briscola-specific errors (`CardNotInHand`); every other + failure mode is a shared `tavolo.platform.errors` subclass. +- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine` + (`GameEngine`): the platform-facing adapters. They validate the + `target_score` creation option (61–120, default 61) and extract the + `MatchResult` (teams, winner, per-player scores and the match summary + persisted as the match's JSON `result`). + +Timeouts are constructor arguments (`turn_timeout_seconds`, +`hand_ack_timeout_seconds`), wired from the environment by the +application composition root. + +## Rules + +- Three cards per player; the next card is turned face up: its suit is + the trump (briscola) for the deal and it is the last card drawn. +- The winner of a trick is the highest trump, or the highest card of the + led suit when no trump was played. The winner takes the trick and leads + the next one. +- After each trick everyone draws back up to three cards, starting with + the trick winner. +- Card points total 120 per deal; a 60–60 deal is a *patta*, its points + are discarded and the deal is replayed. The first team to the target + score wins; a tie at or above the target is broken by another deal. +- Between deals of an unfinished match every player acknowledges the + summary (or the hand-end timeout deals on), exactly like scopone. + +## Development (from `server/`) + +```sh +.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests +.venv/bin/python -m mypy -p tavolo.briscola +``` + +`test_engine.py` covers the pure rules; `test_plugin.py` covers the +platform contract (actions, deadlines, serialization, results). diff --git a/server/packages/tavolo-briscola/pyproject.toml b/server/packages/tavolo-briscola/pyproject.toml new file mode 100644 index 0000000..9a0c26e --- /dev/null +++ b/server/packages/tavolo-briscola/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "tavolo-briscola" +version = "0.1.0" +description = "Briscola game implementation for the tavolo platform" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "tavolo-platform", +] + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = true + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +plugins = [] diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/__init__.py b/server/packages/tavolo-briscola/src/tavolo/briscola/__init__.py new file mode 100644 index 0000000..0dfad05 --- /dev/null +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/__init__.py @@ -0,0 +1,20 @@ +"""Briscola: tavolo's trick-taking game implementation. + +Two variants share the same rules core and platform adapter: + +- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player + briscola (``briscola``); +- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player + fixed-partnership briscola a quattro (``briscola_a_quattro``). + +The pure rules (:mod:`tavolo.briscola.engine`, +:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres; +:mod:`tavolo.briscola.plugin` adapts them to the platform's +:class:`~tavolo.platform.engine.GameEngine` contract so the +game-independent platform can host them. +""" +from __future__ import annotations + +from .plugin import BriscolaEngine, BriscolaQuattroEngine + +__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"] diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/engine.py b/server/packages/tavolo-briscola/src/tavolo/briscola/engine.py new file mode 100644 index 0000000..3738484 --- /dev/null +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/engine.py @@ -0,0 +1,469 @@ +"""Pure rules engine for briscola. + +Every function here is deterministic and I/O-free (the only side effect is +logging): it mutates (or reads) +:class:`~tavolo.briscola.state.BriscolaState` and raises +:class:`~tavolo.platform.errors.GameError` subclasses on rule violations. +This makes the whole rule set unit-testable without Redis, Postgres or +HTTP. The platform-facing adapter is +:class:`~tavolo.briscola.plugin.BriscolaEngine`. + +Rules implemented +----------------- +* 40-card Italian deck (4 suits x ranks 1-10), three cards per player. +* The next card after the deal is turned face up: its suit is the + **briscola** (trump) for the whole deal, and it is the last card drawn + from the stock (the stock is drawn from the bottom up). +* Trick play: the player to the dealer's left leads; each other player + plays one card. The highest trump wins; when no trump was played, the + highest card of the led suit wins. The winner takes the trick and leads + the next one. +* After each trick every player draws back up to three cards, starting + with the trick winner and continuing in turn order; the face-up + briscola card is drawn last, so whoever's turn to draw when only it + remains takes it. +* Card points: ace 11, three 10, king 4, horse 3, jack 2, everything else + 0 — 120 points per deal. Trick strength is unrelated to points: + ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2. +* Match points accumulate across deals. A 60-60 deal is a **patta**: its + points are discarded and the deal is replayed (the dealer rotates). + The first team to reach the target score (default 61) wins; a tie at or + above the target is broken by another deal. +* Between deals of an unfinished match the game pauses in the + ``hand_end`` phase for the deal summary, exactly like scopone: every + player acknowledges (or the hand-end timeout elapses) before the next + deal. +""" +from __future__ import annotations + +import random +from datetime import datetime, timedelta, timezone +from logging import getLogger +from typing import Any, Dict, List, Optional, Tuple + +from tavolo.platform.errors import ( + AlreadyJoined, + GameFinished, + GameNotStarted, + IllegalMove, + LobbyFull, + NotYourTurn, +) + +from .errors import CardNotInHand + +from .state import ( + DEFAULT_HAND_ACK_TIMEOUT_SECONDS, + DEFAULT_TARGET_SCORE, + DEFAULT_TURN_TIMEOUT_SECONDS, + HAND_SIZE, + MAX_TARGET_SCORE, + MIN_TARGET_SCORE, + PHASE_FINISHED, + PHASE_HAND_END, + PHASE_LOBBY, + PHASE_PLAYING, + PLAYER_COUNTS, + SUITS, + TEAM_NAMES, + BriscolaState, + Card, + PlayerState, + Trick, + TrickCard, + parse_card, +) + +log = getLogger(__name__) + +# Trick strength: higher wins. Ace > three > king > horse > jack, then the +# remaining ranks in descending order. All ranks are distinct within a +# suit, so a trick never ties. +TRICK_STRENGTH: Dict[int, int] = { + 1: 9, + 3: 8, + 10: 7, + 9: 6, + 8: 5, + 7: 4, + 6: 3, + 5: 2, + 4: 1, + 2: 0, +} + +# Card points: 120 per deal in total. +CARD_POINTS: Dict[int, int] = { + 1: 11, + 3: 10, + 10: 4, + 9: 3, + 8: 2, +} + +_rng = random.SystemRandom() + + +def full_deck() -> List[Card]: + """Return the 40 cards of the Italian deck in canonical order.""" + return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)] + + +def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]: + """Return a shuffled deck. Pass ``rng`` for deterministic tests.""" + deck = full_deck() + (rng or _rng).shuffle(deck) + return deck + + +def card_points(card: Card) -> int: + """The point value of a single card.""" + return CARD_POINTS.get(card.rank, 0) + + +def create_game( + creator_sub: str, + creator_name: str, + player_count: int = 2, + target_score: int = DEFAULT_TARGET_SCORE, + hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, + turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, +) -> BriscolaState: + """Create a lobby game with the creator seated first.""" + if player_count not in PLAYER_COUNTS: + raise IllegalMove(f"player_count must be one of {PLAYER_COUNTS}") + if ( + isinstance(target_score, bool) + or not isinstance(target_score, int) + or target_score < MIN_TARGET_SCORE + or target_score > MAX_TARGET_SCORE + ): + raise IllegalMove( + f"target_score must be between {MIN_TARGET_SCORE} and {MAX_TARGET_SCORE}" + ) + return BriscolaState( + player_count=player_count, + target_score=target_score, + phase=PHASE_LOBBY, + players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], + hand_ack_timeout=hand_ack_timeout, + turn_timeout=turn_timeout, + ) + + +def join_game(state: BriscolaState, sub: str, name: str) -> None: + """Seat ``sub`` in the next free chair, starting the match when full.""" + if state.phase != PHASE_LOBBY: + raise GameNotStarted("game has already started") + if state.seated(sub): + raise AlreadyJoined("already joined this game") + if len(state.players) >= state.player_count: + raise LobbyFull("game is full") + seat = len(state.players) + state.players.append(PlayerState(sub=sub, name=name, seat=seat)) + if len(state.players) == state.player_count: + start_game(state) + + +def start_game(state: BriscolaState) -> None: + """Deal the first hand and switch the game to playing.""" + if len(state.players) != state.player_count: + raise GameNotStarted( + f"need exactly {state.player_count} players to start" + ) + state.phase = PHASE_PLAYING + _deal(state) + + +def _set_turn_deadline(state: BriscolaState) -> None: + """Arm the auto-play deadline for whoever is on turn.""" + deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout) + state.turn_deadline = deadline.isoformat() + + +def _deal(state: BriscolaState) -> None: + deck = shuffled_deck() + for player in state.players: + player.hand = [] + player.captured = [] + state.trick = [] + state.last_trick = None + state.briscola_card = None + # Dealer rotates each deal; the first card is played by the player to + # the dealer's left. + state.turn = (state.dealer + 1) % state.player_count + state.trick_leader = state.turn + for _ in range(HAND_SIZE): + for offset in range(state.player_count): + player = _player_at( + state, (state.dealer + 1 + offset) % state.player_count + ) + player.hand.append(deck.pop()) + # The turned-up card is the last card drawn: keep it separate from the + # face-down stock and hand it out once the stock is exhausted. + state.briscola_card = deck.pop() + state.briscola_suit = state.briscola_card.suit + state.stock = deck + _set_turn_deadline(state) + log.debug( + "deal %d dealt (dealer seat %d, briscola %s)", + state.hand_number, + state.dealer, + state.briscola_card.code, + ) + + +def _player_at(state: BriscolaState, seat: int) -> PlayerState: + for player in state.players: + if player.seat == seat: + return player + raise IllegalMove(f"no player in seat {seat}") + + +def play(state: BriscolaState, sub: str, card_code: str) -> None: + """Apply one move by the player identified by ``sub``. + + Any card in hand is a legal play; only turn order and phase are + validated. Raises a :class:`~tavolo.platform.errors.GameError` + subclass on any violation. + """ + if state.phase == PHASE_FINISHED: + raise GameFinished("the match is over") + if state.phase == PHASE_HAND_END: + raise IllegalMove("the deal is over; acknowledge the summary to continue") + if state.phase != PHASE_PLAYING: + raise GameNotStarted("the game has not started yet") + + player = state.player_for(sub) + if player is None or player.seat != state.turn: + raise NotYourTurn("it is not your turn") + + card = parse_card(card_code) + if card not in player.hand: + raise CardNotInHand(f"card {card.code} is not in your hand") + + player.hand.remove(card) + state.trick.append(TrickCard(seat=player.seat, card=card.code)) + log.debug( + "deal %d: seat %d played %s (%d/%d in the trick)", + state.hand_number, + player.seat, + card.code, + len(state.trick), + state.player_count, + ) + + if len(state.trick) == state.player_count: + _resolve_trick(state) + else: + state.turn = (state.turn + 1) % state.player_count + _set_turn_deadline(state) + + +def _resolve_trick(state: BriscolaState) -> None: + """Score the completed trick, draw and hand the lead to the winner.""" + winner = _trick_winner(state) + taken = [parse_card(t.card) for t in state.trick] + _player_at(state, winner).captured.extend(taken) + state.last_trick = Trick(winner=winner, cards=list(state.trick)) + state.trick = [] + state.trick_leader = winner + state.turn = winner + log.debug( + "deal %d: seat %d takes the trick with %s", + state.hand_number, + winner, + [t.card for t in state.last_trick.cards], + ) + + if all(not player.hand for player in state.players): + _end_deal(state) + return + _draw(state, winner) + _set_turn_deadline(state) + + +def _trick_winner(state: BriscolaState) -> int: + """The seat winning the current (complete) trick.""" + played = [(t.seat, parse_card(t.card)) for t in state.trick] + leader_suit = played[0][1].suit + trumps = [(seat, card) for seat, card in played if card.suit == state.briscola_suit] + pool = trumps or [ + (seat, card) for seat, card in played if card.suit == leader_suit + ] + return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0] + + +def _draw(state: BriscolaState, winner: int) -> None: + """Draw back up to ``HAND_SIZE`` cards, winner first, then turn order.""" + for offset in range(state.player_count): + player = _player_at(state, (winner + offset) % state.player_count) + if state.stock: + player.hand.append(state.stock.pop()) + elif state.briscola_card is not None: + # The face-up briscola is the last card of the stock: the first + # player who needs a card when only it remains takes it. + player.hand.append(state.briscola_card) + state.briscola_card = None + + +def auto_play(state: BriscolaState, rng: Optional[random.Random] = None) -> None: + """Play a random card for the player currently on turn. + + Every card in hand is legal, so one is drawn at random. Delegates to + :func:`play`, so the move is fully validated and can end the deal or + the match. Pass ``rng`` for deterministic tests. + """ + if state.phase != PHASE_PLAYING: + raise GameNotStarted("the game has not started yet") + player = _player_at(state, state.turn) + if not player.hand: + raise IllegalMove("the player on turn has no cards") + chooser = rng or _rng + card = chooser.choice(player.hand) + play(state, player.sub, card.code) + + +def _end_deal(state: BriscolaState) -> None: + """Score the deal and either finish the match or pause for the summary. + + A 60-60 deal is a patta: its points do not count and the deal is + replayed. If the match continues, the game pauses in the ``hand_end`` + phase so every player can read the summary; the next deal is dealt by + :func:`acknowledge_hand` once all players have acknowledged (or by the + hand-end timeout fired by the platform's deadline scheduler). + """ + state.turn_deadline = None + points, details = deal_points(state) + patta = points[0] == points[1] + if not patta: + state.scores[0] += points[0] + state.scores[1] += points[1] + entry: Dict[str, Any] = { + "deal": state.hand_number, + "patta": patta, + "points": details["points"], + "cards": details["cards"], + "totals": {"A": state.scores[0], "B": state.scores[1]}, + } + state.hand_scores.append(entry) + + a, b = state.scores + log.debug( + "deal %d scored A+%d B+%d%s (totals %d-%d)", + state.hand_number, + points[0], + points[1], + " patta" if patta else "", + a, + b, + ) + if not patta and max(a, b) >= state.target_score and a != b: + state.phase = PHASE_FINISHED + state.winner = 0 if a > b else 1 + log.info("match ended, team %s wins", TEAM_NAMES[state.winner]) + return + + # Pause for the deal summary instead of dealing immediately. + state.phase = PHASE_HAND_END + state.acked = [] + deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout) + state.hand_end_deadline = deadline.isoformat() + + +def acknowledge_hand(state: BriscolaState, sub: str) -> None: + """Record that ``sub`` has read the deal summary. + + When every player has acknowledged, the next deal is dealt. + Acknowledging twice is a no-op; acknowledging outside the ``hand_end`` + phase raises an error. + """ + if state.phase != PHASE_HAND_END: + raise IllegalMove("no deal summary is waiting for acknowledgement") + player = state.player_for(sub) + if player is None: + raise NotYourTurn("you are not seated in this game") + if player.seat in state.acked: + return + state.acked.append(player.seat) + if len(state.acked) < state.player_count: + return + + state.hand_number += 1 + state.dealer = (state.dealer + 1) % state.player_count + state.acked = [] + state.hand_end_deadline = None + state.phase = PHASE_PLAYING + _deal(state) + + +def deal_points(state: BriscolaState) -> Tuple[List[int], Dict[str, object]]: + """Compute the deal points for both teams (index 0 = team A).""" + piles: List[List[Card]] = [[], []] + for player in state.players: + piles[player.team].extend(player.captured) + points = [ + sum(card_points(card) for card in pile) for pile in piles + ] + details: Dict[str, object] = { + "points": {"A": points[0], "B": points[1]}, + "cards": {"A": len(piles[0]), "B": len(piles[1])}, + } + return points, details + + +def state_for_player(state: BriscolaState, sub: Optional[str]) -> Dict[str, object]: + """Serialize ``state`` hiding other players' hands. + + Hands are reduced to a count, except for the requesting player's own + hand. Non-seated viewers — including spectators, for whom ``sub`` is + ``None`` — simply see no hand at all. The platform merges the session + envelope (``id``, ``join_code`` for seated players, ``game_type``, + ``spectator``) into the view itself. + """ + viewer = state.player_for(sub) + players: List[Dict[str, object]] = [] + for player in state.players: + view: Dict[str, object] = { + "sub": player.sub, + "name": player.name, + "seat": player.seat, + "team": TEAM_NAMES[player.team], + "cards_left": len(player.hand), + "captured_count": len(player.captured), + "points": sum(card_points(card) for card in player.captured), + } + if viewer is not None and viewer.seat == player.seat: + view["hand"] = [c.code for c in player.hand] + players.append(view) + + payload: Dict[str, object] = { + "phase": state.phase, + "target_score": state.target_score, + "player_count": state.player_count, + "hand_number": state.hand_number, + "dealer": state.dealer, + "turn": state.turn, + "scores": {"A": state.scores[0], "B": state.scores[1]}, + "winner": None if state.winner is None else TEAM_NAMES[state.winner], + "briscola_suit": state.briscola_suit, + "briscola_card": ( + state.briscola_card.code if state.briscola_card is not None else None + ), + "stock_count": len(state.stock), + "trick": [t.to_json() for t in state.trick], + "trick_leader": state.trick_leader, + "last_trick": state.last_trick.to_json() if state.last_trick else None, + "players": players, + "last_deal": state.hand_scores[-1] if state.hand_scores else None, + "acknowledged": list(state.acked), + "hand_end_deadline": state.hand_end_deadline, + "turn_deadline": state.turn_deadline, + } + if ( + viewer is not None + and state.phase == PHASE_PLAYING + and viewer.seat == state.turn + ): + payload["your_turn"] = True + return payload diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/errors.py b/server/packages/tavolo-briscola/src/tavolo/briscola/errors.py new file mode 100644 index 0000000..659acc0 --- /dev/null +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/errors.py @@ -0,0 +1,34 @@ +"""Briscola errors. + +Most failure modes are game-independent and live in +:mod:`tavolo.platform.errors`; only genuinely briscola-specific errors +are defined here. +""" +from __future__ import annotations + +from tavolo.platform.errors import ( + AlreadyJoined, + GameError, + GameFinished, + GameNotFound, + GameNotStarted, + IllegalMove, + LobbyFull, + NotYourTurn, +) + +__all__ = [ + "AlreadyJoined", + "CardNotInHand", + "GameError", + "GameFinished", + "GameNotFound", + "GameNotStarted", + "IllegalMove", + "LobbyFull", + "NotYourTurn", +] + + +class CardNotInHand(GameError): + """The played card is not held by the player.""" diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py new file mode 100644 index 0000000..04c32e4 --- /dev/null +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/plugin.py @@ -0,0 +1,279 @@ +"""The platform-facing adapter for briscola. + +Two :class:`~tavolo.platform.engine.GameEngine` implementations share one +rules core (:mod:`tavolo.briscola.engine`): classic two-player +:class:`BriscolaEngine` and four-player fixed-partnership +:class:`BriscolaQuattroEngine`. They translate platform calls (create, +join, websocket actions, deadlines) into rules-engine calls and back, +keeping all briscola knowledge inside this package. Nothing outside +``tavolo.briscola`` needs to know about phases, tricks or deal scoring. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from logging import getLogger +from typing import Any, Dict, Mapping, Optional + +from tavolo.platform.engine import ( + Deadline, + GameEngine, + GameSession, + MatchResult, + PlayerResult, + Seat, +) +from tavolo.platform.errors import GameError, IllegalMove + +from . import engine +from .state import ( + DEFAULT_HAND_ACK_TIMEOUT_SECONDS, + DEFAULT_TARGET_SCORE, + DEFAULT_TURN_TIMEOUT_SECONDS, + MAX_TARGET_SCORE, + MIN_TARGET_SCORE, + PHASE_FINISHED, + PHASE_HAND_END, + PHASE_LOBBY, + PHASE_PLAYING, + TEAM_NAMES, + BriscolaState, +) + +log = getLogger(__name__) + + +def _deadline_ms(iso: Optional[str]) -> Optional[int]: + """Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent + or unparseable.""" + if not iso: + return None + try: + return int(datetime.fromisoformat(iso).timestamp() * 1000) + except ValueError: + return None + + +class _BriscolaBase(GameEngine): + """Shared platform adapter for the two briscola table sizes.""" + + #: 2 (classic) or 4 (fixed partnerships); set by the subclasses. + player_count: int + + options_schema = { + "type": "object", + "properties": { + "target_score": { + "type": "integer", + "minimum": MIN_TARGET_SCORE, + "maximum": MAX_TARGET_SCORE, + "default": DEFAULT_TARGET_SCORE, + "description": "Match points the winning team must reach.", + }, + }, + } + + def __init__( + self, + turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS, + hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, + ) -> None: + self._turn_timeout = turn_timeout_seconds + self._hand_ack_timeout = hand_ack_timeout_seconds + + # -- lobby ------------------------------------------------------------ + + def create(self, session: GameSession, options: Mapping[str, Any]) -> None: + target_score = options.get("target_score", DEFAULT_TARGET_SCORE) + if isinstance(target_score, bool) or not isinstance(target_score, int): + raise IllegalMove("target_score must be an integer") + creator = session.players[0] + session.state = engine.create_game( + creator_sub=creator.user_sub, + creator_name=creator.display_name, + player_count=self.player_count, + target_score=target_score, + hand_ack_timeout=self._hand_ack_timeout, + turn_timeout=self._turn_timeout, + ) + # The creator takes seat 0, i.e. team A. + session.players[0] = Seat( + user_sub=creator.user_sub, + display_name=creator.display_name, + team=TEAM_NAMES[0], + ) + + def join(self, session: GameSession, user_sub: str, display_name: str) -> None: + seat = len(session.players) + engine.join_game(session.state, user_sub, display_name) + # join_game raises before appending on any violation, so the seat + # list stays in sync with the engine's players. + session.players.append( + Seat( + user_sub=user_sub, + display_name=display_name, + team=TEAM_NAMES[seat % 2], + ) + ) + + def in_lobby(self, session: GameSession) -> bool: + return session.state.phase == PHASE_LOBBY + + def lobby_view(self, session: GameSession) -> Dict[str, Any]: + state: BriscolaState = session.state + return { + "phase": state.phase, + "target_score": state.target_score, + } + + # -- play ------------------------------------------------------------- + + def handle_action( + self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any] + ) -> None: + state: BriscolaState = session.state + if action == "play": + card = payload.get("card") + if not isinstance(card, str): + raise IllegalMove("'card' must be a card code string") + try: + engine.play(state, user_sub, card) + except ValueError: + raise IllegalMove("invalid card code") + elif action == "ack": + engine.acknowledge_hand(state, user_sub) + else: + raise IllegalMove(f"unknown action: {action!r}") + + def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: + return engine.state_for_player(session.state, user_sub) + + def is_finished(self, session: GameSession) -> bool: + return session.state.phase == PHASE_FINISHED + + def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: + state: BriscolaState = session.state + return { + "scores": {"A": state.scores[0], "B": state.scores[1]}, + "winner": None if state.winner is None else TEAM_NAMES[state.winner], + } + + # -- (de)serialization ------------------------------------------------- + + def state_to_json(self, state: Any) -> Dict[str, Any]: + assert isinstance(state, BriscolaState) + return state.to_json() + + def state_from_json(self, data: Mapping[str, Any]) -> Any: + return BriscolaState.from_json(dict(data)) + + # -- deadlines ---------------------------------------------------------- + + def next_deadline(self, session: GameSession) -> Optional[Deadline]: + state: BriscolaState = session.state + if state.phase == PHASE_PLAYING and state.turn_deadline: + due_ms = _deadline_ms(state.turn_deadline) + if due_ms is None: + return None + return Deadline( + kind="turn", + due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc), + token=f"turn:{state.hand_number}:{state.turn}:{due_ms}", + ) + if state.phase == PHASE_HAND_END and state.hand_end_deadline: + due_ms = _deadline_ms(state.hand_end_deadline) + if due_ms is None: + return None + return Deadline( + kind="hand_end", + due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc), + token=f"hand_end:{state.hand_number}:{due_ms}", + ) + return None + + def fire_deadline(self, session: GameSession, kind: str, token: str) -> None: + current = self.next_deadline(session) + if current is None or current.kind != kind or current.token != token: + # Overtaken by events (a play landed in time, the deal was + # acknowledged, the deadline moved): nothing to do. + raise GameError("stale deadline") + state: BriscolaState = session.state + if kind == "turn": + seat = state.turn + engine.auto_play(state) + log.info( + "auto-played for seat %s (turn timeout, deal %d)", + seat, + state.hand_number, + ) + elif kind == "hand_end": + for player in state.players: + engine.acknowledge_hand(state, player.sub) + log.info( + "deal %d auto-advanced after the acknowledgement timeout", + state.hand_number, + ) + else: # pragma: no cover - next_deadline never emits other kinds + raise GameError(f"unknown deadline kind: {kind!r}") + + # -- results ------------------------------------------------------------- + + def result(self, session: GameSession) -> MatchResult: + state: BriscolaState = session.state + if state.winner is None: + raise GameError("no result: the match is not finished") + teams = [ + [p.sub for p in state.players if p.team == 0], + [p.sub for p in state.players if p.team == 1], + ] + return MatchResult( + teams=teams, + winner_team=state.winner, + players=[ + PlayerResult( + user_sub=player.sub, + seat=player.seat, + won=player.team == state.winner, + team=TEAM_NAMES[player.team], + score=float(state.scores[player.team]), + details={"captured_cards": len(player.captured)}, + ) + for player in state.players + ], + summary={ + "team_a_score": state.scores[0], + "team_b_score": state.scores[1], + "winner_team": TEAM_NAMES[state.winner], + "target_score": state.target_score, + "deals_played": state.hand_number, + "deal_scores": state.hand_scores, + }, + ) + + +class BriscolaEngine(_BriscolaBase): + """Classic two-player briscola as a platform game engine.""" + + id = "briscola" + name = "Briscola" + description = ( + "The classic two-player Italian trick-taking game: three cards " + "each, a trump suit and 120 points per deal. First to the target " + "score wins." + ) + min_players = 2 + max_players = 2 + player_count = 2 + + +class BriscolaQuattroEngine(_BriscolaBase): + """Four-player fixed-partnership briscola as a platform game engine.""" + + id = "briscola_a_quattro" + name = "Briscola a quattro" + description = ( + "Briscola played in two fixed partnerships of two, three cards " + "each and a trump suit. First team to the target score wins." + ) + min_players = 4 + max_players = 4 + player_count = 4 diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/py.typed b/server/packages/tavolo-briscola/src/tavolo/briscola/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/packages/tavolo-briscola/src/tavolo/briscola/state.py b/server/packages/tavolo-briscola/src/tavolo/briscola/state.py new file mode 100644 index 0000000..b62c9c4 --- /dev/null +++ b/server/packages/tavolo-briscola/src/tavolo/briscola/state.py @@ -0,0 +1,279 @@ +"""In-memory representation of a briscola game. + +The whole mutable game lives in :class:`BriscolaState`, which is +serialized to and from plain JSON for storage inside the platform's +session envelope (see :mod:`tavolo.platform.store`). Keeping the +representation JSON-native means the store needs no custom codecs and the +state is inspectable with ``redis-cli``. + +The state contains only game data: the platform owns the session envelope +(id, join code, seats, timestamps, stats persistence) — see +:class:`tavolo.platform.engine.GameSession`. + +Deck convention: a 40-card Italian deck. Suits are ``D`` (denari), +``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``. +A card is rendered as ``RRSUIT`` (e.g. ``07D``). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +SUITS = ("D", "C", "S", "B") +RANKS = tuple(range(1, 11)) + +# Teams are derived from the seat: seats 0 and 2 form team A (index 0), +# seats 1 and 3 form team B (index 1). In the two-player variant the teams +# are simply the two players; in briscola a quattro the partners sit +# opposite each other. +TEAM_A = 0 +TEAM_B = 1 +TEAM_NAMES = ("A", "B") + +PHASE_LOBBY = "lobby" +PHASE_PLAYING = "playing" +# Between deals of an unfinished match: deal summary shown to every +# player; the next deal is dealt once all players acknowledge (or the +# hand-end timeout elapses). +PHASE_HAND_END = "hand_end" +PHASE_FINISHED = "finished" + +# Supported table sizes: classic head-to-head and four-player partnerships. +PLAYER_COUNTS = (2, 4) + +DEFAULT_TARGET_SCORE = 61 +MIN_TARGET_SCORE = 61 +MAX_TARGET_SCORE = 120 + +# Cards dealt to each player at the start of a deal. +HAND_SIZE = 3 + +# Default seconds the between-deals summary waits before dealing anyway. +# Games carry their own copy in ``BriscolaState.hand_ack_timeout``. +DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30 + +# Default seconds a player has to play before the server plays a random +# card for them. Games carry their own copy in +# ``BriscolaState.turn_timeout``. +DEFAULT_TURN_TIMEOUT_SECONDS = 30 + + +def team_of(seat: int) -> int: + return seat % 2 + + +@dataclass(frozen=True) +class Card: + rank: int + suit: str + + def __post_init__(self) -> None: + if self.suit not in SUITS: + raise ValueError(f"invalid suit: {self.suit!r}") + if self.rank not in RANKS: + raise ValueError(f"invalid rank: {self.rank!r}") + + @property + def code(self) -> str: + return f"{self.rank:02d}{self.suit}" + + @staticmethod + def parse(code: str) -> "Card": + code = str(code).upper() + if len(code) != 3 or not code[:2].isdigit(): + raise ValueError(f"invalid card code: {code!r}") + return Card(rank=int(code[:2]), suit=code[2]) + + def to_json(self) -> str: + return self.code + + @staticmethod + def from_json(value: Any) -> "Card": + return Card.parse(str(value)) + + +def parse_card(code: Any) -> Card: + """Parse a card code, raising :class:`ValueError` on malformed input.""" + return Card.parse(str(code)) + + +@dataclass +class TrickCard: + """One card played in the current trick, in play order.""" + + seat: int + card: str + + def to_json(self) -> Dict[str, Any]: + return {"seat": self.seat, "card": self.card} + + @staticmethod + def from_json(data: Dict[str, Any]) -> "TrickCard": + return TrickCard(seat=int(data["seat"]), card=str(data["card"])) + + +@dataclass +class Trick: + """A completed trick: the winner's seat and the cards it captured.""" + + winner: int + cards: List[TrickCard] = field(default_factory=list) + + def to_json(self) -> Dict[str, Any]: + return { + "winner": self.winner, + "cards": [c.to_json() for c in self.cards], + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "Trick": + return Trick( + winner=int(data["winner"]), + cards=[TrickCard.from_json(c) for c in data.get("cards", [])], + ) + + +@dataclass +class PlayerState: + sub: str + name: str + seat: int + hand: List[Card] = field(default_factory=list) + captured: List[Card] = field(default_factory=list) + + @property + def team(self) -> int: + return team_of(self.seat) + + def to_json(self) -> Dict[str, Any]: + return { + "sub": self.sub, + "name": self.name, + "seat": self.seat, + "hand": [c.to_json() for c in self.hand], + "captured": [c.to_json() for c in self.captured], + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "PlayerState": + return PlayerState( + sub=str(data["sub"]), + name=str(data["name"]), + seat=int(data["seat"]), + hand=[Card.from_json(c) for c in data.get("hand", [])], + captured=[Card.from_json(c) for c in data.get("captured", [])], + ) + + +@dataclass +class BriscolaState: + player_count: int = 2 + target_score: int = DEFAULT_TARGET_SCORE + phase: str = PHASE_LOBBY + players: List[PlayerState] = field(default_factory=list) + # Face-down draw pile; cards are drawn from the end. + stock: List[Card] = field(default_factory=list) + # The face-up card turned at deal time: its suit is the trump suit for + # the whole deal, and it is the last card drawn from the stock. + briscola_card: Optional[Card] = None + briscola_suit: str = "" + dealer: int = 0 + turn: int = 0 + # 1-based deal counter, used in the audit trail and deadlines. + hand_number: int = 1 + # Cards of the trick in progress, in play order. + trick: List[TrickCard] = field(default_factory=list) + trick_leader: int = 0 + # The most recently completed trick, for the table announcement. + last_trick: Optional[Trick] = None + # Match points accumulated across deals (patta deals do not count). + scores: List[int] = field(default_factory=lambda: [0, 0]) + winner: Optional[int] = None + # Per-deal points awarded, for a compact audit trail in the API. + hand_scores: List[Dict[str, Any]] = field(default_factory=list) + # While phase == "hand_end": seats that acknowledged the summary, and + # when the auto-continue timeout fires. + acked: List[int] = field(default_factory=list) + hand_end_deadline: Optional[str] = None + # Seconds the deal summary waits before dealing anyway. + hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS + # While phase == "playing": when the server plays a random card for + # the player on turn. + turn_deadline: Optional[str] = None + turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS + + # -- serialization ---------------------------------------------------- + + def to_json(self) -> Dict[str, Any]: + return { + "player_count": self.player_count, + "target_score": self.target_score, + "phase": self.phase, + "players": [p.to_json() for p in self.players], + "stock": [c.to_json() for c in self.stock], + "briscola_card": ( + self.briscola_card.to_json() if self.briscola_card else None + ), + "briscola_suit": self.briscola_suit, + "dealer": self.dealer, + "turn": self.turn, + "hand_number": self.hand_number, + "trick": [t.to_json() for t in self.trick], + "trick_leader": self.trick_leader, + "last_trick": self.last_trick.to_json() if self.last_trick else None, + "scores": list(self.scores), + "winner": self.winner, + "hand_scores": list(self.hand_scores), + "acked": list(self.acked), + "hand_end_deadline": self.hand_end_deadline, + "hand_ack_timeout": self.hand_ack_timeout, + "turn_deadline": self.turn_deadline, + "turn_timeout": self.turn_timeout, + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "BriscolaState": + return BriscolaState( + player_count=int(data.get("player_count", 2)), + target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), + phase=str(data.get("phase", PHASE_LOBBY)), + players=[PlayerState.from_json(p) for p in data.get("players", [])], + stock=[Card.from_json(c) for c in data.get("stock", [])], + briscola_card=( + Card.from_json(data["briscola_card"]) + if data.get("briscola_card") + else None + ), + briscola_suit=str(data.get("briscola_suit", "")), + dealer=int(data.get("dealer", 0)), + turn=int(data.get("turn", 0)), + hand_number=int(data.get("hand_number", 1)), + trick=[TrickCard.from_json(t) for t in data.get("trick", [])], + trick_leader=int(data.get("trick_leader", 0)), + last_trick=( + Trick.from_json(data["last_trick"]) if data.get("last_trick") else None + ), + scores=[int(x) for x in data.get("scores", [0, 0])], + winner=data.get("winner"), + hand_scores=list(data.get("hand_scores", [])), + acked=[int(s) for s in data.get("acked", [])], + hand_end_deadline=data.get("hand_end_deadline"), + hand_ack_timeout=int( + data.get("hand_ack_timeout", DEFAULT_HAND_ACK_TIMEOUT_SECONDS) + ), + turn_deadline=data.get("turn_deadline"), + turn_timeout=int( + data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS) + ), + ) + + # -- helpers ---------------------------------------------------------- + + def player_for(self, sub: Optional[str]) -> Optional[PlayerState]: + for player in self.players: + if player.sub == sub: + return player + return None + + def seated(self, sub: str) -> bool: + return self.player_for(sub) is not None diff --git a/server/packages/tavolo-briscola/tests/test_engine.py b/server/packages/tavolo-briscola/tests/test_engine.py new file mode 100644 index 0000000..9b29b46 --- /dev/null +++ b/server/packages/tavolo-briscola/tests/test_engine.py @@ -0,0 +1,691 @@ +"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation.""" +from __future__ import annotations + +import random +import unittest + +from tavolo.briscola import engine +from tavolo.briscola.errors import ( + CardNotInHand, + GameFinished, + GameNotStarted, + IllegalMove, + NotYourTurn, +) +from tavolo.briscola.state import ( + HAND_SIZE, + PHASE_FINISHED, + PHASE_HAND_END, + PHASE_PLAYING, + BriscolaState, + Card, + PlayerState, +) + + +def card(code: str) -> Card: + return Card.parse(code) + + +def make_state( + hands, + *, + briscola_suit: str = "S", + briscola_card: str | None = None, + stock=None, + captured=None, + turn: int = 0, + dealer: int = 0, + target: int = 61, + scores=None, + phase: str = PHASE_PLAYING, +) -> BriscolaState: + """Build a controlled game state directly (bypassing the deal).""" + state = BriscolaState( + player_count=len(hands), + target_score=target, + phase=phase, + dealer=dealer, + turn=turn, + briscola_suit=briscola_suit, + briscola_card=card(briscola_card) if briscola_card else None, + stock=[card(c) for c in (stock or [])], + scores=list(scores or [0, 0]), + ) + for seat, hand in enumerate(hands): + state.players.append( + PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat, + hand=[card(c) for c in hand]) + ) + if captured is not None: + for player, codes in zip(state.players, captured): + player.captured = [card(c) for c in codes] + return state + + +def trick_cards(state: BriscolaState) -> list[str]: + return [t.card for t in state.trick] + + +def play_all(state: BriscolaState) -> None: + """Play every card of the current trick in turn order.""" + for _ in range(state.player_count): + player = next(p for p in state.players if p.seat == state.turn) + engine.play(state, player.sub, player.hand[0].code) + + +class DeckTest(unittest.TestCase): + def test_full_deck_has_40_unique_cards(self) -> None: + deck = engine.full_deck() + self.assertEqual(40, len(deck)) + self.assertEqual(40, len({c.code for c in deck})) + self.assertEqual(4, len({c.suit for c in deck})) + + def test_shuffled_deck_is_permutation(self) -> None: + deck = engine.shuffled_deck() + self.assertEqual( + sorted(c.code for c in engine.full_deck()), + sorted(c.code for c in deck), + ) + + def test_card_points_add_up_to_120(self) -> None: + self.assertEqual( + 120, sum(engine.card_points(c) for c in engine.full_deck()) + ) + self.assertEqual(11, engine.card_points(card("01D"))) + self.assertEqual(10, engine.card_points(card("03C"))) + self.assertEqual(4, engine.card_points(card("10S"))) + self.assertEqual(3, engine.card_points(card("09B"))) + self.assertEqual(2, engine.card_points(card("08D"))) + self.assertEqual(0, engine.card_points(card("07C"))) + + +class DealTest(unittest.TestCase): + def test_two_player_deal(self) -> None: + state = engine.create_game("p0", "p0") + engine.join_game(state, "p1", "p1") + self.assertEqual(PHASE_PLAYING, state.phase) + for player in state.players: + self.assertEqual(HAND_SIZE, len(player.hand)) + # 40 - 3*2 - 1 face-up = 33 face-down. + self.assertEqual(33, len(state.stock)) + self.assertIsNotNone(state.briscola_card) + assert state.briscola_card is not None + self.assertEqual(state.briscola_card.suit, state.briscola_suit) + self.assertEqual(1, state.turn) # dealer is seat 0 + self.assertEqual([], state.trick) + + def test_four_player_deal(self) -> None: + state = engine.create_game("p0", "p0", player_count=4) + for i in range(1, 4): + engine.join_game(state, f"p{i}", f"p{i}") + self.assertEqual(PHASE_PLAYING, state.phase) + for player in state.players: + self.assertEqual(HAND_SIZE, len(player.hand)) + self.assertEqual(40 - 3 * 4 - 1, len(state.stock)) + self.assertEqual(1, state.turn) + + def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None: + state = engine.create_game("p0", "p0", player_count=4) + for i in range(1, 4): + engine.join_game(state, f"p{i}", f"p{i}") + dealt = [c.code for p in state.players for c in p.hand] + assert state.briscola_card is not None + dealt.append(state.briscola_card.code) + dealt.extend(c.code for c in state.stock) + self.assertEqual(40, len(dealt)) + self.assertEqual(40, len(set(dealt))) + + def test_unsupported_player_count(self) -> None: + with self.assertRaises(IllegalMove): + engine.create_game("p0", "p0", player_count=3) + + def test_target_score_bounds(self) -> None: + with self.assertRaises(IllegalMove): + engine.create_game("p0", "p0", target_score=60) + with self.assertRaises(IllegalMove): + engine.create_game("p0", "p0", target_score=121) + with self.assertRaises(IllegalMove): + engine.create_game("p0", "p0", target_score=True) + state = engine.create_game("p0", "p0", target_score=101) + self.assertEqual(101, state.target_score) + + +class TrickTest(unittest.TestCase): + def test_higher_card_of_led_suit_wins(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S") + engine.play(state, "p0", "02D") + engine.play(state, "p1", "05D") + self.assertEqual(1, state.last_trick.winner) + self.assertEqual(["02D", "05D"], + [c.code for c in state.players[1].captured]) + + def test_trump_beats_a_higher_led_card(self) -> None: + state = make_state([["07D"], ["02C"]], briscola_suit="C") + engine.play(state, "p0", "07D") + engine.play(state, "p1", "02C") + self.assertEqual(1, state.last_trick.winner) + + def test_higher_trump_wins_among_trumps(self) -> None: + state = make_state([["02C"], ["05C"]], briscola_suit="C") + engine.play(state, "p0", "02C") + engine.play(state, "p1", "05C") + self.assertEqual(1, state.last_trick.winner) + + def test_off_suit_non_trump_loses(self) -> None: + state = make_state([["04D"], ["01C"]], briscola_suit="B") + engine.play(state, "p0", "04D") + engine.play(state, "p1", "01C") + self.assertEqual(0, state.last_trick.winner) + + def test_rank_strength_order(self) -> None: + # (winner card, loser card) pairs of the led suit, strongest first: + # ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2. + pairs = [ + ("01D", "03D"), # ace beats three + ("03D", "10D"), # three beats king + ("10D", "09D"), # king beats horse + ("09D", "08D"), # horse beats jack + ("08D", "07D"), # jack beats seven + ("07D", "06D"), + ("06D", "05D"), + ("05D", "04D"), + ("04D", "02D"), + ] + for winner, loser in pairs: + state = make_state([["02D"], ["05D"]], briscola_suit="B") + state.players[0].hand = [card(winner)] + state.players[1].hand = [card(loser)] + engine.play(state, "p0", winner) + engine.play(state, "p1", loser) + self.assertEqual( + 0, state.last_trick.winner, f"{winner} should beat {loser}" + ) + + def test_four_player_trick(self) -> None: + state = make_state( + [["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S" + ) + for seat in range(4): + engine.play(state, f"p{seat}", state.players[seat].hand[0].code) + self.assertEqual(3, state.last_trick.winner) + + def test_four_player_trump_beats_all(self) -> None: + state = make_state( + [["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C" + ) + for seat in range(4): + engine.play(state, f"p{seat}", state.players[seat].hand[0].code) + self.assertEqual(1, state.last_trick.winner) + + def test_winner_leads_the_next_trick(self) -> None: + state = make_state( + [["02D"], ["05D"], ["04D"], ["07D"]], + briscola_suit="S", + stock=["01B", "02B", "03B", "04B"], + ) + for seat in range(4): + engine.play(state, f"p{seat}", state.players[seat].hand[0].code) + self.assertEqual(3, state.turn) + self.assertEqual(3, state.trick_leader) + self.assertEqual([], state.trick) + + def test_turn_advances_within_a_trick(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S") + engine.play(state, "p0", "02D") + self.assertEqual(1, state.turn) + self.assertEqual(["02D"], trick_cards(state)) + + def test_not_your_turn(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1) + with self.assertRaises(NotYourTurn): + engine.play(state, "p0", "02D") + + def test_card_not_in_hand(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S") + with self.assertRaises(CardNotInHand): + engine.play(state, "p0", "07D") + + def test_finished_and_hand_end_reject_plays(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S") + state.phase = PHASE_FINISHED + with self.assertRaises(GameFinished): + engine.play(state, "p0", "02D") + state = make_state([["02D"], ["05D"]], briscola_suit="S") + state.phase = PHASE_HAND_END + with self.assertRaises(IllegalMove): + engine.play(state, "p0", "02D") + + +class DrawTest(unittest.TestCase): + def test_winner_draws_first(self) -> None: + # The stock is drawn from its end: 04B first, then 03B, ... + state = make_state( + [["02D", "09C"], ["05D", "09B"]], + briscola_suit="S", + stock=["01B", "02B", "03B", "04B"], + ) + engine.play(state, "p0", "02D") + engine.play(state, "p1", "05D") + hands = {p.seat: [c.code for c in p.hand] for p in state.players} + self.assertEqual(["09C", "03B"], hands[0]) + self.assertEqual(["09B", "04B"], hands[1]) + + def test_face_up_briscola_is_drawn_last(self) -> None: + state = make_state( + [["02D", "09C"], ["05D", "09B"]], + briscola_suit="C", + briscola_card="05C", + stock=["03B"], + ) + engine.play(state, "p0", "02D") + engine.play(state, "p1", "05D") + hands = {p.seat: [c.code for c in p.hand] for p in state.players} + # The winner draws first and takes the last face-down card; the + # other player then takes the face-up briscola. + self.assertEqual(["09B", "03B"], hands[1]) + self.assertEqual(["09C", "05C"], hands[0]) + self.assertIsNone(state.briscola_card) + + def test_no_draw_when_stock_is_empty(self) -> None: + state = make_state( + [["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[] + ) + engine.play(state, "p0", "02D") + engine.play(state, "p1", "05D") + # p1 wins the trick; with no stock nobody draws and the hands + # shrink for the rest of the deal. + for player in state.players: + self.assertEqual(1, len(player.hand)) + + def test_hands_stay_at_three_until_stock_runs_out(self) -> None: + state = engine.create_game("p0", "p0") + engine.join_game(state, "p1", "p1") + while state.stock or state.briscola_card is not None: + play_all(state) + for player in state.players: + self.assertEqual(HAND_SIZE, len(player.hand)) + self.assertEqual(0, len(state.stock)) + self.assertIsNone(state.briscola_card) + + +class ScoringTest(unittest.TestCase): + def test_deal_points_group_by_team(self) -> None: + state = make_state( + [[], [], [], []], + briscola_suit="S", + captured=[ + ["01D", "07C"], # seat 0, team A: 11 + ["03C", "08D"], # seat 1, team B: 10 + ["10S"], # seat 2, team A: 4 + ["09B"], # seat 3, team B: 3 + ], + ) + points, details = engine.deal_points(state) + self.assertEqual([15, 15], points) + self.assertEqual({"A": 15, "B": 15}, details["points"]) + self.assertEqual({"A": 3, "B": 3}, details["cards"]) + + def test_full_deal_scores_120(self) -> None: + state = engine.create_game("p0", "p0", player_count=4) + for i in range(1, 4): + engine.join_game(state, f"p{i}", f"p{i}") + while state.phase == PHASE_PLAYING: + player = next(p for p in state.players if p.seat == state.turn) + engine.play(state, player.sub, player.hand[0].code) + points, _ = engine.deal_points(state) + self.assertEqual(120, sum(points)) + + def test_match_ends_when_target_reached(self) -> None: + # p0 captures the ace of denari (11) on the last trick: 60+11 >= 61. + state = make_state( + [["02D"], ["05D"]], + briscola_suit="S", + stock=[], + captured=[["01C"], ["10C"]], + scores=[60, 50], + target=61, + ) + # p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a + # higher card: 02D vs 05D -> p1 would win, so give p0 the five. + state.players[0].hand = [card("05D")] + state.players[1].hand = [card("02D")] + engine.play(state, "p0", "05D") + engine.play(state, "p1", "02D") + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(0, state.winner) + self.assertEqual([71, 54], state.scores) + + def test_tie_at_target_is_broken_by_another_deal(self) -> None: + # 65-55 before the deal; this deal gives A 55 and B 65: both reach + # 120 and tie, so another deal must be played. + a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"] + b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [ + f"{rank:02d}S" for rank in range(1, 11) + ] + [f"{rank:02d}B" for rank in range(1, 11)] + state = make_state( + [[], []], + briscola_suit="S", + stock=[], + captured=[a_cards, b_cards], + scores=[65, 55], + target=120, + ) + engine._end_deal(state) + self.assertEqual(PHASE_HAND_END, state.phase) + self.assertEqual([120, 120], state.scores) + self.assertIsNone(state.winner) + + def test_patta_is_discarded_and_replayed(self) -> None: + a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [ + f"{rank:02d}C" for rank in range(1, 11) + ] + b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [ + f"{rank:02d}B" for rank in range(1, 11) + ] + state = make_state( + [["02D"], ["02S"]], + briscola_suit="S", + stock=[], + captured=[a_cards, b_cards], + scores=[10, 20], + target=61, + ) + engine._end_deal(state) + self.assertEqual(PHASE_HAND_END, state.phase) + # The 60-60 deal does not count: totals are unchanged. + self.assertEqual([10, 20], state.scores) + entry = state.hand_scores[-1] + self.assertTrue(entry["patta"]) + self.assertEqual({"A": 60, "B": 60}, entry["points"]) + # The next deal is played at the same cumulative totals. + engine.acknowledge_hand(state, "p0") + engine.acknowledge_hand(state, "p1") + self.assertEqual(PHASE_PLAYING, state.phase) + self.assertEqual(2, state.hand_number) + self.assertEqual([10, 20], state.scores) + + def test_audit_trail_records_each_deal(self) -> None: + state = make_state( + [["02D"], ["05D"]], + briscola_suit="S", + stock=[], + captured=[["01C"], []], + scores=[0, 0], + ) + engine._end_deal(state) + entry = state.hand_scores[-1] + self.assertEqual(1, entry["deal"]) + self.assertFalse(entry["patta"]) + self.assertEqual({"A": 11, "B": 0}, entry["points"]) + self.assertEqual({"A": 11, "B": 0}, entry["totals"]) + self.assertEqual({"A": 1, "B": 0}, entry["cards"]) + + +class MatchFlowTest(unittest.TestCase): + def test_join_starts_when_full(self) -> None: + state = engine.create_game("p0", "p0") + self.assertEqual(1, len(state.players)) + engine.join_game(state, "p1", "p1") + self.assertEqual(PHASE_PLAYING, state.phase) + + state = engine.create_game("p0", "p0", player_count=4) + for i in range(1, 3): + engine.join_game(state, f"p{i}", f"p{i}") + self.assertEqual("lobby", state.phase) + engine.join_game(state, "p3", "p3") + self.assertEqual(PHASE_PLAYING, state.phase) + + def test_join_errors(self) -> None: + from tavolo.platform.errors import AlreadyJoined, GameNotStarted + + state = engine.create_game("p0", "p0") + with self.assertRaises(AlreadyJoined): + engine.join_game(state, "p0", "p0") + engine.join_game(state, "p1", "p1") + with self.assertRaises(GameNotStarted): + engine.join_game(state, "p2", "p2") + + def test_full_random_two_player_match(self) -> None: + self._random_match(player_count=2) + + def test_full_random_four_player_match(self) -> None: + self._random_match(player_count=4) + + def _random_match(self, player_count: int) -> None: + state = engine.create_game("p0", "p0", player_count=player_count) + for i in range(1, player_count): + engine.join_game(state, f"p{i}", f"p{i}") + rng = random.Random(2026) + moves = 0 + while state.phase != PHASE_FINISHED and moves < 200000: + if state.phase == PHASE_HAND_END: + for player in state.players: + engine.acknowledge_hand(state, player.sub) + continue + player = next(p for p in state.players if p.seat == state.turn) + engine.play(state, player.sub, rng.choice(player.hand).code) + moves += 1 + + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertIn(state.winner, (0, 1)) + self.assertEqual([], state.trick) + self.assertTrue(all(not p.hand for p in state.players)) + self.assertEqual( + 40, sum(len(p.captured) for p in state.players) + ) + # Every counted deal totals 120 points, and the winner reached the + # target with a clear lead. + winner_score = state.scores[state.winner] + loser_score = state.scores[1 - state.winner] + self.assertGreaterEqual(winner_score, state.target_score) + self.assertGreater(winner_score, loser_score) + self.assertEqual(0, (winner_score + loser_score) % 120) + + +class AckTest(unittest.TestCase): + def _hand_end_state(self) -> BriscolaState: + state = make_state( + [["02D"], ["05D"]], + briscola_suit="S", + stock=[], + captured=[["01C"], []], + target=120, + ) + engine._end_deal(state) + return state + + def test_ack_all_deals_the_next_deal(self) -> None: + state = self._hand_end_state() + dealer_before = state.dealer + engine.acknowledge_hand(state, "p0") + self.assertEqual([0], state.acked) + self.assertEqual(PHASE_HAND_END, state.phase) + engine.acknowledge_hand(state, "p1") + self.assertEqual(PHASE_PLAYING, state.phase) + self.assertEqual(2, state.hand_number) + self.assertEqual((dealer_before + 1) % 2, state.dealer) + self.assertEqual((dealer_before + 2) % 2, state.turn) + self.assertEqual([], state.acked) + self.assertIsNone(state.hand_end_deadline) + for player in state.players: + self.assertEqual(HAND_SIZE, len(player.hand)) + self.assertEqual([], player.captured) + + def test_double_ack_is_idempotent(self) -> None: + state = self._hand_end_state() + engine.acknowledge_hand(state, "p0") + engine.acknowledge_hand(state, "p0") + self.assertEqual([0], state.acked) + + def test_ack_outside_hand_end_is_rejected(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S") + with self.assertRaises(IllegalMove): + engine.acknowledge_hand(state, "p0") + + def test_ack_by_non_player_is_rejected(self) -> None: + state = self._hand_end_state() + with self.assertRaises(NotYourTurn): + engine.acknowledge_hand(state, "mallory") + + +class AutoPlayTest(unittest.TestCase): + def test_auto_play_plays_a_card_and_advances_turn(self) -> None: + state = make_state( + [["02D", "03C"], ["05D"]], + briscola_suit="S", + stock=["01B", "02B"], + briscola_card="03B", + ) + engine.auto_play(state, random.Random(7)) + self.assertEqual(1, state.turn) + self.assertEqual(1, len(state.players[0].hand)) + self.assertEqual(1, len(state.trick)) + + def test_auto_play_can_end_the_deal(self) -> None: + state = make_state( + [["02D"], ["05D"]], + briscola_suit="S", + stock=[], + captured=[["01C"], []], + target=120, + ) + # Both players are on their last card: two auto-plays complete the + # trick and the deal, scored A=11 / B=0. + engine.auto_play(state) + engine.auto_play(state) + self.assertEqual(PHASE_HAND_END, state.phase) + self.assertIsNone(state.turn_deadline) + self.assertTrue(state.hand_end_deadline) + + def test_auto_play_requires_playing_phase(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S") + state.phase = PHASE_HAND_END + with self.assertRaises(GameNotStarted): + engine.auto_play(state) + + +class ViewTest(unittest.TestCase): + def test_state_for_player_hides_other_hands(self) -> None: + state = make_state( + [["02D", "03C"], ["04D"], ["05D"], ["06D"]], + briscola_suit="D", + briscola_card="07D", + stock=["01B", "02B"], + ) + view = engine.state_for_player(state, "p0") + players = {p["seat"]: p for p in view["players"]} + self.assertEqual(["02D", "03C"], players[0]["hand"]) + self.assertNotIn("hand", players[1]) + self.assertEqual(1, players[1]["cards_left"]) + self.assertEqual("D", view["briscola_suit"]) + self.assertEqual("07D", view["briscola_card"]) + self.assertEqual(2, view["stock_count"]) + self.assertTrue(view.get("your_turn")) + + def test_spectator_sees_public_info_only(self) -> None: + state = make_state( + [["02D"], ["04D"]], + briscola_suit="D", + briscola_card="07D", + ) + view = engine.state_for_player(state, None) + for player in view["players"]: + self.assertNotIn("hand", player) + self.assertNotIn("your_turn", view) + self.assertEqual("07D", view["briscola_card"]) + self.assertEqual({"A": 0, "B": 0}, view["scores"]) + + def test_player_points_are_public(self) -> None: + state = make_state( + [["02D"], ["04D"]], + briscola_suit="D", + captured=[["01C", "03B"], ["10D"]], + ) + view = engine.state_for_player(state, "p0") + players = {p["seat"]: p for p in view["players"]} + self.assertEqual(21, players[0]["points"]) + self.assertEqual(4, players[1]["points"]) + self.assertEqual(2, players[0]["captured_count"]) + + def test_hand_end_view_exposes_the_deal_summary(self) -> None: + state = make_state( + [["02D"], ["05D"]], + briscola_suit="S", + stock=[], + captured=[["01C"], []], + target=120, + ) + engine._end_deal(state) + view = engine.state_for_player(state, "p0") + self.assertEqual(PHASE_HAND_END, view["phase"]) + self.assertEqual( + { + "deal": 1, + "patta": False, + "points": {"A": 11, "B": 0}, + "cards": {"A": 1, "B": 0}, + "totals": {"A": 11, "B": 0}, + }, + view["last_deal"], + ) + self.assertTrue(view["hand_end_deadline"]) + + def test_trick_is_public_in_play_order(self) -> None: + state = make_state( + [["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S" + ) + engine.play(state, "p0", "02D") + engine.play(state, "p1", "05D") + view = engine.state_for_player(state, None) + self.assertEqual( + [{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}], + view["trick"], + ) + + +class SerializationTest(unittest.TestCase): + def test_state_roundtrip(self) -> None: + state = engine.create_game("p0", "p0", player_count=4, target_score=101) + for i in range(1, 4): + engine.join_game(state, f"p{i}", f"p{i}") + engine.play(state, state.players[1].sub, state.players[1].hand[0].code) + restored = BriscolaState.from_json(state.to_json()) + self.assertEqual(state.phase, restored.phase) + self.assertEqual(state.turn, restored.turn) + self.assertEqual(state.hand_number, restored.hand_number) + self.assertEqual(state.target_score, restored.target_score) + self.assertEqual(state.briscola_suit, restored.briscola_suit) + self.assertEqual( + state.briscola_card.code if state.briscola_card else None, + restored.briscola_card.code if restored.briscola_card else None, + ) + self.assertEqual( + [c.code for c in state.stock], [c.code for c in restored.stock] + ) + self.assertEqual( + [(t.seat, t.card) for t in state.trick], + [(t.seat, t.card) for t in restored.trick], + ) + self.assertEqual( + [p.sub for p in state.players], [p.sub for p in restored.players] + ) + self.assertEqual( + [[c.code for c in p.hand] for p in state.players], + [[c.code for c in p.hand] for p in restored.players], + ) + + def test_legacy_state_defaults(self) -> None: + state = make_state([["02D"], ["05D"]], briscola_suit="S") + data = state.to_json() + del data["player_count"] + del data["briscola_card"] + del data["last_trick"] + restored = BriscolaState.from_json(data) + self.assertEqual(2, restored.player_count) + self.assertIsNone(restored.briscola_card) + self.assertIsNone(restored.last_trick) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/packages/tavolo-briscola/tests/test_plugin.py b/server/packages/tavolo-briscola/tests/test_plugin.py new file mode 100644 index 0000000..2263b57 --- /dev/null +++ b/server/packages/tavolo-briscola/tests/test_plugin.py @@ -0,0 +1,349 @@ +"""Adapter tests: the platform contract over the pure briscola rules.""" +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from tavolo.platform import GameSession, Seat +from tavolo.platform.errors import ( + AlreadyJoined, + GameError, + GameFinished, + GameNotStarted, + IllegalMove, +) +from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine +from tavolo.briscola import engine as rules +from tavolo.briscola.state import ( + PHASE_FINISHED, + PHASE_HAND_END, + PHASE_PLAYING, + BriscolaState, +) + + +def _session(engine, **options) -> GameSession: + session = GameSession( + id="b1", + game_type=engine.id, + join_code="CODE01", + creator_sub="alice", + players=[Seat(user_sub="alice", display_name="Alice")], + ) + engine.create(session, options) + return session + + +def _started(engine, **options) -> GameSession: + session = _session(engine, **options) + for i in range(1, engine.max_players): + name = f"p{i}" + engine.join(session, name, name.capitalize()) + return session + + +def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None: + moves = 0 + while not engine.is_finished(session) and moves < limit: + state = session.state + if state.phase == PHASE_HAND_END: + for player in state.players: + engine.handle_action(session, player.sub, "ack", {}) + continue + player = next(p for p in state.players if p.seat == state.turn) + engine.handle_action( + session, player.sub, "play", {"card": player.hand[0].code} + ) + moves += 1 + + +class MetadataTest(unittest.TestCase): + def test_two_player_metadata(self) -> None: + engine = BriscolaEngine() + self.assertEqual("briscola", engine.id) + self.assertEqual("Briscola", engine.name) + self.assertTrue(engine.description) + self.assertEqual(2, engine.min_players) + self.assertEqual(2, engine.max_players) + target = engine.options_schema["properties"]["target_score"] + self.assertEqual("integer", target["type"]) + self.assertEqual(61, target["minimum"]) + self.assertEqual(120, target["maximum"]) + self.assertEqual(61, target["default"]) + + def test_four_player_metadata(self) -> None: + engine = BriscolaQuattroEngine() + self.assertEqual("briscola_a_quattro", engine.id) + self.assertEqual("Briscola a quattro", engine.name) + self.assertTrue(engine.description) + self.assertEqual(4, engine.min_players) + self.assertEqual(4, engine.max_players) + self.assertIn("target_score", engine.options_schema["properties"]) + + +class CreateTest(unittest.TestCase): + def test_create_seats_creator_on_team_a(self) -> None: + engine = BriscolaEngine() + session = _session(engine) + self.assertEqual("A", session.players[0].team) + self.assertIsInstance(session.state, BriscolaState) + self.assertEqual(61, session.state.target_score) + self.assertEqual(2, session.state.player_count) + + def test_create_options(self) -> None: + engine = BriscolaQuattroEngine() + session = _session(engine, target_score=101) + self.assertEqual(101, session.state.target_score) + self.assertEqual(4, session.state.player_count) + + def test_create_rejects_bad_options(self) -> None: + engine = BriscolaEngine() + with self.assertRaises(IllegalMove): + _session(engine, target_score=60) + with self.assertRaises(IllegalMove): + _session(engine, target_score=121) + with self.assertRaises(IllegalMove): + _session(engine, target_score="sixtyone") + with self.assertRaises(IllegalMove): + _session(engine, target_score=True) + + def test_timeouts_come_from_the_engine(self) -> None: + engine = BriscolaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9) + session = _session(engine) + self.assertEqual(7, session.state.turn_timeout) + self.assertEqual(9, session.state.hand_ack_timeout) + + +class JoinTest(unittest.TestCase): + def test_two_player_join_starts(self) -> None: + engine = BriscolaEngine() + session = _session(engine) + self.assertTrue(engine.in_lobby(session)) + engine.join(session, "bob", "Bob") + self.assertEqual("B", session.players[-1].team) + self.assertFalse(engine.in_lobby(session)) + self.assertEqual(PHASE_PLAYING, session.state.phase) + + def test_four_player_teams_alternate(self) -> None: + engine = BriscolaQuattroEngine() + session = _session(engine) + teams = [] + for name in ("bob", "carol", "dave"): + engine.join(session, name, name.capitalize()) + teams.append(session.players[-1].team) + self.assertEqual(["B", "A", "B"], teams) + self.assertEqual(PHASE_PLAYING, session.state.phase) + + def test_join_errors(self) -> None: + engine = BriscolaEngine() + session = _session(engine) + with self.assertRaises(AlreadyJoined): + engine.join(session, "alice", "Alice") + engine.join(session, "bob", "Bob") + with self.assertRaises(GameNotStarted): + engine.join(session, "carol", "Carol") + + +class ActionTest(unittest.TestCase): + def test_unknown_action_rejected(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + with self.assertRaises(IllegalMove): + engine.handle_action(session, "alice", "dance", {}) + + def test_play_validates_payload(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + with self.assertRaises(IllegalMove): + engine.handle_action(session, "p1", "play", {"card": 42}) + with self.assertRaises(IllegalMove): + engine.handle_action(session, "p1", "play", {}) + with self.assertRaises(IllegalMove): + engine.handle_action(session, "p1", "play", {"card": "nope"}) + + def test_play_advances_the_trick(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + state = session.state + self.assertEqual(1, state.turn) + card = state.players[1].hand[0].code + engine.handle_action(session, "p1", "play", {"card": card}) + self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"]) + + def test_play_out_of_turn_rejected(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + state = session.state + card = state.players[0].hand[0].code + with self.assertRaises(GameError): + engine.handle_action(session, "alice", "play", {"card": card}) + + def test_ack_outside_hand_end_rejected(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + with self.assertRaises(IllegalMove): + engine.handle_action(session, "alice", "ack", {}) + + def test_finished_match_rejects_actions(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + _drive_to_finish(engine, session) + self.assertTrue(engine.is_finished(session)) + with self.assertRaises(GameFinished): + engine.handle_action(session, "alice", "play", {"card": "01D"}) + + def test_ack_all_deals_next(self) -> None: + engine = BriscolaEngine() + session = _started(engine, target_score=120) + state = session.state + # Force the deal-end phase: both players keep 3 cards, so the + # match continues after the summary. + state.phase = PHASE_HAND_END + state.acked = [] + state.hand_end_deadline = ( + datetime.now(timezone.utc) + timedelta(seconds=60) + ).isoformat() + engine.handle_action(session, "alice", "ack", {}) + self.assertEqual(PHASE_HAND_END, state.phase) + engine.handle_action(session, "p1", "ack", {}) + self.assertEqual(PHASE_PLAYING, state.phase) + self.assertEqual(2, state.hand_number) + + +class ViewTest(unittest.TestCase): + def test_view_for_hides_other_hands(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + view = engine.view_for(session, "alice") + players = {p["seat"]: p for p in view["players"]} + self.assertIn("hand", players[0]) + self.assertNotIn("hand", players[1]) + # The envelope is the platform's job, not the view's. + self.assertNotIn("id", view) + self.assertNotIn("join_code", view) + self.assertNotIn("game_type", view) + self.assertIn("briscola_suit", view) + self.assertIn("stock_count", view) + + def test_view_for_spectator_hides_all_hands(self) -> None: + engine = BriscolaQuattroEngine() + session = _started(engine) + view = engine.view_for(session, None) + for player in view["players"]: + self.assertNotIn("hand", player) + self.assertNotIn("your_turn", view) + self.assertEqual({"A": 0, "B": 0}, view["scores"]) + + def test_lobby_view(self) -> None: + engine = BriscolaEngine() + session = _session(engine, target_score=101) + lobby = engine.lobby_view(session) + self.assertEqual("lobby", lobby["phase"]) + self.assertEqual(101, lobby["target_score"]) + + +class SerializationTest(unittest.TestCase): + def test_state_roundtrip(self) -> None: + engine = BriscolaQuattroEngine() + session = _started(engine) + restored = engine.state_from_json(engine.state_to_json(session.state)) + self.assertIsInstance(restored, BriscolaState) + self.assertEqual(session.state.phase, restored.phase) + self.assertEqual(session.state.turn, restored.turn) + self.assertEqual( + [p.sub for p in session.state.players], + [p.sub for p in restored.players], + ) + + +class DeadlineTest(unittest.TestCase): + def test_no_deadline_in_lobby(self) -> None: + engine = BriscolaEngine() + self.assertIsNone(engine.next_deadline(_session(engine))) + + def test_turn_deadline_and_revalidation(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + deadline = engine.next_deadline(session) + assert deadline is not None + self.assertEqual("turn", deadline.kind) + with self.assertRaises(GameError): + engine.fire_deadline(session, "turn", "turn:1:1:0") + turn_before = session.state.turn + engine.fire_deadline(session, deadline.kind, deadline.token) + self.assertNotEqual(turn_before, session.state.turn) + + def test_stale_deadline_after_play(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + deadline = engine.next_deadline(session) + assert deadline is not None + state = session.state + player = next(p for p in state.players if p.seat == state.turn) + engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code}) + with self.assertRaises(GameError): + engine.fire_deadline(session, deadline.kind, deadline.token) + + def test_hand_end_deadline_advances(self) -> None: + engine = BriscolaEngine() + session = _started(engine, target_score=120) + state = session.state + state.phase = PHASE_HAND_END + state.hand_end_deadline = ( + datetime.now(timezone.utc) + timedelta(seconds=60) + ).isoformat() + deadline = engine.next_deadline(session) + assert deadline is not None + self.assertEqual("hand_end", deadline.kind) + engine.fire_deadline(session, deadline.kind, deadline.token) + self.assertEqual(PHASE_PLAYING, session.state.phase) + self.assertEqual(2, session.state.hand_number) + + +class ResultTest(unittest.TestCase): + def test_result_of_finished_match(self) -> None: + engine = BriscolaQuattroEngine() + session = _started(engine) + _drive_to_finish(engine, session) + result = engine.result(session) + self.assertEqual(2, len(result.teams)) + self.assertEqual([2, 2], [len(team) for team in result.teams]) + self.assertIn(result.winner_team, (0, 1)) + self.assertEqual(4, len(result.players)) + winner_name = "A" if result.winner_team == 0 else "B" + for player in result.players: + self.assertEqual(player.won, player.team == winner_name) + self.assertIn("captured_cards", player.details) + self.assertIn("team_a_score", result.summary) + self.assertIn("team_b_score", result.summary) + self.assertIn("winner_team", result.summary) + self.assertIn("target_score", result.summary) + self.assertIn("deals_played", result.summary) + self.assertIn("deal_scores", result.summary) + + def test_two_player_result(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + _drive_to_finish(engine, session) + result = engine.result(session) + self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams) + self.assertEqual(2, len(result.players)) + + def test_result_requires_finished_match(self) -> None: + engine = BriscolaEngine() + with self.assertRaises(GameError): + engine.result(_started(engine)) + + def test_game_over_view(self) -> None: + engine = BriscolaEngine() + session = _started(engine) + session.state.phase = PHASE_FINISHED + session.state.winner = 1 + session.state.scores = [55, 65] + over = engine.game_over_view(session, "alice") + self.assertEqual({"A": 55, "B": 65}, over["scores"]) + self.assertEqual("B", over["winner"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/packages/tavolo-platform/README.md b/server/packages/tavolo-platform/README.md index e85c2b9..615a3a3 100644 --- a/server/packages/tavolo-platform/README.md +++ b/server/packages/tavolo-platform/README.md @@ -48,8 +48,8 @@ itself ships no game. Implement `GameEngine` (see `engine.py` for the full contract), register it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a `KayaApp` — see the composition root in `tavolo.app`. The canonical -example is -[`tavolo-scopone`](../tavolo-scopone/README.md). +examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and +[`tavolo-briscola`](../tavolo-briscola/README.md). ## Development (from `server/`) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py index 8b2f7e5..0c83f57 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py @@ -71,7 +71,7 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any] } for index, seat in enumerate(session.players) ], - "seats_open": engine.max_players - len(session.players), + "seats_open": max(0, engine.max_players - len(session.players)), **engine.lobby_view(session), } @@ -85,6 +85,9 @@ def _view_payload( "id": session.id, "game_type": session.game_type, "spectator": not seated, + # Free chairs; the lobby view needs it for games with other player + # counts than four. + "seats_open": max(0, engine.max_players - len(session.players)), } if seated: # The join code is a credential: only players get it. diff --git a/server/packages/tavolo-platform/src/tavolo/platform/ws.py b/server/packages/tavolo-platform/src/tavolo/platform/ws.py index bdab680..ed9bbf0 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/ws.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/ws.py @@ -36,9 +36,10 @@ Client -> server messages are JSON objects:: For scopone scientifico the game actions are ``play`` (with ``card`` and optionally ``capture``) and ``ack`` — see -:mod:`tavolo.scopone.plugin`. Because the whole message (minus -``action``) is handed to the engine as the payload, games define their -own fields freely. +:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card`` +plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole +message (minus ``action``) is handed to the engine as the payload, games +define their own fields freely. Mutations run under the per-game lock; after a successful action the new session is saved and a change signal is published. Every connected @@ -101,6 +102,9 @@ def _state_message( "id": session.id, "game_type": session.game_type, "spectator": not seated, + # Free chairs; the lobby view needs it for games with other player + # counts than four. + "seats_open": max(0, engine.max_players - len(session.players)), } if seated: # The join code is a credential: only players get it. diff --git a/server/packages/tavolo-platform/tests/test_ws.py b/server/packages/tavolo-platform/tests/test_ws.py index 7327384..da92256 100644 --- a/server/packages/tavolo-platform/tests/test_ws.py +++ b/server/packages/tavolo-platform/tests/test_ws.py @@ -132,6 +132,33 @@ class WebSocketTest(unittest.TestCase): self.assertEqual("state", update["type"]) self.assertEqual(1, update["game"]["plays"]) + @async_test + async def test_state_reports_open_seats(self) -> None: + app, platform, _ = make_platform() + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + with oidc_user(platform.oidc, "alice"): + created = await client.post("/api/games", json={}) + game_id = created.json()["id"] + code = created.json()["join_code"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("alice")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual(1, first["game"]["seats_open"]) + + # Once the second player joins the match starts and no seat is + # left open. + with oidc_user(platform.oidc, "bob"): + await client.post("/api/games/join", json={"code": code}) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("alice")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual(0, first["game"]["seats_open"]) + @async_test async def test_unknown_action_returns_error(self) -> None: app, platform, _ = make_platform() diff --git a/server/requirements.txt b/server/requirements.txt index 24d07e8..fe03cde 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -4,10 +4,11 @@ # # pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml # -# NOTE: the tavolo-platform, tavolo-scopone and tavolo-app sibling packages -# are NOT pinned here: they are installed from ./packages in the Dockerfile -# and in development (pip install -e ./packages/tavolo-platform -# -e ./packages/tavolo-scopone -e ./packages/tavolo-app). All of their +# NOTE: the tavolo-platform, tavolo-scopone, tavolo-briscola and tavolo-app +# sibling packages are NOT pinned here: they are installed from ./packages in +# the Dockerfile and in development (pip install -e ./packages/tavolo-platform +# -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola +# -e ./packages/tavolo-app). All of their # third-party dependencies are direct dependencies of tavolo-app, so this # lock covers the full closure. The otel extra (kaya-otel and its # OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the diff --git a/server/tests/test_briscola.py b/server/tests/test_briscola.py new file mode 100644 index 0000000..86b921e --- /dev/null +++ b/server/tests/test_briscola.py @@ -0,0 +1,368 @@ +"""Briscola integration tests: lobby, websocket play, timeouts and summaries. + +The per-package suites cover the rules and the engine contract; these +tests wire both briscola variants through the full platform stack (REST, +WebSocket, deadline scheduler). +""" +from __future__ import annotations + +import asyncio +import contextlib +import unittest + +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.state import ( + PHASE_HAND_END, + PHASE_PLAYING, + BriscolaState, + Card, + PlayerState, +) +from tavolo.platform import GameSession, Seat +from tests.helpers import async_test, make_user, oidc_user, ws_users + + +class BriscolaRoutesTest(unittest.TestCase): + @async_test + async def test_create_and_start_two_player_game(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", "options": {"target_score": 101}}, + ) + self.assertEqual(201, created.status_code) + body = created.json() + self.assertEqual("briscola", body["game_type"]) + self.assertEqual("lobby", body["phase"]) + self.assertEqual(1, body["seats_open"]) + self.assertEqual(101, body["target_score"]) + self.assertEqual("A", body["players"][0]["team"]) + code = body["join_code"] + + with oidc_user("bob"): + started = await client.post("/api/games/join", json={"code": code}) + self.assertEqual(200, started.status_code) + state = started.json() + self.assertEqual("playing", state["phase"]) + self.assertEqual(2, len(state["players"])) + self.assertEqual(["A", "B"], [p["team"] for p in state["players"]]) + for participant in state["players"]: + self.assertEqual(3, participant["cards_left"]) + self.assertEqual(1, state["turn"]) + self.assertEqual(33, state["stock_count"]) + self.assertTrue(state["briscola_suit"]) + self.assertIsNotNone(state["briscola_card"]) + # Bob's view carries his own hand only. + self.assertIn("hand", state["players"][1]) + self.assertNotIn("hand", state["players"][0]) + + # A non-player watches without a hand or the join code. + 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 participant in spectator["players"]: + self.assertNotIn("hand", participant) + + @async_test + async def test_create_and_start_four_player_game(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_a_quattro"} + ) + self.assertEqual(201, created.status_code) + body = created.json() + self.assertEqual("briscola_a_quattro", body["game_type"]) + self.assertEqual(3, body["seats_open"]) + code = body["join_code"] + + for player in ("bob", "carol"): + with oidc_user(player): + joined = await client.post("/api/games/join", json={"code": code}) + self.assertEqual("lobby", joined.json()["phase"]) + + with oidc_user("dave"): + started = await client.post("/api/games/join", json={"code": code}) + state = started.json() + self.assertEqual("playing", state["phase"]) + self.assertEqual(["A", "B", "A", "B"], [p["team"] for p in state["players"]]) + for participant in state["players"]: + self.assertEqual(3, participant["cards_left"]) + self.assertEqual(40 - 12 - 1, state["stock_count"]) + + @async_test + async def test_preview_shows_briscola_options(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", "options": {"target_score": 101}}, + ) + 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", body["game_type"]) + self.assertEqual("Briscola", body["game_name"]) + self.assertTrue(body["game_description"]) + self.assertEqual(101, body["target_score"]) + self.assertEqual(1, body["seats_open"]) + + @async_test + async def test_create_rejects_bad_target_score(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + for bad in (60, 121, "sixtyone", True): + with oidc_user("alice"): + response = await client.post( + "/api/games", + json={"game_type": "briscola", "options": {"target_score": bad}}, + ) + self.assertEqual(400, response.status_code, bad) + + +class BriscolaWebSocketTest(unittest.TestCase): + async def _started_two_player(self, client: AsyncClient) -> dict: + with oidc_user("alice"): + created = await client.post("/api/games", json={"game_type": "briscola"}) + code = created.json()["join_code"] + with oidc_user("bob"): + started = await client.post("/api/games/join", json={"code": code}) + return started.json() + + @async_test + async def test_moves_resolve_the_trick(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_two_player(client) + game_id = state["id"] + bob_card = state["players"][1]["hand"][0] + with oidc_user("alice"): + alice_view = await client.get(f"/api/games/{game_id}") + alice_card = alice_view.json()["players"][0]["hand"][0] + + 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(1, first["game"]["turn"]) + self.assertTrue(first["game"].get("your_turn")) + + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: + await alice_ws.receive_json() + await bob_ws.send_json( + {"action": "play", "card": bob_card} + ) + bob_update = await bob_ws.receive_json() + alice_update = await alice_ws.receive_json() + for update in (bob_update, alice_update): + self.assertEqual("state", update["type"]) + self.assertEqual(0, update["game"]["turn"]) + self.assertEqual( + [{"seat": 1, "card": bob_card}], + update["game"]["trick"], + ) + + await alice_ws.send_json( + {"action": "play", "card": alice_card} + ) + bob_update = await bob_ws.receive_json() + alice_update = await alice_ws.receive_json() + for update in (bob_update, alice_update): + self.assertEqual([], update["game"]["trick"]) + last = update["game"]["last_trick"] + self.assertIsNotNone(last) + self.assertIn(last["winner"], (0, 1)) + self.assertEqual(2, len(last["cards"])) + self.assertEqual(last["winner"], update["game"]["turn"]) + + @async_test + async def test_illegal_move_returns_error(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_two_player(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("alice")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: + await alice_ws.receive_json() + # Bob (seat 1) is on turn, not Alice. + await alice_ws.send_json({"action": "play", "card": "01D"}) + error = await alice_ws.receive_json() + self.assertEqual("error", error["type"]) + + @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 self._started_two_player(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 player in first["game"]["players"]: + self.assertNotIn("hand", player) + await ws.send_json({"action": "play", "card": "01D"}) + error = await ws.receive_json() + self.assertEqual("forbidden", error["code"]) + + +class BriscolaTurnTimeoutTest(unittest.TestCase): + @async_test + async def test_turn_timeout_auto_plays_a_card(self) -> None: + engine = platform.registry.require("briscola") + session = GameSession( + id="briscola-turn-timeout-1", + game_type=engine.id, + join_code="BT0001", + creator_sub="alice", + players=[ + Seat(user_sub="alice", display_name="Alice", team="A"), + ], + ) + engine.create(session, {}) + session.state.turn_timeout = 1 + engine.join(session, "bob", "Bob") + await game_store.save(session) + + 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/{session.id}", ws_client) as ws: + first = await ws.receive_json() + # Bob (seat 1) is first to act and never connects. + self.assertEqual(1, first["game"]["turn"]) + self.assertIsNotNone(first["game"]["turn_deadline"]) + + update = None + for _ in range(20): + try: + update = await asyncio.wait_for(ws.receive_json(), timeout=2) + except asyncio.TimeoutError: + break + if ( + update.get("type") == "state" + and update["game"]["last_trick"] is not None + ): + break + self.assertIsNotNone(update) + assert update is not None + # Both absent players were auto-played; the trick + # resolved and the winner leads with a full hand. + self.assertIn(update["game"]["last_trick"]["winner"], (0, 1)) + self.assertEqual([], update["game"]["trick"]) + for participant in update["game"]["players"]: + self.assertEqual(3, participant["cards_left"]) + + +async def _seed_hand_end_state() -> str: + """Seed a game where a single trick ends the deal with target 120 so + the match continues to the summary.""" + engine = platform.registry.require("briscola") + session = GameSession( + id="briscola-hand-end-1", + game_type=engine.id, + join_code="BH0001", + creator_sub="alice", + players=[ + Seat(user_sub="alice", display_name="Alice", team="A"), + Seat(user_sub="bob", display_name="Bob", team="B"), + ], + ) + session.state = BriscolaState( + player_count=2, + target_score=120, + phase=PHASE_PLAYING, + dealer=0, + turn=0, + briscola_suit="S", + players=[ + PlayerState( + sub="alice", name="Alice", seat=0, + hand=[Card.parse("02D")], captured=[Card.parse("01C")], + ), + PlayerState( + sub="bob", name="Bob", seat=1, + hand=[Card.parse("05D")], captured=[Card.parse("10C")], + ), + ], + hand_ack_timeout=30, + ) + await game_store.save(session) + return session.id + + +class BriscolaHandEndTest(unittest.TestCase): + @async_test + async def test_deal_end_ack_flow(self) -> None: + game_id = await _seed_hand_end_state() + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + async with contextlib.AsyncExitStack() as stack: + with ws_users([make_user("alice"), make_user("bob")]): + sockets = [ + await stack.enter_async_context( + aconnect_ws(f"/ws/games/{game_id}", ws_client) + ) + for _ in range(2) + ] + for ws in sockets: + await ws.receive_json() # initial state + + # Alice leads, Bob takes the trick: the deal ends. + await sockets[0].send_json( + {"action": "play", "card": "02D"} + ) + for ws in sockets: + await ws.receive_json() + await sockets[1].send_json( + {"action": "play", "card": "05D"} + ) + summaries = [await ws.receive_json() for ws in sockets] + for summary in summaries: + self.assertEqual("state", summary["type"]) + self.assertEqual(PHASE_HAND_END, summary["game"]["phase"]) + self.assertEqual([], summary["game"]["acknowledged"]) + self.assertIsNotNone(summary["game"]["hand_end_deadline"]) + deal = summary["game"]["last_deal"] + self.assertEqual({"A": 11, "B": 4}, deal["points"]) + self.assertEqual({"A": 11, "B": 4}, deal["totals"]) + self.assertFalse(deal["patta"]) + + # Both acknowledge; the second ack deals the next deal. + await sockets[0].send_json({"action": "ack"}) + updates = [await ws.receive_json() for ws in sockets] + for update in updates: + self.assertEqual(PHASE_HAND_END, update["game"]["phase"]) + self.assertEqual([0], update["game"]["acknowledged"]) + await sockets[1].send_json({"action": "ack"}) + updates = [await ws.receive_json() for ws in sockets] + for update in updates: + self.assertEqual(PHASE_PLAYING, update["game"]["phase"]) + self.assertEqual(2, update["game"]["hand_number"]) + for player in update["game"]["players"]: + self.assertEqual(3, player["cards_left"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index 704d02e..ed1ff48 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -1,4 +1,4 @@ -"""Game lobby route tests via kaya's ASGI transport (scopone game).""" +"""Game lobby route tests via kaya's ASGI transport (all registered games).""" from __future__ import annotations import unittest @@ -253,16 +253,32 @@ class GameTypesRouteTest(unittest.TestCase): response = await client.get("/api/game-types") self.assertEqual(200, response.status_code) results = response.json()["results"] - self.assertEqual(["scopone_scientifico"], [g["id"] for g in results]) - self.assertEqual("Scopone scientifico", results[0]["name"]) - self.assertTrue(results[0]["description"]) - self.assertEqual(4, results[0]["min_players"]) - self.assertEqual(4, results[0]["max_players"]) - self.assertIn("target_score", results[0]["options_schema"]["properties"]) - self.assertIn("napola", results[0]["options_schema"]["properties"]) - self.assertIn( - "asso_pigliatutto", results[0]["options_schema"]["properties"] + self.assertEqual( + ["scopone_scientifico", "briscola", "briscola_a_quattro"], + [g["id"] for g in results], ) + scopone = results[0] + self.assertEqual("Scopone scientifico", scopone["name"]) + self.assertTrue(scopone["description"]) + self.assertEqual(4, scopone["min_players"]) + self.assertEqual(4, scopone["max_players"]) + self.assertIn("target_score", scopone["options_schema"]["properties"]) + self.assertIn("napola", scopone["options_schema"]["properties"]) + self.assertIn( + "asso_pigliatutto", scopone["options_schema"]["properties"] + ) + briscola = results[1] + self.assertEqual("Briscola", briscola["name"]) + self.assertTrue(briscola["description"]) + self.assertEqual(2, briscola["min_players"]) + self.assertEqual(2, briscola["max_players"]) + self.assertIn("target_score", briscola["options_schema"]["properties"]) + quattro = results[2] + self.assertEqual("Briscola a quattro", quattro["name"]) + self.assertTrue(quattro["description"]) + self.assertEqual(4, quattro["min_players"]) + self.assertEqual(4, quattro["max_players"]) + self.assertIn("target_score", quattro["options_schema"]["properties"]) @async_test async def test_create_defaults_game_type(self) -> None: @@ -294,7 +310,7 @@ class GameTypesRouteTest(unittest.TestCase): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: with oidc_user("alice"): - unknown = await client.post("/api/games", json={"game_type": "briscola"}) + unknown = await client.post("/api/games", json={"game_type": "tresette"}) non_string = await client.post("/api/games", json={"game_type": 42}) self.assertEqual(400, unknown.status_code) self.assertEqual(400, non_string.status_code) diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py index 7a0e544..7a89e15 100644 --- a/server/tests/test_stats.py +++ b/server/tests/test_stats.py @@ -136,5 +136,91 @@ class ScoponeStatsTest(unittest.TestCase): self.assertTrue(all(r["matches"] == 1 for r in by_sub.values())) +async def _finished_briscola_session() -> GameSession: + engine = platform.registry.require("briscola") + session = GameSession( + id="stats-briscola-1", + game_type=engine.id, + join_code="BS0001", + creator_sub="alice", + players=[Seat(user_sub="alice", display_name="Alice")], + ) + engine.create(session, {}) + engine.join(session, "bob", "Bob") + moves = 0 + while not engine.is_finished(session) and moves < 200000: + state = session.state + if state.phase == "hand_end": + for player in state.players: + engine.handle_action(session, player.sub, "ack", {}) + continue + player = next(p for p in state.players if p.seat == state.turn) + engine.handle_action( + session, player.sub, "play", {"card": player.hand[0].code} + ) + moves += 1 + assert engine.is_finished(session) + return session + + +class BriscolaStatsTest(unittest.TestCase): + @async_test + async def test_finished_briscola_match_persisted_with_summary(self) -> None: + await tortoise_mixin._bind() + ctx = tortoise_mixin._ctx + assert ctx is not None + engine = platform.registry.require("briscola") + session = await _finished_briscola_session() + with ctx: + await save_match_result(session, engine) + self.assertEqual(1, await Match.all().count()) + self.assertEqual(2, await MatchPlayer.all().count()) + + match = await Match.all().first() + assert match is not None + self.assertEqual("briscola", match.game_type) + summary = match.result + self.assertIn("team_a_score", summary) + self.assertIn("team_b_score", summary) + self.assertIn("winner_team", summary) + self.assertIn("target_score", summary) + self.assertIn("deals_played", summary) + self.assertIn("deal_scores", summary) + + winner_team = summary["winner_team"] + players = {p.user_sub: p for p in await MatchPlayer.all()} + for row in players.values(): + if row.won: + self.assertEqual(winner_team, row.team) + else: + self.assertNotEqual(winner_team, row.team) + # A two-player match moves Elo by 16 points each. + ratings = {r.user_sub: r.rating for r in await PlayerRating.all()} + self.assertEqual(2, len(ratings)) + self.assertEqual( + {INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values()) + ) + + @async_test + async def test_history_exposes_briscola_result(self) -> None: + await tortoise_mixin._bind() + ctx = tortoise_mixin._ctx + assert ctx is not None + engine = platform.registry.require("briscola") + session = await _finished_briscola_session() + with ctx: + await save_match_result(session, engine) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + history = await client.get("/api/me/matches") + self.assertEqual(200, history.status_code) + results = history.json()["results"] + self.assertEqual(1, len(results)) + self.assertEqual("briscola", results[0]["game_type"]) + self.assertIn("team_a_score", results[0]["result"]) + self.assertEqual(2, len(results[0]["players"])) + + if __name__ == "__main__": unittest.main() diff --git a/web/src/components/briscola.rs b/web/src/components/briscola.rs new file mode 100644 index 0000000..4d31746 --- /dev/null +++ b/web/src/components/briscola.rs @@ -0,0 +1,190 @@ +//! The briscola deal-end summary screen. +//! +//! Shown when a deal finishes but the match continues: the points won in +//! the deal just played, the running totals toward the target and the +//! acknowledgement button (the server deals on when everyone is ready, or +//! after the hand-end timeout). +use sycamore::prelude::*; + +use crate::components::card::{card_back, card_img}; +use crate::model::{DealSummary, GameView}; +use crate::ws::GameSocket; + +/// One explanatory row: icon, title, plain-language sentence, points chip. +fn summary_row( + icon: View, + title: &'static str, + text: String, + cls: &'static str, + chip: String, +) -> View { + view! { + div(class=cls) { + div(class="score-icon") { (icon) } + div(class="score-body") { + div(class="score-title") { (title) } + div(class="score-text") { (text) } + } + div(class="score-points") { (chip) } + } + } +} + +/// The scoring rows of one completed deal (also used by the final screen). +pub fn deal_rows(summary: &DealSummary) -> View { + let a = summary.points.a; + let b = summary.points.b; + let cls = if summary.patta { + "score-row tie" + } else if a > b { + "score-row team-a" + } else { + "score-row team-b" + }; + let chip = if summary.patta { + "patta".to_string() + } else { + format!("+{a} · +{b}") + }; + let patta_note = summary.patta.then(|| view! { + p(class="text-warning small mb-0") { + "60–60: patta — the deal is replayed and its points do not count." + } + }); + let points_row = summary_row( + card_img("01D".to_string(), "score-mini"), + "Deal points", + format!("Team A {a} · Team B {b}"), + cls, + chip, + ); + let cards = summary.cards.map(|c| { + summary_row( + card_back("score-mini"), + "Cards captured", + format!("Team A {} · Team B {}", c.a, c.b), + "score-row", + format!("{} · {}", c.a, c.b), + ) + }); + view! { + div(class="score-rows") { + (points_row) + (patta_note) + (cards) + } + } +} + +/// 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 { + 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 (gained_a, gained_b) = if summary.patta { + (0, 0) + } else { + (summary.points.a, summary.points.b) + }; + view! { + div(class="totals") { + div(class="total-row team-a") { + span(class="total-label") { "Team A" } + div(class="progress") { + div(class="progress-fill", style=format!("width: {pct_a}%")) {} + } + span(class="total-value") { + (summary.totals.a) " / " (target) + span(class="gained") { "+" (gained_a) } + } + } + div(class="total-row team-b") { + span(class="total-label") { "Team B" } + div(class="progress") { + div(class="progress-fill", style=format!("width: {pct_b}%")) {} + } + span(class="total-value") { + (summary.totals.b) " / " (target) + span(class="gained") { "+" (gained_b) } + } + } + } + } +} + +/// The full deal-end modal: points + totals + acknowledgement button. +pub fn deal_summary_modal( + game: GameView, + socket: Signal>, + now: Signal, +) -> View { + let Some(summary) = game.last_deal.clone() else { + return view! {}; + }; + let viewer_seat = game + .players + .iter() + .find(|p| p.hand.is_some()) + .map(|p| p.seat); + let acked = game.acknowledged.clone(); + let already_acked = viewer_seat.is_some_and(|s| acked.contains(&s)); + let waiting: Vec = game + .players + .iter() + .filter(|p| !acked.contains(&p.seat)) + .map(|p| p.name.clone()) + .collect(); + let countdown = game.hand_end_deadline.as_ref().map(|deadline| { + // A dynamic closure so only the ticking number re-renders, not the + // whole modal (which would swap DOM nodes under the user's cursor). + let deadline_ms = js_sys::Date::parse(deadline); + view! { + p(class="text-secondary small") { + "Auto-continuing in " + (move || { + ((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32 + }) + "s" + } + } + }); + + let title = format!("Deal {} — results", summary.deal); + let rows = deal_rows(&summary); + let totals_view = totals(&game, &summary); + + let action = if game.spectator { + // Spectators cannot acknowledge; they just watch the summary. + view! { + p(class="text-secondary small mb-0") { + "Spectating — the next deal is dealt when every player is ready." + } + } + } else if already_acked { + let waiting_text = format!("Waiting for {}…", waiting.join(", ")); + view! { + button(class="btn btn-warning", disabled=true) { (waiting_text) } + } + } else { + view! { + button(class="btn btn-warning", on:click=move |_| { + if let Some(s) = socket.get_clone() { + s.ack(); + } + }) { "Understood, next deal" } + } + }; + + view! { + div(class="overlay") { + div(class="picker summary-panel") { + h2 { (title) } + (rows) + (totals_view) + div(class="summary-actions") { (action) } + (countdown) + } + } + } +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index 4842663..094fca5 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,3 +1,4 @@ +pub mod briscola; pub mod card; pub mod copy; pub mod nav; diff --git a/web/src/model.rs b/web/src/model.rs index f04b298..1093cf4 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -26,6 +26,9 @@ pub struct PlayerView { pub captured_count: usize, #[serde(default)] pub scope: i32, + /// Briscola: point value of the player's captured pile. + #[serde(default)] + pub points: i32, /// Own hand only; absent for the other players. #[serde(default)] pub hand: Option>, @@ -54,6 +57,38 @@ pub struct MoveView { pub pigliatutto: bool, } +/// One card played in the current briscola trick, in play order. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TrickCard { + pub seat: usize, + pub card: String, +} + +/// The most recently completed briscola trick. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct LastTrick { + pub winner: usize, + #[serde(default)] + pub cards: Vec, +} + +/// The scoring breakdown of one completed briscola deal. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct DealSummary { + #[serde(default)] + pub deal: i32, + /// A 60-60 deal: its points are discarded and the deal is replayed. + #[serde(default)] + pub patta: bool, + pub points: Scores, + pub totals: Scores, + #[serde(default)] + pub cards: Option, +} + #[derive(Debug, Clone, Copy, Deserialize)] pub struct TeamCounts { #[serde(rename = "A")] @@ -168,6 +203,30 @@ pub struct GameView { /// Legal captures per hand card; present only for the player on turn. #[serde(default)] pub legal_moves: Option>>>, + /// Briscola: number of seats at the table (2 or 4). + #[serde(default)] + pub player_count: usize, + /// Briscola: trump suit of the current deal (empty for other games). + #[serde(default)] + pub briscola_suit: String, + /// Briscola: the face-up trump card, until it is drawn. + #[serde(default)] + pub briscola_card: Option, + /// Briscola: face-down cards left in the stock. + #[serde(default)] + pub stock_count: usize, + /// Briscola: cards of the trick in progress, in play order. + #[serde(default)] + pub trick: Vec, + /// Briscola: seat that led the current trick. + #[serde(default)] + pub trick_leader: Option, + /// Briscola: the most recently completed trick. + #[serde(default)] + pub last_trick: Option, + /// Briscola: scoring breakdown of the most recent deal. + #[serde(default)] + pub last_deal: Option, } #[derive(Debug, Deserialize)] @@ -567,6 +626,22 @@ pub fn card_asset(code: &str) -> String { format!("/assets/cards/{code}.svg") } +/// Whether a game type is one of the briscola variants. +pub fn is_briscola(game_type: &str) -> bool { + game_type == "briscola" || game_type == "briscola_a_quattro" +} + +/// Human-readable suit name for a one-letter code (`D` -> "denari"). +pub fn suit_name(suit: &str) -> String { + match suit { + "D" => "denari".to_string(), + "C" => "coppe".to_string(), + "S" => "spade".to_string(), + "B" => "bastoni".to_string(), + _ => suit.to_string(), + } +} + pub const CARD_BACK: &str = "/assets/cards/back.svg"; /// Display order key for a hand: rank ascending (01..10, i.e. 1..7 then @@ -1054,4 +1129,112 @@ mod tests { assert!(g.default_options().is_empty()); assert_eq!(0, g.min_players); } + + #[test] + fn is_briscola_matches_both_variants() { + assert!(is_briscola("briscola")); + assert!(is_briscola("briscola_a_quattro")); + assert!(!is_briscola("scopone_scientifico")); + assert_eq!("denari", suit_name("D")); + assert_eq!("coppe", suit_name("C")); + assert_eq!("spade", suit_name("S")); + assert_eq!("bastoni", suit_name("B")); + assert_eq!("?", suit_name("?")); + } + + #[test] + fn game_view_parses_briscola_payload() { + let g: GameView = serde_json::from_value(json!({ + "id": "b1", + "join_code": "ABC123", + "game_type": "briscola", + "phase": "playing", + "target_score": 61, + "player_count": 2, + "hand_number": 1, + "dealer": 0, + "turn": 1, + "scores": {"A": 0, "B": 0}, + "briscola_suit": "D", + "briscola_card": "07D", + "stock_count": 33, + "trick": [{"seat": 1, "card": "04C"}], + "trick_leader": 1, + "last_trick": null, + "players": [ + {"sub": "alice", "name": "Alice", "seat": 0, "team": "A", + "cards_left": 3, "captured_count": 0, "points": 0, + "hand": ["01D"]}, + {"sub": "bob", "name": "Bob", "seat": 1, "team": "B", + "cards_left": 3, "captured_count": 0, "points": 0}, + ], + "last_deal": null, + "acknowledged": [], + "hand_end_deadline": null, + "turn_deadline": "2026-01-01T00:00:30+00:00", + "your_turn": true, + })) + .unwrap(); + assert!(is_briscola(&g.game_type)); + assert_eq!("D", g.briscola_suit); + assert_eq!(Some("07D".to_string()), g.briscola_card); + assert_eq!(33, g.stock_count); + assert_eq!(1, g.trick.len()); + assert_eq!(1, g.trick[0].seat); + assert_eq!("04C", g.trick[0].card); + assert_eq!(Some(1), g.trick_leader); + assert_eq!(0, g.players[0].points); + assert_eq!(Some(true), g.your_turn); + } + + #[test] + fn game_view_parses_briscola_deal_summary() { + let g: GameView = serde_json::from_value(json!({ + "id": "b1", + "game_type": "briscola_a_quattro", + "phase": "hand_end", + "player_count": 4, + "scores": {"A": 55, "B": 65}, + "last_deal": { + "deal": 1, + "patta": false, + "points": {"A": 55, "B": 65}, + "totals": {"A": 55, "B": 65}, + "cards": {"A": 19, "B": 21}, + }, + "last_trick": { + "winner": 3, + "cards": [ + {"seat": 0, "card": "02D"}, + {"seat": 1, "card": "05D"}, + {"seat": 2, "card": "04D"}, + {"seat": 3, "card": "07D"}, + ], + }, + })) + .unwrap(); + let deal = g.last_deal.unwrap(); + 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); + let cards = deal.cards.unwrap(); + assert_eq!(19, cards.a); + assert_eq!(21, cards.b); + let trick = g.last_trick.unwrap(); + assert_eq!(3, trick.winner); + assert_eq!(4, trick.cards.len()); + } + + #[test] + fn deal_summary_defaults() { + let d: DealSummary = serde_json::from_value(json!({ + "points": {"A": 60, "B": 60}, + "totals": {"A": 10, "B": 20}, + })) + .unwrap(); + assert!(!d.patta); + assert_eq!(0, d.deal); + assert!(d.cards.is_none()); + } } diff --git a/web/src/pages/briscola.rs b/web/src/pages/briscola.rs new file mode 100644 index 0000000..2afc228 --- /dev/null +++ b/web/src/pages/briscola.rs @@ -0,0 +1,258 @@ +//! Briscola table view: classic two-player and four-player partnership. +//! +//! The two variants share this view: four seats render in the usual cross +//! (partners opposite), two seats stack opponent over player. The center +//! holds the trick in progress, the stock pile and the face-up briscola. +use sycamore::prelude::*; + +use crate::components::card::{card_back, card_img}; +use crate::components::copy::CopyButton; +use crate::model::{hand_sort_key, suit_name, GameView, PlayerView, Scores}; +use crate::pages::game::watch_link; + +fn player_for_seat(game: &GameView, seat: usize) -> Option { + game.players.iter().find(|p| p.seat == seat).cloned() +} + +/// Panel for one player seat (name, team, hidden card count, 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(); + let team = player.team.clone(); + let points = player.points; + let captured = player.captured_count; + let backs = (0..player.cards_left) + .map(|_| card_back("mini")) + .collect::>(); + view! { + div(class=cls) { + div(class="seat-name") { + (name) + span(class="team-badge") { "Team " (team) } + } + div(class="seat-cards") { (backs) } + div(class="seat-stats") { + (points) " points · " (captured) " cards" + } + } + } +} + +/// The stock pile with the face-up briscola card beside it. +fn stock_view(game: &GameView) -> View { + let backs = (0..game.stock_count.min(3)) + .map(|_| card_back("stock-card")) + .collect::>(); + let trump = game + .briscola_card + .clone() + .map(|code| card_img(code, "briscola-card")); + let label = format!("{} in stock", game.stock_count); + view! { + div(class="stock") { + div(class="stock-pile") { (backs) } + (trump) + span(class="stock-count") { (label) } + } + } +} + +/// 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_for_seat(game, tc.seat) + .map(|p| p.name) + .unwrap_or_default(); + let card = card_img(tc.card.clone(), "table-card"); + view! { + div(class="trick-slot") { + span(class="trick-name") { (name) } + (card) + } + } + }) + .collect::>(); + view! { (cards) } +} + +/// Announcement strip for the most recently completed trick. +fn last_trick_banner(game: &GameView) -> View { + let Some(trick) = game.last_trick.clone() else { + return view! {}; + }; + let name = player_for_seat(game, trick.winner) + .map(|p| p.name) + .unwrap_or_default(); + let cards = trick + .cards + .iter() + .map(|tc| card_img(tc.card.clone(), "move-card")) + .collect::>(); + view! { + div(class="move-banner") { + strong { (name) } + span { " takes the trick " } + (cards) + } + } +} + +/// The main briscola table. +pub fn briscola_table( + game: GameView, + on_hand_card: impl Fn(String) + Copy + 'static, + now: Signal, +) -> View { + // Own seat: the only player entry carrying a hand. Spectators have no + // hand, so they watch from seat 0's perspective. + let viewer_seat = game + .players + .iter() + .find(|p| p.hand.is_some()) + .map(|p| p.seat) + .unwrap_or(0); + let player_count = if game.player_count > 0 { + game.player_count + } else { + game.players.len().max(1) + }; + let my_turn = game.your_turn == Some(true); + let turn_note = if game.phase == "finished" { + "Match finished".to_string() + } else if game.phase == "hand_end" { + "Deal finished".to_string() + } else if my_turn { + "Your turn".to_string() + } else { + let name = player_for_seat(&game, game.turn) + .map(|p| p.name) + .unwrap_or_default(); + format!("{name}'s turn") + }; + let turn_cls = if my_turn { "turn-note you" } else { "turn-note" }; + let countdown = game.turn_deadline.as_ref().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-play in " + (move || { + ((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32 + }) + "s" + } + } + }); + + let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 }); + let target = game.target_score; + let deal = game.hand_number; + + let (grid_cls, top, left, right) = if player_count == 2 { + ( + "table-grid duo", + seat_panel(game.clone(), (viewer_seat + 1) % 2, "top"), + view! {}, + view! {}, + ) + } else { + ( + "table-grid", + seat_panel(game.clone(), (viewer_seat + 2) % 4, "top"), + seat_panel(game.clone(), (viewer_seat + 1) % 4, "left"), + seat_panel(game.clone(), (viewer_seat + 3) % 4, "right"), + ) + }; + + 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"); + view! { + button(class="hand-slot", on:click=move |_| { + if my_turn { + on_hand_card(code.clone()); + } + }) { (card_view) } + } + }) + .collect::>(); + let hint = my_turn.then(|| view! { + p(class="text-secondary small text-center") { + "Click a card to play it." + } + }); + + // 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) } } + }); + // Precomputed so the final view's closures only capture owned views. + let banner = last_trick_banner(&game); + let trick = trick_view(&game); + let stock = stock_view(&game); + + view! { + div(class="table-wrap") { + div(class="hud") { + a(href="/") { "← Lobby" } + span { "Deal " (deal) } + (trump_badge) + (spectator_badge) + span(class="hud-scores") { + "Team A " (scores.a) " — " (scores.b) " Team B (target " + (target) ")" + } + span(class=turn_cls) { (turn_note) } + (countdown) + CopyButton(text=watch_link, label="Copy watch link".to_string()) + } + div(class=grid_cls) { + (top) + (left) + div(class="center") { + (banner) + div(class="table-cards") { (trick) } + (stock) + } + (right) + (bottom) + } + } + } +} diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index 6edfcb5..edd38d9 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -6,11 +6,16 @@ use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api; +use crate::components::briscola::{deal_rows, deal_summary_modal}; use crate::components::card::{card_back, card_img}; 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, GameView, MoveView, PlayerView, Scores, ServerMessage}; +use crate::model::{ + card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores, + ServerMessage, +}; +use crate::pages::briscola::briscola_table; use crate::ws::{self, GameSocket}; fn send_play(socket: Signal>, card: String, capture: Option>) { @@ -20,7 +25,7 @@ fn send_play(socket: Signal>, card: String, capture: Option String { +pub(crate) fn watch_link(id: &str) -> String { let origin = web_sys::window() .and_then(|w| w.location().origin().ok()) .unwrap_or_default(); @@ -387,6 +392,10 @@ pub fn GamePage(id: String) -> View { } } Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) }, + // Briscola has its own table view for both variants. + Some(g) if is_briscola(&g.game_type) => { + briscola_table(g, on_hand_card, now) + } // The live table is scopone-specific; other games get a // fallback until they ship their own play view. Some(g) if g.game_type == "scopone_scientifico" => { @@ -401,6 +410,9 @@ pub fn GamePage(id: String) -> View { Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => { hand_summary_modal(g, socket, now) } + Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => { + deal_summary_modal(g, socket, now) + } _ => view! {}, }) (move || game_over_view(over.get_clone(), game.get_clone())) @@ -715,11 +727,11 @@ fn capture_picker( } } -/// End-of-match overlay (scopone's team score; other games render -/// nothing until they ship their own play view). +/// End-of-match overlay: the final hand/deal breakdown and the winner. fn game_over_view(over: Option<(Scores, Option)>, game: Option) -> View { + let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type)); if let Some(g) = &game { - if g.game_type != "scopone_scientifico" { + if g.game_type != "scopone_scientifico" && !briscola { return view! {}; } } @@ -735,13 +747,19 @@ fn game_over_view(over: Option<(Scores, Option)>, game: Option let line = format!("Team {winner} 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 scoring before the result. - let final_summary = game - .and_then(|g| g.last_hand) - .map(|s| { - let rows = summary_rows(s); - view! { (rows) } - }); + // Explain the final hand's (scopone) or deal's (briscola) + // scoring before the result. + let final_summary: View = if briscola { + match game.as_ref().and_then(|g| g.last_deal.clone()) { + Some(deal) => deal_rows(&deal), + None => view! {}, + } + } else { + match game.as_ref().and_then(|g| g.last_hand.clone()) { + Some(hand) => summary_rows(hand), + None => view! {}, + } + }; let my_matches = (!spectator).then(|| view! { a(class="btn btn-outline-light", href="/history") { "My matches" } }); diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index 0eea1b6..831568e 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -20,14 +20,32 @@ use crate::model::{ /// Used when the game-types fetch fails: match creation must still work. fn fallback_game_types() -> Vec { - vec![GameTypeInfo { - id: "scopone_scientifico".to_string(), - name: "Scopone scientifico".to_string(), - description: String::new(), - min_players: 4, - max_players: 4, - options_schema: serde_json::Value::Null, - }] + vec![ + GameTypeInfo { + id: "scopone_scientifico".to_string(), + name: "Scopone scientifico".to_string(), + description: String::new(), + min_players: 4, + max_players: 4, + options_schema: serde_json::Value::Null, + }, + GameTypeInfo { + id: "briscola".to_string(), + name: "Briscola".to_string(), + description: String::new(), + min_players: 2, + max_players: 2, + options_schema: serde_json::Value::Null, + }, + GameTypeInfo { + id: "briscola_a_quattro".to_string(), + name: "Briscola a quattro".to_string(), + description: String::new(), + min_players: 4, + max_players: 4, + options_schema: serde_json::Value::Null, + }, + ] } /// Human-readable label for a provider name ("google" -> "Google"). @@ -552,7 +570,7 @@ pub fn LobbyPage() -> View { div(class="lobby-hero text-center") { h1(class="display-5 fw-bold") { "tavolo" } p(class="lead text-secondary mb-0") { - "Play scopone scientifico with friends." + "Play scopone scientifico and briscola with friends." } } div(class="row g-4") { diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 51efaf1..6259579 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -1,3 +1,4 @@ +pub mod briscola; pub mod game; pub mod history; pub mod leaderboard; diff --git a/web/src/ws.rs b/web/src/ws.rs index 8fdb363..d21ce1a 100644 --- a/web/src/ws.rs +++ b/web/src/ws.rs @@ -83,8 +83,14 @@ pub fn connect( while let Some(msg) = read.next().await { match msg { Ok(Message::Text(text)) => { - if let Ok(parsed) = serde_json::from_str::(&text) { - on_message(parsed); + match serde_json::from_str::(&text) { + Ok(parsed) => on_message(parsed), + // A silently dropped message would leave the page + // waiting forever; surface the parse error so it is + // visible in the browser console. + Err(err) => web_sys::console::error_1( + &format!("unparseable server message: {err}").into(), + ), } } Ok(Message::Bytes(_)) => {} diff --git a/web/style.css b/web/style.css index 965e028..b00f665 100644 --- a/web/style.css +++ b/web/style.css @@ -1,4 +1,4 @@ -/* Scopone scientifico frontend — game table, cards and scoring summary. +/* tavolo frontend — game tables, cards and scoring summaries. * * The felt palette and the Bootstrap theme overrides live in theme.css, * which is loaded before this file. */ @@ -474,3 +474,69 @@ body { font-size: 1.25rem; font-weight: 700; } + +/* ---------- briscola ---------- */ + +.briscola-badge { + background: var(--panel-light); + color: var(--accent); + font-weight: 700; + border-radius: 999px; + padding: 0.15rem 0.6rem; + border: 1px solid var(--accent); +} + +/* The face-down stock with the face-up trump card beside it. */ +.stock { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.stock-pile { + display: flex; +} + +.playing-card.stock-card { + height: 48px; + margin-left: -34px; +} + +.playing-card.stock-card:first-child { + margin-left: 0; +} + +.playing-card.briscola-card { + height: 64px; + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.stock-count { + color: var(--muted); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} + +/* Current-trick cards with the player's name above each. */ +.trick-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; +} + +.trick-name { + font-size: 0.7rem; + color: var(--muted); +} + +/* Two-player table: opponent on top, player at the bottom. */ +.table-grid.duo { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "top" + "center" + "bottom"; +}