Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2dae476f0
|
||
|
|
18877b8109
|
||
|
|
b821274f0b
|
||
|
|
6222e38da3
|
||
|
|
97d4b3f217
|
||
|
|
7bb6b74fdc
|
||
|
|
b5219062f2
|
@@ -1,14 +1,16 @@
|
|||||||
# tavolo
|
# tavolo
|
||||||
|
|
||||||
A platform for multiplayer card games. The first game is **scopone
|
A platform for multiplayer card games. Two games are included:
|
||||||
scientifico** — the four-player, fixed-partnership Italian card game:
|
**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)
|
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||||
A monorepo of three distributions sharing the `tavolo` namespace: the
|
A monorepo of four distributions sharing the `tavolo` namespace: the
|
||||||
game-independent `tavolo-platform`, the scopone game `tavolo-scopone`,
|
game-independent `tavolo-platform`, the `tavolo-scopone` and
|
||||||
and the `tavolo-app` composition root wiring them together.
|
`tavolo-briscola` games, and the `tavolo-app` composition root wiring
|
||||||
See [server/README.md](server/README.md).
|
them together. See [server/README.md](server/README.md).
|
||||||
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||||||
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
||||||
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
||||||
@@ -37,21 +39,30 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
|||||||
## Between hands
|
## Between hands
|
||||||
|
|
||||||
When a hand ends but the match is not decided, the game pauses on a
|
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
|
(carte, denara, settebello, primiera, scope — plus napola and asso
|
||||||
pigliatutto when enabled)
|
pigliatutto when enabled), briscola players see the deal's points and any
|
||||||
with the running totals and
|
*patta*; both see the running totals and must click "Understood" before
|
||||||
must click "Understood" before the next hand is dealt. If someone is away
|
the next hand is dealt. If someone is away the next hand is dealt
|
||||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
|
||||||
(default 30s). The match-ending hand is explained on the final screen.
|
match-ending hand is explained on the final screen.
|
||||||
|
|
||||||
## On your turn
|
## On your turn
|
||||||
|
|
||||||
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
||||||
player does not move — disconnected or fallen asleep — the server plays 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.
|
rules require a capture), so one absent player cannot stall the table.
|
||||||
|
|
||||||
|
## Watching
|
||||||
|
|
||||||
|
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
||||||
|
spectators see the table, the score and every player's captured cards
|
||||||
|
(and scope for scopone), but never a hand and never the join code. The
|
||||||
|
"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
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
Backend (from `server/`):
|
||||||
@@ -60,10 +71,11 @@ Backend (from `server/`):
|
|||||||
cd server
|
cd server
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.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 tests -t .
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
.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-scopone/tests
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend (from `web/`), with the backend running on :8080:
|
Frontend (from `web/`), with the backend running on :8080:
|
||||||
|
|||||||
+2
-2
@@ -58,13 +58,13 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
|||||||
&& /opt/venv/bin/pip install --upgrade pip \
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
&& /opt/venv/bin/pip install -r requirements.txt
|
&& /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
|
# namespace packages sharing the `tavolo` namespace). --no-deps: every
|
||||||
# third-party dependency — including the tavolo-app `otel` extra — is
|
# third-party dependency — including the tavolo-app `otel` extra — is
|
||||||
# already pinned in requirements.txt.
|
# already pinned in requirements.txt.
|
||||||
COPY server/packages/ ./packages/
|
COPY server/packages/ ./packages/
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
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
|
# server/pyproject.toml only carries the [tool.aerich] section (the
|
||||||
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
|
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
|
||||||
# next to ./migrations).
|
# next to ./migrations).
|
||||||
|
|||||||
+89
-27
@@ -1,10 +1,12 @@
|
|||||||
# tavolo (backend)
|
# tavolo (backend)
|
||||||
|
|
||||||
The backend for a multiplayer card-game platform, built on the
|
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
|
sharing the `tavolo` namespace (see
|
||||||
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md)
|
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
|
||||||
and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/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
|
- **`tavolo-platform`** (`packages/tavolo-platform/`) — everything
|
||||||
game-independent: the lobby API, the live-game store, the WebSocket
|
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
|
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
|
||||||
scientifico, the four-player, fixed-partnership variant of the classic
|
scientifico, the four-player, fixed-partnership variant of the classic
|
||||||
Italian card game: pure rules plus the platform adapter.
|
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
|
- **`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.
|
environment-driven settings and the SPA shell.
|
||||||
|
|
||||||
Players authenticate with the configured **OIDC** provider. Live game
|
Players authenticate with the configured **OIDC** provider. Live game
|
||||||
@@ -74,8 +80,8 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
|
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
|
||||||
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
|
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
|
||||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
| `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`) |
|
| `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 scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
signals; every open WebSocket reloads the session and pushes the
|
||||||
personalized view to its player.
|
personalized view to its player.
|
||||||
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
|
- `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
|
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
|
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
|
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
|
scoped per game), start/finish timestamps, and the game-specific outcome
|
||||||
as reported by the engine (`result` JSON — for scopone: both teams'
|
as reported by the engine (`result` JSON — for scopone: both teams'
|
||||||
final scores, winner, target score, hands played and the per-hand
|
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,
|
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||||
seat, game-defined team label (nullable), whether they won, the points
|
seat, game-defined team label (nullable), whether they won, the points
|
||||||
they scored, the Elo change the match produced (`elo_delta`) and
|
they scored, the Elo change the match produced (`elo_delta`) and
|
||||||
@@ -262,40 +269,47 @@ existed):
|
|||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||||
`/api/game-types` and `/api/leaderboard` require authentication.
|
`/api/game-types`, `/api/leaderboard` and the game snapshot
|
||||||
|
(`GET /api/games/{id}`) require authentication.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
| `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 |
|
| `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/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}` | Personalized snapshot (only your own hand is visible) |
|
| `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 |
|
||||||
|
| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
|
||||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||||
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
||||||
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
||||||
|
|
||||||
## WebSocket protocol
|
## WebSocket protocol
|
||||||
|
|
||||||
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone
|
||||||
players are accepted; the upgrade is refused before the handshake
|
who knows the game id is accepted: seated players get their personalized
|
||||||
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
|
view, everyone else — anonymous users and authenticated non-players — is
|
||||||
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
|
a read-only **spectator**. Only an unknown game id is refused before the
|
||||||
a refused handshake as an abnormal close — there is no close code to
|
handshake completes, with `404`. (Browsers report a refused handshake as
|
||||||
read.)
|
an abnormal close — there is no close code to read.)
|
||||||
|
|
||||||
Server → client messages are JSON objects with a `type`:
|
Server → client messages are JSON objects with a `type`:
|
||||||
|
|
||||||
- `{"type": "state", "game": {...}}` — personalized game view. Your own
|
- `{"type": "state", "game": {...}}` — personalized game view. Your own
|
||||||
hand is included; other players expose only their card counts.
|
hand is included; other players expose only their card counts.
|
||||||
`your_turn: true` is present when it is your move.
|
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. `seats_open` reports the free chairs (0 once the match
|
||||||
|
started).
|
||||||
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
|
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
|
||||||
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
||||||
|
|
||||||
Client → server messages are JSON objects with an `action`. The
|
Client → server messages are JSON objects with an `action`. The
|
||||||
platform handles `state` (resend the snapshot) itself; every other action
|
platform handles `state` (resend the snapshot) itself; every other action
|
||||||
is dispatched to the session's game engine with the rest of the message
|
is dispatched to the session's game engine with the rest of the message
|
||||||
as its payload. For scopone scientifico:
|
as its payload. Spectators may only send `state`/`sync`: any other
|
||||||
|
action is answered with an `error` whose `code` is `forbidden`. For
|
||||||
|
scopone scientifico:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
@@ -314,7 +328,21 @@ as its payload. For scopone scientifico:
|
|||||||
after `HAND_ACK_TIMEOUT_SECONDS`.
|
after `HAND_ACK_TIMEOUT_SECONDS`.
|
||||||
- `state` asks for a fresh snapshot.
|
- `state` asks for a fresh snapshot.
|
||||||
|
|
||||||
After every accepted move the new state is broadcast to all four players.
|
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.
|
||||||
|
|
||||||
### Turn timeout
|
### Turn timeout
|
||||||
|
|
||||||
@@ -330,15 +358,18 @@ match keeps progressing even with every player disconnected.
|
|||||||
### Hand-end summary
|
### Hand-end summary
|
||||||
|
|
||||||
When a hand finishes but the match continues, the game enters the
|
When a hand finishes but the match continues, the game enters the
|
||||||
`hand_end` phase instead of dealing immediately: the state carries
|
`hand_end` phase instead of dealing immediately: the state carries the
|
||||||
`last_hand` (a full scoring breakdown with an `award` map naming the team
|
scoring breakdown of the hand just played (`last_hand` with an `award`
|
||||||
that won each category), the `acknowledged` seats and a
|
map naming the team that won each scopone category; `last_deal` with the
|
||||||
`hand_end_deadline`. The frontend renders this as a screen every player
|
deal's points and running totals for briscola), the `acknowledged` seats
|
||||||
must dismiss. A play attempted in this phase is rejected with an
|
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.
|
`illegal_move` error.
|
||||||
|
|
||||||
## Rules implemented
|
## Rules implemented
|
||||||
|
|
||||||
|
### Scopone scientifico
|
||||||
|
|
||||||
- 40-card Italian deck, ten cards per player, empty table at hand start.
|
- 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)
|
- A card captures a **single card of equal rank** (mandatory when present)
|
||||||
or a **combination of table cards whose ranks sum to its own**.
|
or a **combination of table cards whose ranks sum to its own**.
|
||||||
@@ -362,20 +393,45 @@ must dismiss. A play attempted in this phase is rejected with an
|
|||||||
configurable per game via the `target_score` creation option) with a
|
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.
|
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
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.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:
|
# app integration suite:
|
||||||
.venv/bin/python -m unittest discover -s tests -t .
|
.venv/bin/python -m unittest discover -s tests -t .
|
||||||
# per-package suites (each package is self-contained):
|
# 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-platform/tests
|
||||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/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):
|
# 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.platform
|
||||||
.venv/bin/python -m mypy -p tavolo.scopone
|
.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
|
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
|
||||||
@@ -433,6 +489,12 @@ packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
|
|||||||
├── engine.py # pure scopone scientifico rules
|
├── engine.py # pure scopone scientifico rules
|
||||||
├── errors.py # scopone-specific rule violations
|
├── errors.py # scopone-specific rule violations
|
||||||
└── plugin.py # ScoponeEngine: the GameEngine adapter
|
└── 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)
|
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||||
src/tavolo/
|
src/tavolo/
|
||||||
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||||
@@ -443,5 +505,5 @@ packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
|||||||
└── static.py # SPA shell hosting
|
└── static.py # SPA shell hosting
|
||||||
pyproject.toml # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
|
pyproject.toml # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
|
||||||
migrations/ # aerich migrations for the platform models
|
migrations/ # aerich migrations for the platform models
|
||||||
tests/ # app integration suite (platform + scopone wired together)
|
tests/ # app integration suite (platform + games wired together)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
The deployable tavolo application: the composition root wiring
|
The deployable tavolo application: the composition root wiring
|
||||||
[`tavolo-platform`](../tavolo-platform/README.md) to the
|
[`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
|
## Contents
|
||||||
|
|
||||||
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
|
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
|
||||||
mixins plus `PlatformMixin` and the deadline scheduler), registers the
|
mixins plus `PlatformMixin` and the deadline scheduler), registers the
|
||||||
`ScoponeEngine` with its configured timeouts, and exposes
|
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
|
||||||
`tavolo.app:app` for granian.
|
their configured timeouts, and exposes `tavolo.app:app` for granian.
|
||||||
- `config.py` — environment-driven frozen `Settings`.
|
- `config.py` — environment-driven frozen `Settings`.
|
||||||
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
|
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
|
||||||
meter/tracer/logger providers, the
|
meter/tracer/logger providers, the
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "tavolo-app"
|
name = "tavolo-app"
|
||||||
version = "0.1.0"
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tavolo-platform",
|
"tavolo-platform",
|
||||||
"tavolo-scopone",
|
"tavolo-scopone",
|
||||||
|
"tavolo-briscola",
|
||||||
"kaya-core",
|
"kaya-core",
|
||||||
"kaya-cors",
|
"kaya-cors",
|
||||||
"kaya-session",
|
"kaya-session",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from kaya.oidc import OIDCConfig, OIDCProviders
|
|||||||
from kaya.openapi import OpenAPIMixin
|
from kaya.openapi import OpenAPIMixin
|
||||||
from kaya.session import SessionMixin
|
from kaya.session import SessionMixin
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||||
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||||
@@ -131,9 +132,21 @@ registry.register(
|
|||||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
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.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||||
log.debug(
|
log.debug(
|
||||||
"scopone timeouts: hand_ack=%ds turn=%ds",
|
"game timeouts: hand_ack=%ds turn=%ds",
|
||||||
settings.hand_ack_timeout_seconds,
|
settings.hand_ack_timeout_seconds,
|
||||||
settings.turn_timeout_seconds,
|
settings.turn_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -215,12 +215,12 @@ class Settings:
|
|||||||
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
||||||
static_dir: str
|
static_dir: str
|
||||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
# 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).
|
# see tavolo.app).
|
||||||
hand_ack_timeout_seconds: int
|
hand_ack_timeout_seconds: int
|
||||||
# Seconds a player has to play before the server plays a random legal
|
# Seconds a player has to play before the server plays a random legal
|
||||||
# card for them, covering disconnects and idle players (wired into the
|
# card for them, covering disconnects and idle players (wired into the
|
||||||
# ScoponeEngine; see tavolo.app).
|
# game engines; see tavolo.app).
|
||||||
turn_timeout_seconds: int
|
turn_timeout_seconds: int
|
||||||
# Upper bound on how long the deadline consumer sleeps between polls
|
# Upper bound on how long the deadline consumer sleeps between polls
|
||||||
# when nothing else wakes it. Enqueued deadlines are announced over a
|
# when nothing else wakes it. Enqueued deadlines are announced over a
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# tavolo-briscola
|
||||||
|
|
||||||
|
Briscola — the Italian trick-taking game — as a
|
||||||
|
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||||
|
Two variants share one rules core:
|
||||||
|
|
||||||
|
- `briscola` — the classic two-player game;
|
||||||
|
- `briscola_a_quattro` — four-player fixed-partnership briscola.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `state.py` — `BriscolaState` (pure game data: phases, players, hands,
|
||||||
|
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||||
|
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||||
|
envelope, no transport, no I/O.
|
||||||
|
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
||||||
|
(trump first, then the led suit; strength ace > three > king > horse >
|
||||||
|
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
||||||
|
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
||||||
|
jack 2), patta re-deals and the target-score match end, deal-end
|
||||||
|
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
||||||
|
logging, so the whole rule set is unit-testable.
|
||||||
|
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||||
|
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||||
|
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
||||||
|
(`GameEngine`): the platform-facing adapters. They validate the
|
||||||
|
`target_score` creation option (61–120, default 61) and extract the
|
||||||
|
`MatchResult` (teams, winner, per-player scores and the match summary
|
||||||
|
persisted as the match's JSON `result`).
|
||||||
|
|
||||||
|
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||||
|
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||||
|
application composition root.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Three cards per player; the next card is turned face up: its suit is
|
||||||
|
the trump (briscola) for the deal and it is the last card drawn.
|
||||||
|
- The winner of a trick is the highest trump, or the highest card of the
|
||||||
|
led suit when no trump was played. The winner takes the trick and leads
|
||||||
|
the next one.
|
||||||
|
- After each trick everyone draws back up to three cards, starting with
|
||||||
|
the trick winner.
|
||||||
|
- Card points total 120 per deal; a 60–60 deal is a *patta*, its points
|
||||||
|
are discarded and the deal is replayed. The first team to the target
|
||||||
|
score wins; a tie at or above the target is broken by another deal.
|
||||||
|
- Between deals of an unfinished match every player acknowledges the
|
||||||
|
summary (or the hand-end timeout deals on), exactly like scopone.
|
||||||
|
|
||||||
|
## Development (from `server/`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||||
|
.venv/bin/python -m mypy -p tavolo.briscola
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||||
|
platform contract (actions, deadlines, serialization, results).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tavolo-briscola"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Briscola game implementation for the tavolo platform"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"tavolo-platform",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
plugins = []
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Briscola: tavolo's trick-taking game implementation.
|
||||||
|
|
||||||
|
Two variants share the same rules core and platform adapter:
|
||||||
|
|
||||||
|
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||||
|
briscola (``briscola``);
|
||||||
|
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
||||||
|
fixed-partnership briscola a quattro (``briscola_a_quattro``).
|
||||||
|
|
||||||
|
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||||
|
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
||||||
|
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||||
|
game-independent platform can host them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .plugin import BriscolaEngine, BriscolaQuattroEngine
|
||||||
|
|
||||||
|
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
"""Pure rules engine for briscola.
|
||||||
|
|
||||||
|
Every function here is deterministic and I/O-free (the only side effect is
|
||||||
|
logging): it mutates (or reads)
|
||||||
|
:class:`~tavolo.briscola.state.BriscolaState` and raises
|
||||||
|
:class:`~tavolo.platform.errors.GameError` subclasses on rule violations.
|
||||||
|
This makes the whole rule set unit-testable without Redis, Postgres or
|
||||||
|
HTTP. The platform-facing adapter is
|
||||||
|
:class:`~tavolo.briscola.plugin.BriscolaEngine`.
|
||||||
|
|
||||||
|
Rules implemented
|
||||||
|
-----------------
|
||||||
|
* 40-card Italian deck (4 suits x ranks 1-10), three cards per player.
|
||||||
|
* The next card after the deal is turned face up: its suit is the
|
||||||
|
**briscola** (trump) for the whole deal, and it is the last card drawn
|
||||||
|
from the stock (the stock is drawn from the bottom up).
|
||||||
|
* Trick play: the player to the dealer's left leads; each other player
|
||||||
|
plays one card. The highest trump wins; when no trump was played, the
|
||||||
|
highest card of the led suit wins. The winner takes the trick and leads
|
||||||
|
the next one.
|
||||||
|
* After each trick every player draws back up to three cards, starting
|
||||||
|
with the trick winner and continuing in turn order; the face-up
|
||||||
|
briscola card is drawn last, so whoever's turn to draw when only it
|
||||||
|
remains takes it.
|
||||||
|
* Card points: ace 11, three 10, king 4, horse 3, jack 2, everything else
|
||||||
|
0 — 120 points per deal. Trick strength is unrelated to points:
|
||||||
|
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
|
||||||
|
* Match points accumulate across deals. A 60-60 deal is a **patta**: its
|
||||||
|
points are discarded and the deal is replayed (the dealer rotates).
|
||||||
|
The first team to reach the target score (default 61) wins; a tie at or
|
||||||
|
above the target is broken by another deal.
|
||||||
|
* Between deals of an unfinished match the game pauses in the
|
||||||
|
``hand_end`` phase for the deal summary, exactly like scopone: every
|
||||||
|
player acknowledges (or the hand-end timeout elapses) before the next
|
||||||
|
deal.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .errors import CardNotInHand
|
||||||
|
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
DEFAULT_TARGET_SCORE,
|
||||||
|
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
HAND_SIZE,
|
||||||
|
MAX_TARGET_SCORE,
|
||||||
|
MIN_TARGET_SCORE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
PLAYER_COUNTS,
|
||||||
|
SUITS,
|
||||||
|
TEAM_NAMES,
|
||||||
|
BriscolaState,
|
||||||
|
Card,
|
||||||
|
PlayerState,
|
||||||
|
Trick,
|
||||||
|
TrickCard,
|
||||||
|
parse_card,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
# Trick strength: higher wins. Ace > three > king > horse > jack, then the
|
||||||
|
# remaining ranks in descending order. All ranks are distinct within a
|
||||||
|
# suit, so a trick never ties.
|
||||||
|
TRICK_STRENGTH: Dict[int, int] = {
|
||||||
|
1: 9,
|
||||||
|
3: 8,
|
||||||
|
10: 7,
|
||||||
|
9: 6,
|
||||||
|
8: 5,
|
||||||
|
7: 4,
|
||||||
|
6: 3,
|
||||||
|
5: 2,
|
||||||
|
4: 1,
|
||||||
|
2: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Card points: 120 per deal in total.
|
||||||
|
CARD_POINTS: Dict[int, int] = {
|
||||||
|
1: 11,
|
||||||
|
3: 10,
|
||||||
|
10: 4,
|
||||||
|
9: 3,
|
||||||
|
8: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
_rng = random.SystemRandom()
|
||||||
|
|
||||||
|
|
||||||
|
def full_deck() -> List[Card]:
|
||||||
|
"""Return the 40 cards of the Italian deck in canonical order."""
|
||||||
|
return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)]
|
||||||
|
|
||||||
|
|
||||||
|
def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
|
||||||
|
"""Return a shuffled deck. Pass ``rng`` for deterministic tests."""
|
||||||
|
deck = full_deck()
|
||||||
|
(rng or _rng).shuffle(deck)
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
def card_points(card: Card) -> int:
|
||||||
|
"""The point value of a single card."""
|
||||||
|
return CARD_POINTS.get(card.rank, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def create_game(
|
||||||
|
creator_sub: str,
|
||||||
|
creator_name: str,
|
||||||
|
player_count: int = 2,
|
||||||
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
) -> BriscolaState:
|
||||||
|
"""Create a lobby game with the creator seated first."""
|
||||||
|
if player_count not in PLAYER_COUNTS:
|
||||||
|
raise IllegalMove(f"player_count must be one of {PLAYER_COUNTS}")
|
||||||
|
if (
|
||||||
|
isinstance(target_score, bool)
|
||||||
|
or not isinstance(target_score, int)
|
||||||
|
or target_score < MIN_TARGET_SCORE
|
||||||
|
or target_score > MAX_TARGET_SCORE
|
||||||
|
):
|
||||||
|
raise IllegalMove(
|
||||||
|
f"target_score must be between {MIN_TARGET_SCORE} and {MAX_TARGET_SCORE}"
|
||||||
|
)
|
||||||
|
return BriscolaState(
|
||||||
|
player_count=player_count,
|
||||||
|
target_score=target_score,
|
||||||
|
phase=PHASE_LOBBY,
|
||||||
|
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
|
turn_timeout=turn_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def join_game(state: BriscolaState, sub: str, name: str) -> None:
|
||||||
|
"""Seat ``sub`` in the next free chair, starting the match when full."""
|
||||||
|
if state.phase != PHASE_LOBBY:
|
||||||
|
raise GameNotStarted("game has already started")
|
||||||
|
if state.seated(sub):
|
||||||
|
raise AlreadyJoined("already joined this game")
|
||||||
|
if len(state.players) >= state.player_count:
|
||||||
|
raise LobbyFull("game is full")
|
||||||
|
seat = len(state.players)
|
||||||
|
state.players.append(PlayerState(sub=sub, name=name, seat=seat))
|
||||||
|
if len(state.players) == state.player_count:
|
||||||
|
start_game(state)
|
||||||
|
|
||||||
|
|
||||||
|
def start_game(state: BriscolaState) -> None:
|
||||||
|
"""Deal the first hand and switch the game to playing."""
|
||||||
|
if len(state.players) != state.player_count:
|
||||||
|
raise GameNotStarted(
|
||||||
|
f"need exactly {state.player_count} players to start"
|
||||||
|
)
|
||||||
|
state.phase = PHASE_PLAYING
|
||||||
|
_deal(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_turn_deadline(state: BriscolaState) -> None:
|
||||||
|
"""Arm the auto-play deadline for whoever is on turn."""
|
||||||
|
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
|
||||||
|
state.turn_deadline = deadline.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _deal(state: BriscolaState) -> None:
|
||||||
|
deck = shuffled_deck()
|
||||||
|
for player in state.players:
|
||||||
|
player.hand = []
|
||||||
|
player.captured = []
|
||||||
|
state.trick = []
|
||||||
|
state.last_trick = None
|
||||||
|
state.briscola_card = None
|
||||||
|
# Dealer rotates each deal; the first card is played by the player to
|
||||||
|
# the dealer's left.
|
||||||
|
state.turn = (state.dealer + 1) % state.player_count
|
||||||
|
state.trick_leader = state.turn
|
||||||
|
for _ in range(HAND_SIZE):
|
||||||
|
for offset in range(state.player_count):
|
||||||
|
player = _player_at(
|
||||||
|
state, (state.dealer + 1 + offset) % state.player_count
|
||||||
|
)
|
||||||
|
player.hand.append(deck.pop())
|
||||||
|
# The turned-up card is the last card drawn: keep it separate from the
|
||||||
|
# face-down stock and hand it out once the stock is exhausted.
|
||||||
|
state.briscola_card = deck.pop()
|
||||||
|
state.briscola_suit = state.briscola_card.suit
|
||||||
|
state.stock = deck
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
log.debug(
|
||||||
|
"deal %d dealt (dealer seat %d, briscola %s)",
|
||||||
|
state.hand_number,
|
||||||
|
state.dealer,
|
||||||
|
state.briscola_card.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _player_at(state: BriscolaState, seat: int) -> PlayerState:
|
||||||
|
for player in state.players:
|
||||||
|
if player.seat == seat:
|
||||||
|
return player
|
||||||
|
raise IllegalMove(f"no player in seat {seat}")
|
||||||
|
|
||||||
|
|
||||||
|
def play(state: BriscolaState, sub: str, card_code: str) -> None:
|
||||||
|
"""Apply one move by the player identified by ``sub``.
|
||||||
|
|
||||||
|
Any card in hand is a legal play; only turn order and phase are
|
||||||
|
validated. Raises a :class:`~tavolo.platform.errors.GameError`
|
||||||
|
subclass on any violation.
|
||||||
|
"""
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
raise GameFinished("the match is over")
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
raise IllegalMove("the deal is over; acknowledge the summary to continue")
|
||||||
|
if state.phase != PHASE_PLAYING:
|
||||||
|
raise GameNotStarted("the game has not started yet")
|
||||||
|
|
||||||
|
player = state.player_for(sub)
|
||||||
|
if player is None or player.seat != state.turn:
|
||||||
|
raise NotYourTurn("it is not your turn")
|
||||||
|
|
||||||
|
card = parse_card(card_code)
|
||||||
|
if card not in player.hand:
|
||||||
|
raise CardNotInHand(f"card {card.code} is not in your hand")
|
||||||
|
|
||||||
|
player.hand.remove(card)
|
||||||
|
state.trick.append(TrickCard(seat=player.seat, card=card.code))
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d played %s (%d/%d in the trick)",
|
||||||
|
state.hand_number,
|
||||||
|
player.seat,
|
||||||
|
card.code,
|
||||||
|
len(state.trick),
|
||||||
|
state.player_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(state.trick) == state.player_count:
|
||||||
|
_resolve_trick(state)
|
||||||
|
else:
|
||||||
|
state.turn = (state.turn + 1) % state.player_count
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_trick(state: BriscolaState) -> None:
|
||||||
|
"""Score the completed trick, draw and hand the lead to the winner."""
|
||||||
|
winner = _trick_winner(state)
|
||||||
|
taken = [parse_card(t.card) for t in state.trick]
|
||||||
|
_player_at(state, winner).captured.extend(taken)
|
||||||
|
state.last_trick = Trick(winner=winner, cards=list(state.trick))
|
||||||
|
state.trick = []
|
||||||
|
state.trick_leader = winner
|
||||||
|
state.turn = winner
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d takes the trick with %s",
|
||||||
|
state.hand_number,
|
||||||
|
winner,
|
||||||
|
[t.card for t in state.last_trick.cards],
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(not player.hand for player in state.players):
|
||||||
|
_end_deal(state)
|
||||||
|
return
|
||||||
|
_draw(state, winner)
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _trick_winner(state: BriscolaState) -> int:
|
||||||
|
"""The seat winning the current (complete) trick."""
|
||||||
|
played = [(t.seat, parse_card(t.card)) for t in state.trick]
|
||||||
|
leader_suit = played[0][1].suit
|
||||||
|
trumps = [(seat, card) for seat, card in played if card.suit == state.briscola_suit]
|
||||||
|
pool = trumps or [
|
||||||
|
(seat, card) for seat, card in played if card.suit == leader_suit
|
||||||
|
]
|
||||||
|
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _draw(state: BriscolaState, winner: int) -> None:
|
||||||
|
"""Draw back up to ``HAND_SIZE`` cards, winner first, then turn order."""
|
||||||
|
for offset in range(state.player_count):
|
||||||
|
player = _player_at(state, (winner + offset) % state.player_count)
|
||||||
|
if state.stock:
|
||||||
|
player.hand.append(state.stock.pop())
|
||||||
|
elif state.briscola_card is not None:
|
||||||
|
# The face-up briscola is the last card of the stock: the first
|
||||||
|
# player who needs a card when only it remains takes it.
|
||||||
|
player.hand.append(state.briscola_card)
|
||||||
|
state.briscola_card = None
|
||||||
|
|
||||||
|
|
||||||
|
def auto_play(state: BriscolaState, rng: Optional[random.Random] = None) -> None:
|
||||||
|
"""Play a random card for the player currently on turn.
|
||||||
|
|
||||||
|
Every card in hand is legal, so one is drawn at random. Delegates to
|
||||||
|
:func:`play`, so the move is fully validated and can end the deal or
|
||||||
|
the match. Pass ``rng`` for deterministic tests.
|
||||||
|
"""
|
||||||
|
if state.phase != PHASE_PLAYING:
|
||||||
|
raise GameNotStarted("the game has not started yet")
|
||||||
|
player = _player_at(state, state.turn)
|
||||||
|
if not player.hand:
|
||||||
|
raise IllegalMove("the player on turn has no cards")
|
||||||
|
chooser = rng or _rng
|
||||||
|
card = chooser.choice(player.hand)
|
||||||
|
play(state, player.sub, card.code)
|
||||||
|
|
||||||
|
|
||||||
|
def _end_deal(state: BriscolaState) -> None:
|
||||||
|
"""Score the deal and either finish the match or pause for the summary.
|
||||||
|
|
||||||
|
A 60-60 deal is a patta: its points do not count and the deal is
|
||||||
|
replayed. If the match continues, the game pauses in the ``hand_end``
|
||||||
|
phase so every player can read the summary; the next deal is dealt by
|
||||||
|
:func:`acknowledge_hand` once all players have acknowledged (or by the
|
||||||
|
hand-end timeout fired by the platform's deadline scheduler).
|
||||||
|
"""
|
||||||
|
state.turn_deadline = None
|
||||||
|
points, details = deal_points(state)
|
||||||
|
patta = points[0] == points[1]
|
||||||
|
if not patta:
|
||||||
|
state.scores[0] += points[0]
|
||||||
|
state.scores[1] += points[1]
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"deal": state.hand_number,
|
||||||
|
"patta": patta,
|
||||||
|
"points": details["points"],
|
||||||
|
"cards": details["cards"],
|
||||||
|
"totals": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
}
|
||||||
|
state.hand_scores.append(entry)
|
||||||
|
|
||||||
|
a, b = state.scores
|
||||||
|
log.debug(
|
||||||
|
"deal %d scored A+%d B+%d%s (totals %d-%d)",
|
||||||
|
state.hand_number,
|
||||||
|
points[0],
|
||||||
|
points[1],
|
||||||
|
" patta" if patta else "",
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
)
|
||||||
|
if not patta and max(a, b) >= state.target_score and a != b:
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
state.winner = 0 if a > b else 1
|
||||||
|
log.info("match ended, team %s wins", TEAM_NAMES[state.winner])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pause for the deal summary instead of dealing immediately.
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.acked = []
|
||||||
|
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
|
||||||
|
state.hand_end_deadline = deadline.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_hand(state: BriscolaState, sub: str) -> None:
|
||||||
|
"""Record that ``sub`` has read the deal summary.
|
||||||
|
|
||||||
|
When every player has acknowledged, the next deal is dealt.
|
||||||
|
Acknowledging twice is a no-op; acknowledging outside the ``hand_end``
|
||||||
|
phase raises an error.
|
||||||
|
"""
|
||||||
|
if state.phase != PHASE_HAND_END:
|
||||||
|
raise IllegalMove("no deal summary is waiting for acknowledgement")
|
||||||
|
player = state.player_for(sub)
|
||||||
|
if player is None:
|
||||||
|
raise NotYourTurn("you are not seated in this game")
|
||||||
|
if player.seat in state.acked:
|
||||||
|
return
|
||||||
|
state.acked.append(player.seat)
|
||||||
|
if len(state.acked) < state.player_count:
|
||||||
|
return
|
||||||
|
|
||||||
|
state.hand_number += 1
|
||||||
|
state.dealer = (state.dealer + 1) % state.player_count
|
||||||
|
state.acked = []
|
||||||
|
state.hand_end_deadline = None
|
||||||
|
state.phase = PHASE_PLAYING
|
||||||
|
_deal(state)
|
||||||
|
|
||||||
|
|
||||||
|
def deal_points(state: BriscolaState) -> Tuple[List[int], Dict[str, object]]:
|
||||||
|
"""Compute the deal points for both teams (index 0 = team A)."""
|
||||||
|
piles: List[List[Card]] = [[], []]
|
||||||
|
for player in state.players:
|
||||||
|
piles[player.team].extend(player.captured)
|
||||||
|
points = [
|
||||||
|
sum(card_points(card) for card in pile) for pile in piles
|
||||||
|
]
|
||||||
|
details: Dict[str, object] = {
|
||||||
|
"points": {"A": points[0], "B": points[1]},
|
||||||
|
"cards": {"A": len(piles[0]), "B": len(piles[1])},
|
||||||
|
}
|
||||||
|
return points, details
|
||||||
|
|
||||||
|
|
||||||
|
def state_for_player(state: BriscolaState, sub: Optional[str]) -> Dict[str, object]:
|
||||||
|
"""Serialize ``state`` hiding other players' hands.
|
||||||
|
|
||||||
|
Hands are reduced to a count, except for the requesting player's own
|
||||||
|
hand. Non-seated viewers — including spectators, for whom ``sub`` is
|
||||||
|
``None`` — simply see no hand at all. The platform merges the session
|
||||||
|
envelope (``id``, ``join_code`` for seated players, ``game_type``,
|
||||||
|
``spectator``) into the view itself.
|
||||||
|
"""
|
||||||
|
viewer = state.player_for(sub)
|
||||||
|
players: List[Dict[str, object]] = []
|
||||||
|
for player in state.players:
|
||||||
|
view: Dict[str, object] = {
|
||||||
|
"sub": player.sub,
|
||||||
|
"name": player.name,
|
||||||
|
"seat": player.seat,
|
||||||
|
"team": TEAM_NAMES[player.team],
|
||||||
|
"cards_left": len(player.hand),
|
||||||
|
"captured_count": len(player.captured),
|
||||||
|
"points": sum(card_points(card) for card in player.captured),
|
||||||
|
}
|
||||||
|
if viewer is not None and viewer.seat == player.seat:
|
||||||
|
view["hand"] = [c.code for c in player.hand]
|
||||||
|
players.append(view)
|
||||||
|
|
||||||
|
payload: Dict[str, object] = {
|
||||||
|
"phase": state.phase,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"player_count": state.player_count,
|
||||||
|
"hand_number": state.hand_number,
|
||||||
|
"dealer": state.dealer,
|
||||||
|
"turn": state.turn,
|
||||||
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
|
||||||
|
"briscola_suit": state.briscola_suit,
|
||||||
|
"briscola_card": (
|
||||||
|
state.briscola_card.code if state.briscola_card is not None else None
|
||||||
|
),
|
||||||
|
"stock_count": len(state.stock),
|
||||||
|
"trick": [t.to_json() for t in state.trick],
|
||||||
|
"trick_leader": state.trick_leader,
|
||||||
|
"last_trick": state.last_trick.to_json() if state.last_trick else None,
|
||||||
|
"players": players,
|
||||||
|
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
|
||||||
|
"acknowledged": list(state.acked),
|
||||||
|
"hand_end_deadline": state.hand_end_deadline,
|
||||||
|
"turn_deadline": state.turn_deadline,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
viewer is not None
|
||||||
|
and state.phase == PHASE_PLAYING
|
||||||
|
and viewer.seat == state.turn
|
||||||
|
):
|
||||||
|
payload["your_turn"] = True
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Briscola errors.
|
||||||
|
|
||||||
|
Most failure modes are game-independent and live in
|
||||||
|
:mod:`tavolo.platform.errors`; only genuinely briscola-specific errors
|
||||||
|
are defined here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotFound,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AlreadyJoined",
|
||||||
|
"CardNotInHand",
|
||||||
|
"GameError",
|
||||||
|
"GameFinished",
|
||||||
|
"GameNotFound",
|
||||||
|
"GameNotStarted",
|
||||||
|
"IllegalMove",
|
||||||
|
"LobbyFull",
|
||||||
|
"NotYourTurn",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CardNotInHand(GameError):
|
||||||
|
"""The played card is not held by the player."""
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""The platform-facing adapter for briscola.
|
||||||
|
|
||||||
|
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
||||||
|
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
||||||
|
:class:`BriscolaEngine` and four-player fixed-partnership
|
||||||
|
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
||||||
|
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||||
|
keeping all briscola knowledge inside this package. Nothing outside
|
||||||
|
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, Mapping, Optional
|
||||||
|
|
||||||
|
from tavolo.platform.engine import (
|
||||||
|
Deadline,
|
||||||
|
GameEngine,
|
||||||
|
GameSession,
|
||||||
|
MatchResult,
|
||||||
|
PlayerResult,
|
||||||
|
Seat,
|
||||||
|
)
|
||||||
|
from tavolo.platform.errors import GameError, IllegalMove
|
||||||
|
|
||||||
|
from . import engine
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
DEFAULT_TARGET_SCORE,
|
||||||
|
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
MAX_TARGET_SCORE,
|
||||||
|
MIN_TARGET_SCORE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
TEAM_NAMES,
|
||||||
|
BriscolaState,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
||||||
|
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
||||||
|
or unparseable."""
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _BriscolaBase(GameEngine):
|
||||||
|
"""Shared platform adapter for the two briscola table sizes."""
|
||||||
|
|
||||||
|
#: 2 (classic) or 4 (fixed partnerships); set by the subclasses.
|
||||||
|
player_count: int
|
||||||
|
|
||||||
|
options_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target_score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": MIN_TARGET_SCORE,
|
||||||
|
"maximum": MAX_TARGET_SCORE,
|
||||||
|
"default": DEFAULT_TARGET_SCORE,
|
||||||
|
"description": "Match points the winning team must reach.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self._turn_timeout = turn_timeout_seconds
|
||||||
|
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||||
|
|
||||||
|
# -- lobby ------------------------------------------------------------
|
||||||
|
|
||||||
|
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||||
|
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
|
||||||
|
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
||||||
|
raise IllegalMove("target_score must be an integer")
|
||||||
|
creator = session.players[0]
|
||||||
|
session.state = engine.create_game(
|
||||||
|
creator_sub=creator.user_sub,
|
||||||
|
creator_name=creator.display_name,
|
||||||
|
player_count=self.player_count,
|
||||||
|
target_score=target_score,
|
||||||
|
hand_ack_timeout=self._hand_ack_timeout,
|
||||||
|
turn_timeout=self._turn_timeout,
|
||||||
|
)
|
||||||
|
# The creator takes seat 0, i.e. team A.
|
||||||
|
session.players[0] = Seat(
|
||||||
|
user_sub=creator.user_sub,
|
||||||
|
display_name=creator.display_name,
|
||||||
|
team=TEAM_NAMES[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||||
|
seat = len(session.players)
|
||||||
|
engine.join_game(session.state, user_sub, display_name)
|
||||||
|
# join_game raises before appending on any violation, so the seat
|
||||||
|
# list stays in sync with the engine's players.
|
||||||
|
session.players.append(
|
||||||
|
Seat(
|
||||||
|
user_sub=user_sub,
|
||||||
|
display_name=display_name,
|
||||||
|
team=TEAM_NAMES[seat % 2],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_lobby(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_LOBBY
|
||||||
|
|
||||||
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
return {
|
||||||
|
"phase": state.phase,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- play -------------------------------------------------------------
|
||||||
|
|
||||||
|
def handle_action(
|
||||||
|
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if action == "play":
|
||||||
|
card = payload.get("card")
|
||||||
|
if not isinstance(card, str):
|
||||||
|
raise IllegalMove("'card' must be a card code string")
|
||||||
|
try:
|
||||||
|
engine.play(state, user_sub, card)
|
||||||
|
except ValueError:
|
||||||
|
raise IllegalMove("invalid card code")
|
||||||
|
elif action == "ack":
|
||||||
|
engine.acknowledge_hand(state, user_sub)
|
||||||
|
else:
|
||||||
|
raise IllegalMove(f"unknown action: {action!r}")
|
||||||
|
|
||||||
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
return engine.state_for_player(session.state, user_sub)
|
||||||
|
|
||||||
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_FINISHED
|
||||||
|
|
||||||
|
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
return {
|
||||||
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- (de)serialization -------------------------------------------------
|
||||||
|
|
||||||
|
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||||
|
assert isinstance(state, BriscolaState)
|
||||||
|
return state.to_json()
|
||||||
|
|
||||||
|
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||||
|
return BriscolaState.from_json(dict(data))
|
||||||
|
|
||||||
|
# -- deadlines ----------------------------------------------------------
|
||||||
|
|
||||||
|
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||||
|
due_ms = _deadline_ms(state.turn_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="turn",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||||
|
)
|
||||||
|
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
||||||
|
due_ms = _deadline_ms(state.hand_end_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="hand_end",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"hand_end:{state.hand_number}:{due_ms}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||||
|
current = self.next_deadline(session)
|
||||||
|
if current is None or current.kind != kind or current.token != token:
|
||||||
|
# Overtaken by events (a play landed in time, the deal was
|
||||||
|
# acknowledged, the deadline moved): nothing to do.
|
||||||
|
raise GameError("stale deadline")
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if kind == "turn":
|
||||||
|
seat = state.turn
|
||||||
|
engine.auto_play(state)
|
||||||
|
log.info(
|
||||||
|
"auto-played for seat %s (turn timeout, deal %d)",
|
||||||
|
seat,
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
elif kind == "hand_end":
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
log.info(
|
||||||
|
"deal %d auto-advanced after the acknowledgement timeout",
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - next_deadline never emits other kinds
|
||||||
|
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||||
|
|
||||||
|
# -- results -------------------------------------------------------------
|
||||||
|
|
||||||
|
def result(self, session: GameSession) -> MatchResult:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if state.winner is None:
|
||||||
|
raise GameError("no result: the match is not finished")
|
||||||
|
teams = [
|
||||||
|
[p.sub for p in state.players if p.team == 0],
|
||||||
|
[p.sub for p in state.players if p.team == 1],
|
||||||
|
]
|
||||||
|
return MatchResult(
|
||||||
|
teams=teams,
|
||||||
|
winner_team=state.winner,
|
||||||
|
players=[
|
||||||
|
PlayerResult(
|
||||||
|
user_sub=player.sub,
|
||||||
|
seat=player.seat,
|
||||||
|
won=player.team == state.winner,
|
||||||
|
team=TEAM_NAMES[player.team],
|
||||||
|
score=float(state.scores[player.team]),
|
||||||
|
details={"captured_cards": len(player.captured)},
|
||||||
|
)
|
||||||
|
for player in state.players
|
||||||
|
],
|
||||||
|
summary={
|
||||||
|
"team_a_score": state.scores[0],
|
||||||
|
"team_b_score": state.scores[1],
|
||||||
|
"winner_team": TEAM_NAMES[state.winner],
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"deals_played": state.hand_number,
|
||||||
|
"deal_scores": state.hand_scores,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaEngine(_BriscolaBase):
|
||||||
|
"""Classic two-player briscola as a platform game engine."""
|
||||||
|
|
||||||
|
id = "briscola"
|
||||||
|
name = "Briscola"
|
||||||
|
description = (
|
||||||
|
"The classic two-player Italian trick-taking game: three cards "
|
||||||
|
"each, a trump suit and 120 points per deal. First to the target "
|
||||||
|
"score wins."
|
||||||
|
)
|
||||||
|
min_players = 2
|
||||||
|
max_players = 2
|
||||||
|
player_count = 2
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaQuattroEngine(_BriscolaBase):
|
||||||
|
"""Four-player fixed-partnership briscola as a platform game engine."""
|
||||||
|
|
||||||
|
id = "briscola_a_quattro"
|
||||||
|
name = "Briscola a quattro"
|
||||||
|
description = (
|
||||||
|
"Briscola played in two fixed partnerships of two, three cards "
|
||||||
|
"each and a trump suit. First team to the target score wins."
|
||||||
|
)
|
||||||
|
min_players = 4
|
||||||
|
max_players = 4
|
||||||
|
player_count = 4
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""In-memory representation of a briscola game.
|
||||||
|
|
||||||
|
The whole mutable game lives in :class:`BriscolaState`, which is
|
||||||
|
serialized to and from plain JSON for storage inside the platform's
|
||||||
|
session envelope (see :mod:`tavolo.platform.store`). Keeping the
|
||||||
|
representation JSON-native means the store needs no custom codecs and the
|
||||||
|
state is inspectable with ``redis-cli``.
|
||||||
|
|
||||||
|
The state contains only game data: the platform owns the session envelope
|
||||||
|
(id, join code, seats, timestamps, stats persistence) — see
|
||||||
|
:class:`tavolo.platform.engine.GameSession`.
|
||||||
|
|
||||||
|
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
|
||||||
|
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
|
||||||
|
A card is rendered as ``RRSUIT`` (e.g. ``07D``).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
SUITS = ("D", "C", "S", "B")
|
||||||
|
RANKS = tuple(range(1, 11))
|
||||||
|
|
||||||
|
# Teams are derived from the seat: seats 0 and 2 form team A (index 0),
|
||||||
|
# seats 1 and 3 form team B (index 1). In the two-player variant the teams
|
||||||
|
# are simply the two players; in briscola a quattro the partners sit
|
||||||
|
# opposite each other.
|
||||||
|
TEAM_A = 0
|
||||||
|
TEAM_B = 1
|
||||||
|
TEAM_NAMES = ("A", "B")
|
||||||
|
|
||||||
|
PHASE_LOBBY = "lobby"
|
||||||
|
PHASE_PLAYING = "playing"
|
||||||
|
# Between deals of an unfinished match: deal summary shown to every
|
||||||
|
# player; the next deal is dealt once all players acknowledge (or the
|
||||||
|
# hand-end timeout elapses).
|
||||||
|
PHASE_HAND_END = "hand_end"
|
||||||
|
PHASE_FINISHED = "finished"
|
||||||
|
|
||||||
|
# Supported table sizes: classic head-to-head and four-player partnerships.
|
||||||
|
PLAYER_COUNTS = (2, 4)
|
||||||
|
|
||||||
|
DEFAULT_TARGET_SCORE = 61
|
||||||
|
MIN_TARGET_SCORE = 61
|
||||||
|
MAX_TARGET_SCORE = 120
|
||||||
|
|
||||||
|
# Cards dealt to each player at the start of a deal.
|
||||||
|
HAND_SIZE = 3
|
||||||
|
|
||||||
|
# Default seconds the between-deals summary waits before dealing anyway.
|
||||||
|
# Games carry their own copy in ``BriscolaState.hand_ack_timeout``.
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
# Default seconds a player has to play before the server plays a random
|
||||||
|
# card for them. Games carry their own copy in
|
||||||
|
# ``BriscolaState.turn_timeout``.
|
||||||
|
DEFAULT_TURN_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def team_of(seat: int) -> int:
|
||||||
|
return seat % 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Card:
|
||||||
|
rank: int
|
||||||
|
suit: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.suit not in SUITS:
|
||||||
|
raise ValueError(f"invalid suit: {self.suit!r}")
|
||||||
|
if self.rank not in RANKS:
|
||||||
|
raise ValueError(f"invalid rank: {self.rank!r}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code(self) -> str:
|
||||||
|
return f"{self.rank:02d}{self.suit}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(code: str) -> "Card":
|
||||||
|
code = str(code).upper()
|
||||||
|
if len(code) != 3 or not code[:2].isdigit():
|
||||||
|
raise ValueError(f"invalid card code: {code!r}")
|
||||||
|
return Card(rank=int(code[:2]), suit=code[2])
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return self.code
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(value: Any) -> "Card":
|
||||||
|
return Card.parse(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_card(code: Any) -> Card:
|
||||||
|
"""Parse a card code, raising :class:`ValueError` on malformed input."""
|
||||||
|
return Card.parse(str(code))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrickCard:
|
||||||
|
"""One card played in the current trick, in play order."""
|
||||||
|
|
||||||
|
seat: int
|
||||||
|
card: str
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {"seat": self.seat, "card": self.card}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "TrickCard":
|
||||||
|
return TrickCard(seat=int(data["seat"]), card=str(data["card"]))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trick:
|
||||||
|
"""A completed trick: the winner's seat and the cards it captured."""
|
||||||
|
|
||||||
|
winner: int
|
||||||
|
cards: List[TrickCard] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"winner": self.winner,
|
||||||
|
"cards": [c.to_json() for c in self.cards],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "Trick":
|
||||||
|
return Trick(
|
||||||
|
winner=int(data["winner"]),
|
||||||
|
cards=[TrickCard.from_json(c) for c in data.get("cards", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayerState:
|
||||||
|
sub: str
|
||||||
|
name: str
|
||||||
|
seat: int
|
||||||
|
hand: List[Card] = field(default_factory=list)
|
||||||
|
captured: List[Card] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def team(self) -> int:
|
||||||
|
return team_of(self.seat)
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sub": self.sub,
|
||||||
|
"name": self.name,
|
||||||
|
"seat": self.seat,
|
||||||
|
"hand": [c.to_json() for c in self.hand],
|
||||||
|
"captured": [c.to_json() for c in self.captured],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "PlayerState":
|
||||||
|
return PlayerState(
|
||||||
|
sub=str(data["sub"]),
|
||||||
|
name=str(data["name"]),
|
||||||
|
seat=int(data["seat"]),
|
||||||
|
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
||||||
|
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BriscolaState:
|
||||||
|
player_count: int = 2
|
||||||
|
target_score: int = DEFAULT_TARGET_SCORE
|
||||||
|
phase: str = PHASE_LOBBY
|
||||||
|
players: List[PlayerState] = field(default_factory=list)
|
||||||
|
# Face-down draw pile; cards are drawn from the end.
|
||||||
|
stock: List[Card] = field(default_factory=list)
|
||||||
|
# The face-up card turned at deal time: its suit is the trump suit for
|
||||||
|
# the whole deal, and it is the last card drawn from the stock.
|
||||||
|
briscola_card: Optional[Card] = None
|
||||||
|
briscola_suit: str = ""
|
||||||
|
dealer: int = 0
|
||||||
|
turn: int = 0
|
||||||
|
# 1-based deal counter, used in the audit trail and deadlines.
|
||||||
|
hand_number: int = 1
|
||||||
|
# Cards of the trick in progress, in play order.
|
||||||
|
trick: List[TrickCard] = field(default_factory=list)
|
||||||
|
trick_leader: int = 0
|
||||||
|
# The most recently completed trick, for the table announcement.
|
||||||
|
last_trick: Optional[Trick] = None
|
||||||
|
# Match points accumulated across deals (patta deals do not count).
|
||||||
|
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||||
|
winner: Optional[int] = None
|
||||||
|
# Per-deal points awarded, for a compact audit trail in the API.
|
||||||
|
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
# While phase == "hand_end": seats that acknowledged the summary, and
|
||||||
|
# when the auto-continue timeout fires.
|
||||||
|
acked: List[int] = field(default_factory=list)
|
||||||
|
hand_end_deadline: Optional[str] = None
|
||||||
|
# Seconds the deal summary waits before dealing anyway.
|
||||||
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS
|
||||||
|
# While phase == "playing": when the server plays a random card for
|
||||||
|
# the player on turn.
|
||||||
|
turn_deadline: Optional[str] = None
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"player_count": self.player_count,
|
||||||
|
"target_score": self.target_score,
|
||||||
|
"phase": self.phase,
|
||||||
|
"players": [p.to_json() for p in self.players],
|
||||||
|
"stock": [c.to_json() for c in self.stock],
|
||||||
|
"briscola_card": (
|
||||||
|
self.briscola_card.to_json() if self.briscola_card else None
|
||||||
|
),
|
||||||
|
"briscola_suit": self.briscola_suit,
|
||||||
|
"dealer": self.dealer,
|
||||||
|
"turn": self.turn,
|
||||||
|
"hand_number": self.hand_number,
|
||||||
|
"trick": [t.to_json() for t in self.trick],
|
||||||
|
"trick_leader": self.trick_leader,
|
||||||
|
"last_trick": self.last_trick.to_json() if self.last_trick else None,
|
||||||
|
"scores": list(self.scores),
|
||||||
|
"winner": self.winner,
|
||||||
|
"hand_scores": list(self.hand_scores),
|
||||||
|
"acked": list(self.acked),
|
||||||
|
"hand_end_deadline": self.hand_end_deadline,
|
||||||
|
"hand_ack_timeout": self.hand_ack_timeout,
|
||||||
|
"turn_deadline": self.turn_deadline,
|
||||||
|
"turn_timeout": self.turn_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "BriscolaState":
|
||||||
|
return BriscolaState(
|
||||||
|
player_count=int(data.get("player_count", 2)),
|
||||||
|
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||||
|
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||||
|
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||||
|
stock=[Card.from_json(c) for c in data.get("stock", [])],
|
||||||
|
briscola_card=(
|
||||||
|
Card.from_json(data["briscola_card"])
|
||||||
|
if data.get("briscola_card")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
briscola_suit=str(data.get("briscola_suit", "")),
|
||||||
|
dealer=int(data.get("dealer", 0)),
|
||||||
|
turn=int(data.get("turn", 0)),
|
||||||
|
hand_number=int(data.get("hand_number", 1)),
|
||||||
|
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
|
||||||
|
trick_leader=int(data.get("trick_leader", 0)),
|
||||||
|
last_trick=(
|
||||||
|
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
|
||||||
|
),
|
||||||
|
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||||
|
winner=data.get("winner"),
|
||||||
|
hand_scores=list(data.get("hand_scores", [])),
|
||||||
|
acked=[int(s) for s in data.get("acked", [])],
|
||||||
|
hand_end_deadline=data.get("hand_end_deadline"),
|
||||||
|
hand_ack_timeout=int(
|
||||||
|
data.get("hand_ack_timeout", DEFAULT_HAND_ACK_TIMEOUT_SECONDS)
|
||||||
|
),
|
||||||
|
turn_deadline=data.get("turn_deadline"),
|
||||||
|
turn_timeout=int(
|
||||||
|
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
|
||||||
|
for player in self.players:
|
||||||
|
if player.sub == sub:
|
||||||
|
return player
|
||||||
|
return None
|
||||||
|
|
||||||
|
def seated(self, sub: str) -> bool:
|
||||||
|
return self.player_for(sub) is not None
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from tavolo.briscola import engine
|
||||||
|
from tavolo.briscola.errors import (
|
||||||
|
CardNotInHand,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
from tavolo.briscola.state import (
|
||||||
|
HAND_SIZE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
BriscolaState,
|
||||||
|
Card,
|
||||||
|
PlayerState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def card(code: str) -> Card:
|
||||||
|
return Card.parse(code)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
hands,
|
||||||
|
*,
|
||||||
|
briscola_suit: str = "S",
|
||||||
|
briscola_card: str | None = None,
|
||||||
|
stock=None,
|
||||||
|
captured=None,
|
||||||
|
turn: int = 0,
|
||||||
|
dealer: int = 0,
|
||||||
|
target: int = 61,
|
||||||
|
scores=None,
|
||||||
|
phase: str = PHASE_PLAYING,
|
||||||
|
) -> BriscolaState:
|
||||||
|
"""Build a controlled game state directly (bypassing the deal)."""
|
||||||
|
state = BriscolaState(
|
||||||
|
player_count=len(hands),
|
||||||
|
target_score=target,
|
||||||
|
phase=phase,
|
||||||
|
dealer=dealer,
|
||||||
|
turn=turn,
|
||||||
|
briscola_suit=briscola_suit,
|
||||||
|
briscola_card=card(briscola_card) if briscola_card else None,
|
||||||
|
stock=[card(c) for c in (stock or [])],
|
||||||
|
scores=list(scores or [0, 0]),
|
||||||
|
)
|
||||||
|
for seat, hand in enumerate(hands):
|
||||||
|
state.players.append(
|
||||||
|
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||||
|
hand=[card(c) for c in hand])
|
||||||
|
)
|
||||||
|
if captured is not None:
|
||||||
|
for player, codes in zip(state.players, captured):
|
||||||
|
player.captured = [card(c) for c in codes]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def trick_cards(state: BriscolaState) -> list[str]:
|
||||||
|
return [t.card for t in state.trick]
|
||||||
|
|
||||||
|
|
||||||
|
def play_all(state: BriscolaState) -> None:
|
||||||
|
"""Play every card of the current trick in turn order."""
|
||||||
|
for _ in range(state.player_count):
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.play(state, player.sub, player.hand[0].code)
|
||||||
|
|
||||||
|
|
||||||
|
class DeckTest(unittest.TestCase):
|
||||||
|
def test_full_deck_has_40_unique_cards(self) -> None:
|
||||||
|
deck = engine.full_deck()
|
||||||
|
self.assertEqual(40, len(deck))
|
||||||
|
self.assertEqual(40, len({c.code for c in deck}))
|
||||||
|
self.assertEqual(4, len({c.suit for c in deck}))
|
||||||
|
|
||||||
|
def test_shuffled_deck_is_permutation(self) -> None:
|
||||||
|
deck = engine.shuffled_deck()
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(c.code for c in engine.full_deck()),
|
||||||
|
sorted(c.code for c in deck),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_card_points_add_up_to_120(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
120, sum(engine.card_points(c) for c in engine.full_deck())
|
||||||
|
)
|
||||||
|
self.assertEqual(11, engine.card_points(card("01D")))
|
||||||
|
self.assertEqual(10, engine.card_points(card("03C")))
|
||||||
|
self.assertEqual(4, engine.card_points(card("10S")))
|
||||||
|
self.assertEqual(3, engine.card_points(card("09B")))
|
||||||
|
self.assertEqual(2, engine.card_points(card("08D")))
|
||||||
|
self.assertEqual(0, engine.card_points(card("07C")))
|
||||||
|
|
||||||
|
|
||||||
|
class DealTest(unittest.TestCase):
|
||||||
|
def test_two_player_deal(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
# 40 - 3*2 - 1 face-up = 33 face-down.
|
||||||
|
self.assertEqual(33, len(state.stock))
|
||||||
|
self.assertIsNotNone(state.briscola_card)
|
||||||
|
assert state.briscola_card is not None
|
||||||
|
self.assertEqual(state.briscola_card.suit, state.briscola_suit)
|
||||||
|
self.assertEqual(1, state.turn) # dealer is seat 0
|
||||||
|
self.assertEqual([], state.trick)
|
||||||
|
|
||||||
|
def test_four_player_deal(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
self.assertEqual(40 - 3 * 4 - 1, len(state.stock))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
|
||||||
|
def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
dealt = [c.code for p in state.players for c in p.hand]
|
||||||
|
assert state.briscola_card is not None
|
||||||
|
dealt.append(state.briscola_card.code)
|
||||||
|
dealt.extend(c.code for c in state.stock)
|
||||||
|
self.assertEqual(40, len(dealt))
|
||||||
|
self.assertEqual(40, len(set(dealt)))
|
||||||
|
|
||||||
|
def test_unsupported_player_count(self) -> None:
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", player_count=3)
|
||||||
|
|
||||||
|
def test_target_score_bounds(self) -> None:
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", target_score=60)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", target_score=121)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", target_score=True)
|
||||||
|
state = engine.create_game("p0", "p0", target_score=101)
|
||||||
|
self.assertEqual(101, state.target_score)
|
||||||
|
|
||||||
|
|
||||||
|
class TrickTest(unittest.TestCase):
|
||||||
|
def test_higher_card_of_led_suit_wins(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
self.assertEqual(["02D", "05D"],
|
||||||
|
[c.code for c in state.players[1].captured])
|
||||||
|
|
||||||
|
def test_trump_beats_a_higher_led_card(self) -> None:
|
||||||
|
state = make_state([["07D"], ["02C"]], briscola_suit="C")
|
||||||
|
engine.play(state, "p0", "07D")
|
||||||
|
engine.play(state, "p1", "02C")
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_higher_trump_wins_among_trumps(self) -> None:
|
||||||
|
state = make_state([["02C"], ["05C"]], briscola_suit="C")
|
||||||
|
engine.play(state, "p0", "02C")
|
||||||
|
engine.play(state, "p1", "05C")
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_off_suit_non_trump_loses(self) -> None:
|
||||||
|
state = make_state([["04D"], ["01C"]], briscola_suit="B")
|
||||||
|
engine.play(state, "p0", "04D")
|
||||||
|
engine.play(state, "p1", "01C")
|
||||||
|
self.assertEqual(0, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_rank_strength_order(self) -> None:
|
||||||
|
# (winner card, loser card) pairs of the led suit, strongest first:
|
||||||
|
# ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
|
||||||
|
pairs = [
|
||||||
|
("01D", "03D"), # ace beats three
|
||||||
|
("03D", "10D"), # three beats king
|
||||||
|
("10D", "09D"), # king beats horse
|
||||||
|
("09D", "08D"), # horse beats jack
|
||||||
|
("08D", "07D"), # jack beats seven
|
||||||
|
("07D", "06D"),
|
||||||
|
("06D", "05D"),
|
||||||
|
("05D", "04D"),
|
||||||
|
("04D", "02D"),
|
||||||
|
]
|
||||||
|
for winner, loser in pairs:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="B")
|
||||||
|
state.players[0].hand = [card(winner)]
|
||||||
|
state.players[1].hand = [card(loser)]
|
||||||
|
engine.play(state, "p0", winner)
|
||||||
|
engine.play(state, "p1", loser)
|
||||||
|
self.assertEqual(
|
||||||
|
0, state.last_trick.winner, f"{winner} should beat {loser}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_four_player_trick(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
|
||||||
|
)
|
||||||
|
for seat in range(4):
|
||||||
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
||||||
|
self.assertEqual(3, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_four_player_trump_beats_all(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C"
|
||||||
|
)
|
||||||
|
for seat in range(4):
|
||||||
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_winner_leads_the_next_trick(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=["01B", "02B", "03B", "04B"],
|
||||||
|
)
|
||||||
|
for seat in range(4):
|
||||||
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
||||||
|
self.assertEqual(3, state.turn)
|
||||||
|
self.assertEqual(3, state.trick_leader)
|
||||||
|
self.assertEqual([], state.trick)
|
||||||
|
|
||||||
|
def test_turn_advances_within_a_trick(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
self.assertEqual(["02D"], trick_cards(state))
|
||||||
|
|
||||||
|
def test_not_your_turn(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1)
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
def test_card_not_in_hand(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
with self.assertRaises(CardNotInHand):
|
||||||
|
engine.play(state, "p0", "07D")
|
||||||
|
|
||||||
|
def test_finished_and_hand_end_reject_plays(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
|
||||||
|
class DrawTest(unittest.TestCase):
|
||||||
|
def test_winner_draws_first(self) -> None:
|
||||||
|
# The stock is drawn from its end: 04B first, then 03B, ...
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "09C"], ["05D", "09B"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=["01B", "02B", "03B", "04B"],
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
|
||||||
|
self.assertEqual(["09C", "03B"], hands[0])
|
||||||
|
self.assertEqual(["09B", "04B"], hands[1])
|
||||||
|
|
||||||
|
def test_face_up_briscola_is_drawn_last(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "09C"], ["05D", "09B"]],
|
||||||
|
briscola_suit="C",
|
||||||
|
briscola_card="05C",
|
||||||
|
stock=["03B"],
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
|
||||||
|
# The winner draws first and takes the last face-down card; the
|
||||||
|
# other player then takes the face-up briscola.
|
||||||
|
self.assertEqual(["09B", "03B"], hands[1])
|
||||||
|
self.assertEqual(["09C", "05C"], hands[0])
|
||||||
|
self.assertIsNone(state.briscola_card)
|
||||||
|
|
||||||
|
def test_no_draw_when_stock_is_empty(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[]
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
# p1 wins the trick; with no stock nobody draws and the hands
|
||||||
|
# shrink for the rest of the deal.
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(1, len(player.hand))
|
||||||
|
|
||||||
|
def test_hands_stay_at_three_until_stock_runs_out(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
while state.stock or state.briscola_card is not None:
|
||||||
|
play_all(state)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
self.assertEqual(0, len(state.stock))
|
||||||
|
self.assertIsNone(state.briscola_card)
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringTest(unittest.TestCase):
|
||||||
|
def test_deal_points_group_by_team(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], []],
|
||||||
|
briscola_suit="S",
|
||||||
|
captured=[
|
||||||
|
["01D", "07C"], # seat 0, team A: 11
|
||||||
|
["03C", "08D"], # seat 1, team B: 10
|
||||||
|
["10S"], # seat 2, team A: 4
|
||||||
|
["09B"], # seat 3, team B: 3
|
||||||
|
],
|
||||||
|
)
|
||||||
|
points, details = engine.deal_points(state)
|
||||||
|
self.assertEqual([15, 15], points)
|
||||||
|
self.assertEqual({"A": 15, "B": 15}, details["points"])
|
||||||
|
self.assertEqual({"A": 3, "B": 3}, details["cards"])
|
||||||
|
|
||||||
|
def test_full_deal_scores_120(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
while state.phase == PHASE_PLAYING:
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.play(state, player.sub, player.hand[0].code)
|
||||||
|
points, _ = engine.deal_points(state)
|
||||||
|
self.assertEqual(120, sum(points))
|
||||||
|
|
||||||
|
def test_match_ends_when_target_reached(self) -> None:
|
||||||
|
# p0 captures the ace of denari (11) on the last trick: 60+11 >= 61.
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], ["10C"]],
|
||||||
|
scores=[60, 50],
|
||||||
|
target=61,
|
||||||
|
)
|
||||||
|
# p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a
|
||||||
|
# higher card: 02D vs 05D -> p1 would win, so give p0 the five.
|
||||||
|
state.players[0].hand = [card("05D")]
|
||||||
|
state.players[1].hand = [card("02D")]
|
||||||
|
engine.play(state, "p0", "05D")
|
||||||
|
engine.play(state, "p1", "02D")
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertEqual(0, state.winner)
|
||||||
|
self.assertEqual([71, 54], state.scores)
|
||||||
|
|
||||||
|
def test_tie_at_target_is_broken_by_another_deal(self) -> None:
|
||||||
|
# 65-55 before the deal; this deal gives A 55 and B 65: both reach
|
||||||
|
# 120 and tie, so another deal must be played.
|
||||||
|
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"]
|
||||||
|
b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [
|
||||||
|
f"{rank:02d}S" for rank in range(1, 11)
|
||||||
|
] + [f"{rank:02d}B" for rank in range(1, 11)]
|
||||||
|
state = make_state(
|
||||||
|
[[], []],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[a_cards, b_cards],
|
||||||
|
scores=[65, 55],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
self.assertEqual([120, 120], state.scores)
|
||||||
|
self.assertIsNone(state.winner)
|
||||||
|
|
||||||
|
def test_patta_is_discarded_and_replayed(self) -> None:
|
||||||
|
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [
|
||||||
|
f"{rank:02d}C" for rank in range(1, 11)
|
||||||
|
]
|
||||||
|
b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [
|
||||||
|
f"{rank:02d}B" for rank in range(1, 11)
|
||||||
|
]
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["02S"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[a_cards, b_cards],
|
||||||
|
scores=[10, 20],
|
||||||
|
target=61,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
# The 60-60 deal does not count: totals are unchanged.
|
||||||
|
self.assertEqual([10, 20], state.scores)
|
||||||
|
entry = state.hand_scores[-1]
|
||||||
|
self.assertTrue(entry["patta"])
|
||||||
|
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
||||||
|
# The next deal is played at the same cumulative totals.
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
engine.acknowledge_hand(state, "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
self.assertEqual([10, 20], state.scores)
|
||||||
|
|
||||||
|
def test_audit_trail_records_each_deal(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
scores=[0, 0],
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
entry = state.hand_scores[-1]
|
||||||
|
self.assertEqual(1, entry["deal"])
|
||||||
|
self.assertFalse(entry["patta"])
|
||||||
|
self.assertEqual({"A": 11, "B": 0}, entry["points"])
|
||||||
|
self.assertEqual({"A": 11, "B": 0}, entry["totals"])
|
||||||
|
self.assertEqual({"A": 1, "B": 0}, entry["cards"])
|
||||||
|
|
||||||
|
|
||||||
|
class MatchFlowTest(unittest.TestCase):
|
||||||
|
def test_join_starts_when_full(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
self.assertEqual(1, len(state.players))
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 3):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
self.assertEqual("lobby", state.phase)
|
||||||
|
engine.join_game(state, "p3", "p3")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
|
||||||
|
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join_game(state, "p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join_game(state, "p2", "p2")
|
||||||
|
|
||||||
|
def test_full_random_two_player_match(self) -> None:
|
||||||
|
self._random_match(player_count=2)
|
||||||
|
|
||||||
|
def test_full_random_four_player_match(self) -> None:
|
||||||
|
self._random_match(player_count=4)
|
||||||
|
|
||||||
|
def _random_match(self, player_count: int) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=player_count)
|
||||||
|
for i in range(1, player_count):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
rng = random.Random(2026)
|
||||||
|
moves = 0
|
||||||
|
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.play(state, player.sub, rng.choice(player.hand).code)
|
||||||
|
moves += 1
|
||||||
|
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertIn(state.winner, (0, 1))
|
||||||
|
self.assertEqual([], state.trick)
|
||||||
|
self.assertTrue(all(not p.hand for p in state.players))
|
||||||
|
self.assertEqual(
|
||||||
|
40, sum(len(p.captured) for p in state.players)
|
||||||
|
)
|
||||||
|
# Every counted deal totals 120 points, and the winner reached the
|
||||||
|
# target with a clear lead.
|
||||||
|
winner_score = state.scores[state.winner]
|
||||||
|
loser_score = state.scores[1 - state.winner]
|
||||||
|
self.assertGreaterEqual(winner_score, state.target_score)
|
||||||
|
self.assertGreater(winner_score, loser_score)
|
||||||
|
self.assertEqual(0, (winner_score + loser_score) % 120)
|
||||||
|
|
||||||
|
|
||||||
|
class AckTest(unittest.TestCase):
|
||||||
|
def _hand_end_state(self) -> BriscolaState:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def test_ack_all_deals_the_next_deal(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
dealer_before = state.dealer
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
self.assertEqual([0], state.acked)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
engine.acknowledge_hand(state, "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
self.assertEqual((dealer_before + 1) % 2, state.dealer)
|
||||||
|
self.assertEqual((dealer_before + 2) % 2, state.turn)
|
||||||
|
self.assertEqual([], state.acked)
|
||||||
|
self.assertIsNone(state.hand_end_deadline)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
self.assertEqual([], player.captured)
|
||||||
|
|
||||||
|
def test_double_ack_is_idempotent(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
self.assertEqual([0], state.acked)
|
||||||
|
|
||||||
|
def test_ack_outside_hand_end_is_rejected(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
|
||||||
|
def test_ack_by_non_player_is_rejected(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
engine.acknowledge_hand(state, "mallory")
|
||||||
|
|
||||||
|
|
||||||
|
class AutoPlayTest(unittest.TestCase):
|
||||||
|
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=["01B", "02B"],
|
||||||
|
briscola_card="03B",
|
||||||
|
)
|
||||||
|
engine.auto_play(state, random.Random(7))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
self.assertEqual(1, len(state.players[0].hand))
|
||||||
|
self.assertEqual(1, len(state.trick))
|
||||||
|
|
||||||
|
def test_auto_play_can_end_the_deal(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
# Both players are on their last card: two auto-plays complete the
|
||||||
|
# trick and the deal, scored A=11 / B=0.
|
||||||
|
engine.auto_play(state)
|
||||||
|
engine.auto_play(state)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
self.assertIsNone(state.turn_deadline)
|
||||||
|
self.assertTrue(state.hand_end_deadline)
|
||||||
|
|
||||||
|
def test_auto_play_requires_playing_phase(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.auto_play(state)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_state_for_player_hides_other_hands(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
briscola_card="07D",
|
||||||
|
stock=["01B", "02B"],
|
||||||
|
)
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertEqual(["02D", "03C"], players[0]["hand"])
|
||||||
|
self.assertNotIn("hand", players[1])
|
||||||
|
self.assertEqual(1, players[1]["cards_left"])
|
||||||
|
self.assertEqual("D", view["briscola_suit"])
|
||||||
|
self.assertEqual("07D", view["briscola_card"])
|
||||||
|
self.assertEqual(2, view["stock_count"])
|
||||||
|
self.assertTrue(view.get("your_turn"))
|
||||||
|
|
||||||
|
def test_spectator_sees_public_info_only(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["04D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
briscola_card="07D",
|
||||||
|
)
|
||||||
|
view = engine.state_for_player(state, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertEqual("07D", view["briscola_card"])
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
|
||||||
|
def test_player_points_are_public(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["04D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
captured=[["01C", "03B"], ["10D"]],
|
||||||
|
)
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertEqual(21, players[0]["points"])
|
||||||
|
self.assertEqual(4, players[1]["points"])
|
||||||
|
self.assertEqual(2, players[0]["captured_count"])
|
||||||
|
|
||||||
|
def test_hand_end_view_exposes_the_deal_summary(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
self.assertEqual(PHASE_HAND_END, view["phase"])
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"deal": 1,
|
||||||
|
"patta": False,
|
||||||
|
"points": {"A": 11, "B": 0},
|
||||||
|
"cards": {"A": 1, "B": 0},
|
||||||
|
"totals": {"A": 11, "B": 0},
|
||||||
|
},
|
||||||
|
view["last_deal"],
|
||||||
|
)
|
||||||
|
self.assertTrue(view["hand_end_deadline"])
|
||||||
|
|
||||||
|
def test_trick_is_public_in_play_order(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
view = engine.state_for_player(state, None)
|
||||||
|
self.assertEqual(
|
||||||
|
[{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}],
|
||||||
|
view["trick"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4, target_score=101)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
engine.play(state, state.players[1].sub, state.players[1].hand[0].code)
|
||||||
|
restored = BriscolaState.from_json(state.to_json())
|
||||||
|
self.assertEqual(state.phase, restored.phase)
|
||||||
|
self.assertEqual(state.turn, restored.turn)
|
||||||
|
self.assertEqual(state.hand_number, restored.hand_number)
|
||||||
|
self.assertEqual(state.target_score, restored.target_score)
|
||||||
|
self.assertEqual(state.briscola_suit, restored.briscola_suit)
|
||||||
|
self.assertEqual(
|
||||||
|
state.briscola_card.code if state.briscola_card else None,
|
||||||
|
restored.briscola_card.code if restored.briscola_card else None,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[c.code for c in state.stock], [c.code for c in restored.stock]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[(t.seat, t.card) for t in state.trick],
|
||||||
|
[(t.seat, t.card) for t in restored.trick],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in state.players], [p.sub for p in restored.players]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[[c.code for c in p.hand] for p in state.players],
|
||||||
|
[[c.code for c in p.hand] for p in restored.players],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_state_defaults(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
data = state.to_json()
|
||||||
|
del data["player_count"]
|
||||||
|
del data["briscola_card"]
|
||||||
|
del data["last_trick"]
|
||||||
|
restored = BriscolaState.from_json(data)
|
||||||
|
self.assertEqual(2, restored.player_count)
|
||||||
|
self.assertIsNone(restored.briscola_card)
|
||||||
|
self.assertIsNone(restored.last_trick)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Adapter tests: the platform contract over the pure briscola rules."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
)
|
||||||
|
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||||
|
from tavolo.briscola import engine as rules
|
||||||
|
from tavolo.briscola.state import (
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
BriscolaState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(engine, **options) -> GameSession:
|
||||||
|
session = GameSession(
|
||||||
|
id="b1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="CODE01",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||||
|
)
|
||||||
|
engine.create(session, options)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _started(engine, **options) -> GameSession:
|
||||||
|
session = _session(engine, **options)
|
||||||
|
for i in range(1, engine.max_players):
|
||||||
|
name = f"p{i}"
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < limit:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
for player in state.players:
|
||||||
|
engine.handle_action(session, player.sub, "ack", {})
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.handle_action(
|
||||||
|
session, player.sub, "play", {"card": player.hand[0].code}
|
||||||
|
)
|
||||||
|
moves += 1
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataTest(unittest.TestCase):
|
||||||
|
def test_two_player_metadata(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
self.assertEqual("briscola", engine.id)
|
||||||
|
self.assertEqual("Briscola", engine.name)
|
||||||
|
self.assertTrue(engine.description)
|
||||||
|
self.assertEqual(2, engine.min_players)
|
||||||
|
self.assertEqual(2, engine.max_players)
|
||||||
|
target = engine.options_schema["properties"]["target_score"]
|
||||||
|
self.assertEqual("integer", target["type"])
|
||||||
|
self.assertEqual(61, target["minimum"])
|
||||||
|
self.assertEqual(120, target["maximum"])
|
||||||
|
self.assertEqual(61, target["default"])
|
||||||
|
|
||||||
|
def test_four_player_metadata(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
self.assertEqual("briscola_a_quattro", engine.id)
|
||||||
|
self.assertEqual("Briscola a quattro", engine.name)
|
||||||
|
self.assertTrue(engine.description)
|
||||||
|
self.assertEqual(4, engine.min_players)
|
||||||
|
self.assertEqual(4, engine.max_players)
|
||||||
|
self.assertIn("target_score", engine.options_schema["properties"])
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTest(unittest.TestCase):
|
||||||
|
def test_create_seats_creator_on_team_a(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual("A", session.players[0].team)
|
||||||
|
self.assertIsInstance(session.state, BriscolaState)
|
||||||
|
self.assertEqual(61, session.state.target_score)
|
||||||
|
self.assertEqual(2, session.state.player_count)
|
||||||
|
|
||||||
|
def test_create_options(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _session(engine, target_score=101)
|
||||||
|
self.assertEqual(101, session.state.target_score)
|
||||||
|
self.assertEqual(4, session.state.player_count)
|
||||||
|
|
||||||
|
def test_create_rejects_bad_options(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=60)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=121)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score="sixtyone")
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=True)
|
||||||
|
|
||||||
|
def test_timeouts_come_from_the_engine(self) -> None:
|
||||||
|
engine = BriscolaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual(7, session.state.turn_timeout)
|
||||||
|
self.assertEqual(9, session.state.hand_ack_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
class JoinTest(unittest.TestCase):
|
||||||
|
def test_two_player_join_starts(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertTrue(engine.in_lobby(session))
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
self.assertEqual("B", session.players[-1].team)
|
||||||
|
self.assertFalse(engine.in_lobby(session))
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
|
||||||
|
def test_four_player_teams_alternate(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
teams = []
|
||||||
|
for name in ("bob", "carol", "dave"):
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
teams.append(session.players[-1].team)
|
||||||
|
self.assertEqual(["B", "A", "B"], teams)
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join(session, "alice", "Alice")
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join(session, "carol", "Carol")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionTest(unittest.TestCase):
|
||||||
|
def test_unknown_action_rejected(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "alice", "dance", {})
|
||||||
|
|
||||||
|
def test_play_validates_payload(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": 42})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": "nope"})
|
||||||
|
|
||||||
|
def test_play_advances_the_trick(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
card = state.players[1].hand[0].code
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": card})
|
||||||
|
self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"])
|
||||||
|
|
||||||
|
def test_play_out_of_turn_rejected(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
card = state.players[0].hand[0].code
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.handle_action(session, "alice", "play", {"card": card})
|
||||||
|
|
||||||
|
def test_ack_outside_hand_end_rejected(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "alice", "ack", {})
|
||||||
|
|
||||||
|
def test_finished_match_rejects_actions(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
self.assertTrue(engine.is_finished(session))
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
engine.handle_action(session, "alice", "play", {"card": "01D"})
|
||||||
|
|
||||||
|
def test_ack_all_deals_next(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine, target_score=120)
|
||||||
|
state = session.state
|
||||||
|
# Force the deal-end phase: both players keep 3 cards, so the
|
||||||
|
# match continues after the summary.
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.acked = []
|
||||||
|
state.hand_end_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||||
|
).isoformat()
|
||||||
|
engine.handle_action(session, "alice", "ack", {})
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
engine.handle_action(session, "p1", "ack", {})
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_view_for_hides_other_hands(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, "alice")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertIn("hand", players[0])
|
||||||
|
self.assertNotIn("hand", players[1])
|
||||||
|
# The envelope is the platform's job, not the view's.
|
||||||
|
self.assertNotIn("id", view)
|
||||||
|
self.assertNotIn("join_code", view)
|
||||||
|
self.assertNotIn("game_type", view)
|
||||||
|
self.assertIn("briscola_suit", view)
|
||||||
|
self.assertIn("stock_count", view)
|
||||||
|
|
||||||
|
def test_view_for_spectator_hides_all_hands(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
|
||||||
|
def test_lobby_view(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine, target_score=101)
|
||||||
|
lobby = engine.lobby_view(session)
|
||||||
|
self.assertEqual("lobby", lobby["phase"])
|
||||||
|
self.assertEqual(101, lobby["target_score"])
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||||
|
self.assertIsInstance(restored, BriscolaState)
|
||||||
|
self.assertEqual(session.state.phase, restored.phase)
|
||||||
|
self.assertEqual(session.state.turn, restored.turn)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in session.state.players],
|
||||||
|
[p.sub for p in restored.players],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineTest(unittest.TestCase):
|
||||||
|
def test_no_deadline_in_lobby(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||||
|
|
||||||
|
def test_turn_deadline_and_revalidation(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("turn", deadline.kind)
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, "turn", "turn:1:1:0")
|
||||||
|
turn_before = session.state.turn
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertNotEqual(turn_before, session.state.turn)
|
||||||
|
|
||||||
|
def test_stale_deadline_after_play(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
state = session.state
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code})
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
|
||||||
|
def test_hand_end_deadline_advances(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine, target_score=120)
|
||||||
|
state = session.state
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.hand_end_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||||
|
).isoformat()
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("hand_end", deadline.kind)
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
self.assertEqual(2, session.state.hand_number)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultTest(unittest.TestCase):
|
||||||
|
def test_result_of_finished_match(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual(2, len(result.teams))
|
||||||
|
self.assertEqual([2, 2], [len(team) for team in result.teams])
|
||||||
|
self.assertIn(result.winner_team, (0, 1))
|
||||||
|
self.assertEqual(4, len(result.players))
|
||||||
|
winner_name = "A" if result.winner_team == 0 else "B"
|
||||||
|
for player in result.players:
|
||||||
|
self.assertEqual(player.won, player.team == winner_name)
|
||||||
|
self.assertIn("captured_cards", player.details)
|
||||||
|
self.assertIn("team_a_score", result.summary)
|
||||||
|
self.assertIn("team_b_score", result.summary)
|
||||||
|
self.assertIn("winner_team", result.summary)
|
||||||
|
self.assertIn("target_score", result.summary)
|
||||||
|
self.assertIn("deals_played", result.summary)
|
||||||
|
self.assertIn("deal_scores", result.summary)
|
||||||
|
|
||||||
|
def test_two_player_result(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams)
|
||||||
|
self.assertEqual(2, len(result.players))
|
||||||
|
|
||||||
|
def test_result_requires_finished_match(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.result(_started(engine))
|
||||||
|
|
||||||
|
def test_game_over_view(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
session.state.phase = PHASE_FINISHED
|
||||||
|
session.state.winner = 1
|
||||||
|
session.state.scores = [55, 65]
|
||||||
|
over = engine.game_over_view(session, "alice")
|
||||||
|
self.assertEqual({"A": 55, "B": 65}, over["scores"])
|
||||||
|
self.assertEqual("B", over["winner"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -48,8 +48,8 @@ itself ships no game.
|
|||||||
Implement `GameEngine` (see `engine.py` for the full contract), register
|
Implement `GameEngine` (see `engine.py` for the full contract), register
|
||||||
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
||||||
`KayaApp` — see the composition root in `tavolo.app`. The canonical
|
`KayaApp` — see the composition root in `tavolo.app`. The canonical
|
||||||
example is
|
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
|
||||||
[`tavolo-scopone`](../tavolo-scopone/README.md).
|
[`tavolo-briscola`](../tavolo-briscola/README.md).
|
||||||
|
|
||||||
## Development (from `server/`)
|
## Development (from `server/`)
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,13 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
|||||||
|
|
||||||
|
|
||||||
def display_name(user: OIDCUser) -> str:
|
def display_name(user: OIDCUser) -> str:
|
||||||
"""Best-effort human-readable name for a user."""
|
"""Best-effort human-readable name for a user.
|
||||||
for key in ("name", "preferred_username", "email"):
|
|
||||||
|
``preferred_username`` is preferred: it is the account's stable handle,
|
||||||
|
while ``name`` is a free-form display name a user may change (and two
|
||||||
|
accounts can share). ``email`` is a last resort before the ``sub``.
|
||||||
|
"""
|
||||||
|
for key in ("preferred_username", "name", "email"):
|
||||||
value = user.get(key)
|
value = user.get(key)
|
||||||
if isinstance(value, str) and value:
|
if isinstance(value, str) and value:
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -191,12 +191,17 @@ class GameEngine(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
"""The personalized game view for one player.
|
"""The personalized game view for one viewer.
|
||||||
|
|
||||||
Must hide information the player is not entitled to (e.g. other
|
Must hide information the viewer is not entitled to (e.g. other
|
||||||
players' hands). The platform merges in the envelope fields
|
players' hands). ``user_sub`` is ``None`` for a spectator — an
|
||||||
(``id``, ``join_code``, ``game_type``) itself.
|
anonymous user or an authenticated user who is not seated — and
|
||||||
|
the view must then contain no privileged information either (no
|
||||||
|
hand, no legal moves: only what everyone at the table can see).
|
||||||
|
The platform merges in the envelope fields (``id``,
|
||||||
|
``join_code`` for seated players, ``game_type``, ``spectator``)
|
||||||
|
itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -248,6 +253,13 @@ class GameEngine(ABC):
|
|||||||
``kind``/``token`` no longer match the live state.
|
``kind``/``token`` no longer match the live state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
def game_over_view(
|
||||||
"""Extra fields merged into the ``game_over`` websocket message."""
|
self, session: GameSession, user_sub: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Extra fields merged into the ``game_over`` websocket message.
|
||||||
|
|
||||||
|
``user_sub`` is ``None`` for a spectator (see :meth:`view_for`);
|
||||||
|
the default fields are public, so the default implementation
|
||||||
|
ignores it.
|
||||||
|
"""
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import secrets
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import TYPE_CHECKING, Any, Dict
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
from kaya.core import HttpContext, KayaApp
|
||||||
from kaya.openapi import operation
|
from kaya.openapi import operation
|
||||||
@@ -71,19 +71,29 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
|
|||||||
}
|
}
|
||||||
for index, seat in enumerate(session.players)
|
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),
|
**engine.lobby_view(session),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
def _view_payload(
|
||||||
|
platform: "Platform", session: GameSession, sub: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
engine = platform.registry.require(session.game_type)
|
engine = platform.registry.require(session.game_type)
|
||||||
return {
|
seated = sub is not None and session.seated(sub)
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"join_code": session.join_code,
|
|
||||||
"game_type": session.game_type,
|
"game_type": session.game_type,
|
||||||
**engine.view_for(session, sub),
|
"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.
|
||||||
|
payload["join_code"] = session.join_code
|
||||||
|
payload.update(engine.view_for(session, sub))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
||||||
@@ -98,6 +108,34 @@ def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, An
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ongoing_game_payload(
|
||||||
|
platform: "Platform", session: GameSession, sub: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""One entry of the caller's ongoing-games list.
|
||||||
|
|
||||||
|
Reuses the lobby payload (players, open seats, option echo) but drops
|
||||||
|
the join code — the Continue link only needs the id, and the game page
|
||||||
|
shows the code to its players anyway.
|
||||||
|
"""
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
payload = _lobby_payload(platform, session)
|
||||||
|
payload.pop("join_code", None)
|
||||||
|
payload["game_name"] = engine.name
|
||||||
|
payload["game_description"] = engine.description
|
||||||
|
payload["your_seat"] = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, seat in enumerate(session.players)
|
||||||
|
if seat.user_sub == sub
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
payload["created_at"] = (
|
||||||
|
session.created_at.isoformat() if session.created_at else None
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def register(app: KayaApp, platform: "Platform") -> None:
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
@app.GET("/api/game-types")
|
@app.GET("/api/game-types")
|
||||||
@operation(summary="List available games",
|
@operation(summary="List available games",
|
||||||
@@ -310,24 +348,48 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
@app.GET("/api/games/${game_id}")
|
@app.GET("/api/games/${game_id}")
|
||||||
@operation(summary="Get a game snapshot",
|
@operation(summary="Get a game snapshot",
|
||||||
description="Only seated players may read a game; the view is "
|
description="Anyone who knows the game id may read a game. "
|
||||||
"personalized by the engine (e.g. hidden hands).",
|
"Seated players get the personalized view (their "
|
||||||
|
"own hand plus the join code); everyone else — "
|
||||||
|
"anonymous users and authenticated non-players — "
|
||||||
|
"gets the read-only spectator view: no hands, no "
|
||||||
|
"join code, just what is public at the table.",
|
||||||
tags=["games"],
|
tags=["games"],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The personalized game state"},
|
200: {"description": "The personalized (player) or "
|
||||||
401: {"description": "Authentication required"},
|
"read-only (spectator) game state"},
|
||||||
403: {"description": "Not a player in this game"},
|
|
||||||
404: {"description": "Game not found"},
|
404: {"description": "Game not found"},
|
||||||
})
|
})
|
||||||
@require_auth(platform.oidc)
|
|
||||||
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
||||||
session = await platform.game_store.load(game_id)
|
session = await platform.game_store.load(game_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
await send_error(ctx, 404, "game not found")
|
await send_error(ctx, 404, "game not found")
|
||||||
return
|
return
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None
|
await send_json(
|
||||||
if not session.seated(user.sub):
|
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||||
await send_error(ctx, 403, "forbidden")
|
)
|
||||||
return
|
|
||||||
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
@app.GET("/api/me/games")
|
||||||
|
@operation(summary="List my ongoing games",
|
||||||
|
description="Live games the caller is seated in — waiting "
|
||||||
|
"lobbies and matches in progress or between "
|
||||||
|
"hands — newest first, for the lobby's Continue "
|
||||||
|
"list. Finished matches are not included.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The caller's ongoing games"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def my_games(ctx: HttpContext) -> None:
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
sessions = await platform.game_store.list_ongoing()
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
|
_ongoing_game_payload(platform, session, user.sub)
|
||||||
|
for session in sessions
|
||||||
|
if session.seated(user.sub)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
||||||
|
|
||||||
@@ -72,6 +72,15 @@ def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
|
|||||||
return value.isoformat() if value is not None else None
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _created_at_key(session: GameSession) -> datetime:
|
||||||
|
"""Sort key for :meth:`GameStore.list_ongoing` (newest first).
|
||||||
|
|
||||||
|
Sessions without a creation timestamp sort last; the epoch sentinel is
|
||||||
|
timezone-aware so it never mixes with the stored UTC datetimes.
|
||||||
|
"""
|
||||||
|
return session.created_at or datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _dt_from_json(value: Any) -> Optional[datetime]:
|
def _dt_from_json(value: Any) -> Optional[datetime]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -137,6 +146,15 @@ class GameStore(ABC):
|
|||||||
still retained for the final-board view do not.
|
still retained for the final-board view do not.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||||
|
"""Return up to ``limit`` live games that have not finished yet.
|
||||||
|
|
||||||
|
Newest ``created_at`` first. Sessions whose key has expired are
|
||||||
|
skipped, and so are matches that are over but still retained for
|
||||||
|
the final-board view.
|
||||||
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||||
"""Async context manager serializing mutations of one game."""
|
"""Async context manager serializing mutations of one game."""
|
||||||
@@ -241,6 +259,27 @@ class RedisGameStore(GameStore):
|
|||||||
_, count = await pipe.execute()
|
_, count = await pipe.execute()
|
||||||
return int(count)
|
return int(count)
|
||||||
|
|
||||||
|
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||||
|
await self._redis.zremrangebyscore(
|
||||||
|
ONGOING_GAMES_KEY, "-inf", time.time()
|
||||||
|
)
|
||||||
|
members = await self._redis.zrange(ONGOING_GAMES_KEY, 0, -1)
|
||||||
|
sessions: List[GameSession] = []
|
||||||
|
for member in members:
|
||||||
|
game_id = (
|
||||||
|
member.decode("utf-8") if isinstance(member, bytes) else str(member)
|
||||||
|
)
|
||||||
|
session = await self.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
# The game key expired but the ongoing entry had not been
|
||||||
|
# pruned yet: skip it.
|
||||||
|
continue
|
||||||
|
if self._registry.require(session.game_type).is_finished(session):
|
||||||
|
continue
|
||||||
|
sessions.append(session)
|
||||||
|
sessions.sort(key=_created_at_key, reverse=True)
|
||||||
|
return sessions[:limit]
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
pubsub = self._redis.pubsub()
|
pubsub = self._redis.pubsub()
|
||||||
@@ -352,6 +391,18 @@ class InMemoryGameStore(GameStore):
|
|||||||
async def count_ongoing(self) -> int:
|
async def count_ongoing(self) -> int:
|
||||||
return len(self._ongoing)
|
return len(self._ongoing)
|
||||||
|
|
||||||
|
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||||
|
sessions: List[GameSession] = []
|
||||||
|
for game_id in list(self._ongoing):
|
||||||
|
session = await self.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
continue
|
||||||
|
if self._registry.require(session.game_type).is_finished(session):
|
||||||
|
continue
|
||||||
|
sessions.append(session)
|
||||||
|
sessions.sort(key=_created_at_key, reverse=True)
|
||||||
|
return sessions[:limit]
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
queue: asyncio.Queue = asyncio.Queue()
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|||||||
@@ -2,12 +2,21 @@
|
|||||||
|
|
||||||
Clients connect to ``/ws/games/{game_id}`` using their session cookie
|
Clients connect to ``/ws/games/{game_id}`` using their session cookie
|
||||||
(the OIDC login stores the user in the session, which the session mixin
|
(the OIDC login stores the user in the session, which the session mixin
|
||||||
loads onto the websocket). Only seated players are accepted.
|
loads onto the websocket). Anyone who knows the game id is accepted:
|
||||||
|
seated players get their personalized view, everyone else — anonymous
|
||||||
|
users and authenticated users who are not seated — is a read-only
|
||||||
|
**spectator** receiving the view without privileged information (no
|
||||||
|
hands, no legal moves). The game view carries ``spectator: true`` for
|
||||||
|
them and never carries the join code. Spectator actions other than
|
||||||
|
``state``/``sync`` are rejected with a ``forbidden`` error; seating is
|
||||||
|
re-checked under the game lock so a viewer who joins mid-connection
|
||||||
|
immediately gains (and, if they somehow left, would lose) the right to
|
||||||
|
act.
|
||||||
|
|
||||||
The platform owns the connection lifecycle — authentication, seat
|
The platform owns the connection lifecycle — authentication, the
|
||||||
check, the subscription/publish fan-out and the message envelope — and
|
subscription/publish fan-out and the message envelope — and delegates
|
||||||
delegates the game-specific actions to the engine registered for the
|
the game-specific actions to the engine registered for the session's
|
||||||
session's ``game_type``.
|
``game_type``.
|
||||||
|
|
||||||
Protocol
|
Protocol
|
||||||
--------
|
--------
|
||||||
@@ -27,9 +36,10 @@ Client -> server messages are JSON objects::
|
|||||||
|
|
||||||
For scopone scientifico the game actions are ``play`` (with ``card``
|
For scopone scientifico the game actions are ``play`` (with ``card``
|
||||||
and optionally ``capture``) and ``ack`` — see
|
and optionally ``capture``) and ``ack`` — see
|
||||||
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
|
:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card``
|
||||||
``action``) is handed to the engine as the payload, games define their
|
plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole
|
||||||
own fields freely.
|
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
|
Mutations run under the per-game lock; after a successful action the new
|
||||||
session is saved and a change signal is published. Every connected
|
session is saved and a change signal is published. Every connected
|
||||||
@@ -48,7 +58,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
|
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
@@ -83,17 +93,24 @@ async def _reject(ws: WebSocket, status: int) -> None:
|
|||||||
await ws.close(status)
|
await ws.close(status)
|
||||||
|
|
||||||
|
|
||||||
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
def _state_message(
|
||||||
|
platform: "Platform", session: GameSession, sub: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
engine = platform.registry.require(session.game_type)
|
engine = platform.registry.require(session.game_type)
|
||||||
return {
|
seated = sub is not None and session.seated(sub)
|
||||||
"type": "state",
|
payload: Dict[str, Any] = {
|
||||||
"game": {
|
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"join_code": session.join_code,
|
|
||||||
"game_type": session.game_type,
|
"game_type": session.game_type,
|
||||||
**engine.view_for(session, sub),
|
"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.
|
||||||
|
payload["join_code"] = session.join_code
|
||||||
|
payload.update(engine.view_for(session, sub))
|
||||||
|
return {"type": "state", "game": payload}
|
||||||
|
|
||||||
|
|
||||||
def register(app: KayaApp, platform: "Platform") -> None:
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
@@ -101,24 +118,19 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
@app.websocket("/ws/games/${game_id}")
|
@app.websocket("/ws/games/${game_id}")
|
||||||
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||||
|
# Anonymous and non-seated viewers are welcome as read-only
|
||||||
|
# spectators; only an unknown game id is refused.
|
||||||
user = auth.get_ws_user(ws)
|
user = auth.get_ws_user(ws)
|
||||||
if user is None:
|
sub: Optional[str] = user.sub if user is not None else None
|
||||||
log.debug("websocket %s rejected: no authenticated user", game_id)
|
|
||||||
await _reject(ws, 401)
|
|
||||||
return
|
|
||||||
|
|
||||||
session = await platform.game_store.load(game_id)
|
session = await platform.game_store.load(game_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
log.debug("websocket rejected: unknown game %s", game_id)
|
log.debug("websocket rejected: unknown game %s", game_id)
|
||||||
await _reject(ws, 404)
|
await _reject(ws, 404)
|
||||||
return
|
return
|
||||||
if not session.seated(user.sub):
|
|
||||||
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
|
||||||
await _reject(ws, 403)
|
|
||||||
return
|
|
||||||
|
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
log.info("%s connected to game %s", user.sub, game_id)
|
log.info("%s connected to game %s", sub or "spectator", game_id)
|
||||||
|
|
||||||
send_lock = asyncio.Lock()
|
send_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -132,7 +144,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
# ends momentarily.
|
# ends momentarily.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await send(_state_message(platform, session, user.sub))
|
await send(_state_message(platform, session, sub))
|
||||||
# Backstop: make sure the current deadline is queued even if its
|
# Backstop: make sure the current deadline is queued even if its
|
||||||
# entry was lost (e.g. the queue was flushed while the game lived
|
# entry was lost (e.g. the queue was flushed while the game lived
|
||||||
# on thanks to its sliding TTL).
|
# on thanks to its sliding TTL).
|
||||||
@@ -140,7 +152,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
async with platform.game_store.subscribe(game_id) as events:
|
async with platform.game_store.subscribe(game_id) as events:
|
||||||
forward = asyncio.create_task(
|
forward = asyncio.create_task(
|
||||||
_forward(platform, events, game_id, user.sub, send)
|
_forward(platform, events, game_id, sub, send)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async for message in ws:
|
async for message in ws:
|
||||||
@@ -149,19 +161,19 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
if message.kind != "text" or not isinstance(message.data, str):
|
if message.kind != "text" or not isinstance(message.data, str):
|
||||||
await send(_error("expected a text frame with a JSON object"))
|
await send(_error("expected a text frame with a JSON object"))
|
||||||
continue
|
continue
|
||||||
await _handle_message(platform, send, game_id, user.sub, message.data)
|
await _handle_message(platform, send, game_id, sub, message.data)
|
||||||
finally:
|
finally:
|
||||||
forward.cancel()
|
forward.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await forward
|
await forward
|
||||||
log.debug("%s disconnected from game %s", user.sub, game_id)
|
log.debug("%s disconnected from game %s", sub or "spectator", game_id)
|
||||||
|
|
||||||
|
|
||||||
async def _forward(
|
async def _forward(
|
||||||
platform: "Platform",
|
platform: "Platform",
|
||||||
events,
|
events,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
sub: str,
|
sub: Optional[str],
|
||||||
send: Send,
|
send: Send,
|
||||||
) -> None:
|
) -> None:
|
||||||
async for _ in events:
|
async for _ in events:
|
||||||
@@ -181,7 +193,7 @@ async def _forward(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_message(
|
async def _handle_message(
|
||||||
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
|
platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
@@ -210,7 +222,7 @@ async def _handle_action(
|
|||||||
platform: "Platform",
|
platform: "Platform",
|
||||||
send: Send,
|
send: Send,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
sub: str,
|
sub: Optional[str],
|
||||||
action: str,
|
action: str,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -219,6 +231,13 @@ async def _handle_action(
|
|||||||
if session is None:
|
if session is None:
|
||||||
await send(_error("game not found", code="not_found"))
|
await send(_error("game not found", code="not_found"))
|
||||||
return
|
return
|
||||||
|
# Re-check seating under the lock: an anonymous spectator stays a
|
||||||
|
# spectator forever, while a logged-in viewer who joins the game
|
||||||
|
# mid-connection becomes a player and may act from then on.
|
||||||
|
if sub is None or not session.seated(sub):
|
||||||
|
log.debug("game %s: rejected %r from a spectator", game_id, action)
|
||||||
|
await send(_error("spectators cannot act on the game", code="forbidden"))
|
||||||
|
return
|
||||||
engine = platform.registry.require(session.game_type)
|
engine = platform.registry.require(session.game_type)
|
||||||
payload = {key: value for key, value in data.items() if key != "action"}
|
payload = {key: value for key, value in data.items() if key != "action"}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class DummyEngine(GameEngine):
|
|||||||
state["finished"] = True
|
state["finished"] = True
|
||||||
state["winner"] = user_sub
|
state["winner"] = user_sub
|
||||||
|
|
||||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
state = session.state
|
state = session.state
|
||||||
return {
|
return {
|
||||||
"started": state["started"],
|
"started": state["started"],
|
||||||
@@ -146,7 +146,7 @@ class DummyEngine(GameEngine):
|
|||||||
"winner": state["winner"],
|
"winner": state["winner"],
|
||||||
"plays": len(state["plays"]),
|
"plays": len(state["plays"]),
|
||||||
"target": state["target"],
|
"target": state["target"],
|
||||||
"viewer_seated": session.seated(user_sub),
|
"viewer_seated": user_sub is not None and session.seated(user_sub),
|
||||||
}
|
}
|
||||||
|
|
||||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ import unittest
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from kaya.oidc import OIDCConfig, OIDCProviders
|
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
|
||||||
from kaya.session import InMemorySessionStore, Session, SessionMixin
|
from kaya.session import InMemorySessionStore, Session, SessionMixin
|
||||||
|
|
||||||
from helpers import async_test
|
from helpers import async_test
|
||||||
from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user
|
from tavolo.platform.auth import (
|
||||||
|
current_user,
|
||||||
|
display_name,
|
||||||
|
get_ws_user,
|
||||||
|
require_auth,
|
||||||
|
session_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _providers(*names: str) -> OIDCProviders:
|
def _providers(*names: str) -> OIDCProviders:
|
||||||
@@ -87,6 +93,32 @@ class GetWsUserTest(unittest.TestCase):
|
|||||||
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayNameTest(unittest.TestCase):
|
||||||
|
"""The claim priority used for the name shown at the table."""
|
||||||
|
|
||||||
|
def test_prefers_preferred_username_over_name(self) -> None:
|
||||||
|
user = OIDCUser(
|
||||||
|
{"sub": "alice", "preferred_username": "alice", "name": "Alice"}
|
||||||
|
)
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_name(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "name": "Alice"})
|
||||||
|
self.assertEqual("Alice", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_email(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
|
||||||
|
self.assertEqual("alice@example.com", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_sub(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice"})
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
def test_ignores_empty_and_non_string_claims(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "preferred_username": "", "name": 42})
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
|
||||||
class CurrentUserTest(unittest.TestCase):
|
class CurrentUserTest(unittest.TestCase):
|
||||||
def test_anonymous_session_has_no_user(self) -> None:
|
def test_anonymous_session_has_no_user(self) -> None:
|
||||||
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
||||||
|
|||||||
@@ -44,10 +44,24 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(game_id, snap["id"])
|
self.assertEqual(game_id, snap["id"])
|
||||||
self.assertEqual("dummy", snap["game_type"])
|
self.assertEqual("dummy", snap["game_type"])
|
||||||
self.assertTrue(snap["viewer_seated"])
|
self.assertTrue(snap["viewer_seated"])
|
||||||
|
self.assertFalse(snap["spectator"])
|
||||||
|
self.assertIn("join_code", snap)
|
||||||
|
|
||||||
|
# A logged-in non-player is a read-only spectator: the view
|
||||||
|
# marks it and the join code is withheld.
|
||||||
with oidc_user(platform.oidc, "mallory"):
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
forbidden = await client.get(f"/api/games/{game_id}")
|
watched = await client.get(f"/api/games/{game_id}")
|
||||||
self.assertEqual(403, forbidden.status_code)
|
self.assertEqual(200, watched.status_code)
|
||||||
|
spect = watched.json()
|
||||||
|
self.assertFalse(spect["viewer_seated"])
|
||||||
|
self.assertTrue(spect["spectator"])
|
||||||
|
self.assertNotIn("join_code", spect)
|
||||||
|
|
||||||
|
# Anonymous users may watch too.
|
||||||
|
anonymous = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertEqual(200, anonymous.status_code)
|
||||||
|
self.assertTrue(anonymous.json()["spectator"])
|
||||||
|
self.assertNotIn("join_code", anonymous.json())
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_join_starts_game(self) -> None:
|
async def test_join_starts_game(self) -> None:
|
||||||
@@ -174,6 +188,63 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
response = await client.get("/api/games/does-not-exist")
|
response = await client.get("/api/games/does-not-exist")
|
||||||
self.assertEqual(404, response.status_code)
|
self.assertEqual(404, response.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_games_lists_only_my_ongoing_games(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
anonymous = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(401, anonymous.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
mine = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": 5}}
|
||||||
|
)
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
theirs = await client.post("/api/games", json={})
|
||||||
|
self.assertEqual(201, mine.status_code)
|
||||||
|
self.assertEqual(201, theirs.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
listed = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(200, listed.status_code)
|
||||||
|
results = listed.json()["results"]
|
||||||
|
self.assertEqual([mine.json()["id"]], [g["id"] for g in results])
|
||||||
|
entry = results[0]
|
||||||
|
self.assertEqual("dummy", entry["game_type"])
|
||||||
|
self.assertEqual("Dummy game", entry["game_name"])
|
||||||
|
self.assertTrue(entry["game_description"])
|
||||||
|
self.assertEqual("lobby", entry["phase"])
|
||||||
|
self.assertEqual(5, entry["target"])
|
||||||
|
self.assertEqual(1, entry["seats_open"])
|
||||||
|
self.assertEqual(0, entry["your_seat"])
|
||||||
|
self.assertNotIn("join_code", entry)
|
||||||
|
self.assertEqual("alice", entry["players"][0]["sub"])
|
||||||
|
|
||||||
|
# Bob's own game is the only one he sees; Alice's is not his.
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
bobs = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(
|
||||||
|
[theirs.json()["id"]],
|
||||||
|
[g["id"] for g in bobs.json()["results"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once Bob joins Alice's game it appears for both, now in the
|
||||||
|
# playing phase.
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
await client.post(
|
||||||
|
"/api/games/join", json={"code": mine.json()["join_code"]}
|
||||||
|
)
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
bobs = await client.get("/api/me/games")
|
||||||
|
by_id = {g["id"]: g for g in bobs.json()["results"]}
|
||||||
|
self.assertEqual(
|
||||||
|
{mine.json()["id"], theirs.json()["id"]}, set(by_id)
|
||||||
|
)
|
||||||
|
self.assertEqual("playing", by_id[mine.json()["id"]]["phase"])
|
||||||
|
self.assertEqual(1, by_id[mine.json()["id"]]["your_seat"])
|
||||||
|
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
||||||
|
|
||||||
|
|
||||||
class GameTypesRouteTest(unittest.TestCase):
|
class GameTypesRouteTest(unittest.TestCase):
|
||||||
@async_test
|
@async_test
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional, cast
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
from tavolo.platform import GameSession, Seat
|
from tavolo.platform import GameSession, Seat
|
||||||
@@ -48,7 +49,7 @@ class _FakePipeline:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def set(self, key: str, value: str, ex: object = None) -> None:
|
def set(self, key: str, value: str, ex: object = None) -> None:
|
||||||
self._ops.append(("set", key))
|
self._ops.append(("set", key, value))
|
||||||
|
|
||||||
def zadd(self, key: str, mapping: dict) -> None:
|
def zadd(self, key: str, mapping: dict) -> None:
|
||||||
self._ops.append(("zadd", key, mapping))
|
self._ops.append(("zadd", key, mapping))
|
||||||
@@ -88,6 +89,14 @@ class _FakeRedis:
|
|||||||
async def zrem(self, key: str, member: str) -> int:
|
async def zrem(self, key: str, member: str) -> int:
|
||||||
return cast(int, self.apply(("zrem", key, member)))
|
return cast(int, self.apply(("zrem", key, member)))
|
||||||
|
|
||||||
|
async def get(self, key: str) -> object:
|
||||||
|
return self.keys.get(key)
|
||||||
|
|
||||||
|
async def zremrangebyscore(
|
||||||
|
self, key: str, minimum: object, maximum: object
|
||||||
|
) -> int:
|
||||||
|
return cast(int, self.apply(("zremrangebyscore", key)))
|
||||||
|
|
||||||
async def zrange(
|
async def zrange(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -113,7 +122,7 @@ class _FakeRedis:
|
|||||||
def apply(self, op: tuple) -> object:
|
def apply(self, op: tuple) -> object:
|
||||||
name, *args = op
|
name, *args = op
|
||||||
if name == "set":
|
if name == "set":
|
||||||
self.keys[args[0]] = "payload"
|
self.keys[args[0]] = args[1]
|
||||||
return True
|
return True
|
||||||
if name == "zadd":
|
if name == "zadd":
|
||||||
self.zsets.setdefault(args[0], {}).update(args[1])
|
self.zsets.setdefault(args[0], {}).update(args[1])
|
||||||
@@ -247,6 +256,28 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
|||||||
await store.save(other)
|
await store.save(other)
|
||||||
self.assertEqual(0, await store.count_ongoing())
|
self.assertEqual(0, await store.count_ongoing())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_list_ongoing_newest_first_and_unfinished_only(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
old = _session("old", "CODE01")
|
||||||
|
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
new = _session("new", "CODE02")
|
||||||
|
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||||
|
await store.save(old)
|
||||||
|
await store.save(new)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# A finished match retained for the final board is not listed.
|
||||||
|
new.state["finished"] = True
|
||||||
|
await store.save(new)
|
||||||
|
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_deadline_queue(self) -> None:
|
async def test_deadline_queue(self) -> None:
|
||||||
store = InMemoryGameStore(_registry())
|
store = InMemoryGameStore(_registry())
|
||||||
@@ -311,6 +342,33 @@ class RedisGameStoreTest(unittest.TestCase):
|
|||||||
self.assertEqual(1, await store.count_ongoing())
|
self.assertEqual(1, await store.count_ongoing())
|
||||||
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_list_ongoing_loads_unfinished_sessions(self) -> None:
|
||||||
|
redis = _FakeRedis()
|
||||||
|
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
|
||||||
|
old = _session("old", "CODE01")
|
||||||
|
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
new = _session("new", "CODE02")
|
||||||
|
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||||
|
await store.save(old)
|
||||||
|
await store.save(new)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# A finished match retained for the final board is not listed.
|
||||||
|
new.state["finished"] = True
|
||||||
|
await store.save(new)
|
||||||
|
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||||
|
|
||||||
|
# An ongoing entry whose game key expired is skipped.
|
||||||
|
redis.zsets[ONGOING_GAMES_KEY]["gone"] = time.time() + 100
|
||||||
|
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
||||||
redis = _FakeRedis()
|
redis = _FakeRedis()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from httpx_ws.transport import ASGIWebSocketTransport
|
|||||||
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
|
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
|
||||||
|
|
||||||
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
||||||
from tavolo.platform import GameSession, Seat
|
|
||||||
|
|
||||||
|
|
||||||
class AbruptCloseSocket(WebSocket):
|
class AbruptCloseSocket(WebSocket):
|
||||||
@@ -117,6 +116,9 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual("dummy", first["game"]["game_type"])
|
self.assertEqual("dummy", first["game"]["game_type"])
|
||||||
self.assertEqual(0, first["game"]["plays"])
|
self.assertEqual(0, first["game"]["plays"])
|
||||||
self.assertEqual(game_id, first["game"]["id"])
|
self.assertEqual(game_id, first["game"]["id"])
|
||||||
|
# Seated players get the join code; spectators don't.
|
||||||
|
self.assertIn("join_code", first["game"])
|
||||||
|
self.assertFalse(first["game"]["spectator"])
|
||||||
|
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
alice_first = await alice_ws.receive_json()
|
alice_first = await alice_ws.receive_json()
|
||||||
@@ -130,6 +132,33 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual("state", update["type"])
|
self.assertEqual("state", update["type"])
|
||||||
self.assertEqual(1, update["game"]["plays"])
|
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_test
|
||||||
async def test_unknown_action_returns_error(self) -> None:
|
async def test_unknown_action_returns_error(self) -> None:
|
||||||
app, platform, _ = make_platform()
|
app, platform, _ = make_platform()
|
||||||
@@ -237,7 +266,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(404, caught.exception.code)
|
self.assertEqual(404, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_non_player_is_closed(self) -> None:
|
async def test_non_player_watches_as_spectator(self) -> None:
|
||||||
app, platform, _ = make_platform()
|
app, platform, _ = make_platform()
|
||||||
api_transport = ASGITransport(app=app)
|
api_transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
@@ -247,21 +276,38 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
with ws_users([make_user("mallory")]):
|
with ws_users([make_user("mallory")]):
|
||||||
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
first = await ws.receive_json()
|
||||||
pass
|
self.assertEqual("state", first["type"])
|
||||||
self.assertEqual(403, caught.exception.code)
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertFalse(first["game"]["viewer_seated"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
# Spectators may not act.
|
||||||
|
await ws.send_json({"action": "play"})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_unauthenticated_is_closed(self) -> None:
|
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||||
app, _, _ = make_platform()
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
with ws_users([]):
|
with ws_users([]):
|
||||||
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
first = await ws.receive_json()
|
||||||
pass
|
self.assertEqual("state", first["type"])
|
||||||
self.assertEqual(401, caught.exception.code)
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
await ws.send_json({"action": "play"})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
|
||||||
class RejectionStatusTest(unittest.TestCase):
|
class RejectionStatusTest(unittest.TestCase):
|
||||||
@@ -271,33 +317,19 @@ class RejectionStatusTest(unittest.TestCase):
|
|||||||
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
|
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
|
||||||
close code like 4401 is not a valid status and panics the worker
|
close code like 4401 is not a valid status and panics the worker
|
||||||
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
|
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
|
||||||
dies). Drive the rejections through the app with a recording socket
|
dies). Drive the rejection through the app with a recording socket
|
||||||
so the invariant is checked without a real server.
|
so the invariant is checked without a real server. Spectators are
|
||||||
|
accepted, so the only pre-accept rejection left is an unknown game.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
|
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
|
||||||
app, platform, _ = make_platform()
|
app, _, _ = make_platform()
|
||||||
session = GameSession(
|
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
|
||||||
id="seated-game",
|
ws = RecordingSocket("/ws/games/no-such-game")
|
||||||
game_type="dummy",
|
|
||||||
join_code="SEAT01",
|
|
||||||
creator_sub="alice",
|
|
||||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
|
||||||
)
|
|
||||||
engine = platform.registry.require("dummy")
|
|
||||||
engine.create(session, {})
|
|
||||||
await platform.game_store.save(session)
|
|
||||||
cases = [
|
|
||||||
("unauthenticated", [], "/ws/games/whatever", 401),
|
|
||||||
("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404),
|
|
||||||
("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403),
|
|
||||||
]
|
|
||||||
for label, users, path, expected in cases:
|
|
||||||
ws = RecordingSocket(path)
|
|
||||||
with ws_users(users):
|
with ws_users(users):
|
||||||
await app.handle_websocket(ws)
|
await app.handle_websocket(ws)
|
||||||
self.assertEqual([expected], ws.close_codes, label)
|
self.assertEqual([404], ws.close_codes, label)
|
||||||
for code in ws.close_codes:
|
for code in ws.close_codes:
|
||||||
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
|
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
|
||||||
|
|
||||||
|
|||||||
@@ -528,13 +528,14 @@ def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
|
|||||||
return points, details
|
return points, details
|
||||||
|
|
||||||
|
|
||||||
def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
|
def state_for_player(state: ScoponeState, sub: Optional[str]) -> Dict[str, object]:
|
||||||
"""Serialize ``state`` hiding other players' hands.
|
"""Serialize ``state`` hiding other players' hands.
|
||||||
|
|
||||||
Hands are reduced to a count, except for the requesting player's own
|
Hands are reduced to a count, except for the requesting player's own
|
||||||
hand. Non-seated viewers simply see no hand at all. The platform
|
hand. Non-seated viewers — including spectators, for whom ``sub`` is
|
||||||
merges the session envelope (``id``, ``join_code``, ``game_type``)
|
``None`` — simply see no hand at all. The platform merges the session
|
||||||
into the view itself.
|
envelope (``id``, ``join_code`` for seated players, ``game_type``,
|
||||||
|
``spectator``) into the view itself.
|
||||||
"""
|
"""
|
||||||
viewer = state.player_for(sub)
|
viewer = state.player_for(sub)
|
||||||
players: List[Dict[str, object]] = []
|
players: List[Dict[str, object]] = []
|
||||||
|
|||||||
@@ -171,13 +171,13 @@ class ScoponeEngine(GameEngine):
|
|||||||
else:
|
else:
|
||||||
raise IllegalMove(f"unknown action: {action!r}")
|
raise IllegalMove(f"unknown action: {action!r}")
|
||||||
|
|
||||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
return engine.state_for_player(session.state, user_sub)
|
return engine.state_for_player(session.state, user_sub)
|
||||||
|
|
||||||
def is_finished(self, session: GameSession) -> bool:
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
return session.state.phase == PHASE_FINISHED
|
return session.state.phase == PHASE_FINISHED
|
||||||
|
|
||||||
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
state: ScoponeState = session.state
|
state: ScoponeState = session.state
|
||||||
return {
|
return {
|
||||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ class ScoponeState:
|
|||||||
|
|
||||||
# -- helpers ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
def player_for(self, sub: str) -> Optional[PlayerState]:
|
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
|
||||||
for player in self.players:
|
for player in self.players:
|
||||||
if player.sub == sub:
|
if player.sub == sub:
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -574,6 +574,27 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
self.assertEqual(["07C"], view["table"])
|
self.assertEqual(["07C"], view["table"])
|
||||||
self.assertTrue(view.get("your_turn"))
|
self.assertTrue(view.get("your_turn"))
|
||||||
|
|
||||||
|
def test_state_for_player_spectator_sees_public_info_only(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["07C"],
|
||||||
|
captured=[["01D"], [], ["02C"], ["03B"]],
|
||||||
|
scope=[1, 0, 2, 0],
|
||||||
|
)
|
||||||
|
view = engine.state_for_player(state, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertNotIn("legal_moves", view)
|
||||||
|
# The public table state is still fully visible.
|
||||||
|
self.assertEqual(["07C"], view["table"])
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertEqual(1, players[0]["captured_count"])
|
||||||
|
self.assertEqual(1, players[0]["scope"])
|
||||||
|
self.assertEqual(2, players[2]["scope"])
|
||||||
|
self.assertEqual(1, players[1]["cards_left"])
|
||||||
|
|
||||||
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
||||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
table=["07C"])
|
table=["07C"])
|
||||||
|
|||||||
@@ -160,6 +160,17 @@ class ViewTest(unittest.TestCase):
|
|||||||
self.assertNotIn("join_code", view)
|
self.assertNotIn("join_code", view)
|
||||||
self.assertNotIn("game_type", view)
|
self.assertNotIn("game_type", view)
|
||||||
|
|
||||||
|
def test_view_for_spectator_hides_all_hands(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertNotIn("legal_moves", view)
|
||||||
|
self.assertIn("table", view)
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
|
||||||
def test_lobby_view(self) -> None:
|
def test_lobby_view(self) -> None:
|
||||||
engine = ScoponeEngine()
|
engine = ScoponeEngine()
|
||||||
session = _session(engine, target_score=16)
|
session = _session(engine, target_score=16)
|
||||||
|
|||||||
@@ -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
|
# 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
|
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-briscola and tavolo-app
|
||||||
# are NOT pinned here: they are installed from ./packages in the Dockerfile
|
# sibling packages are NOT pinned here: they are installed from ./packages in
|
||||||
# and in development (pip install -e ./packages/tavolo-platform
|
# the Dockerfile and in development (pip install -e ./packages/tavolo-platform
|
||||||
# -e ./packages/tavolo-scopone -e ./packages/tavolo-app). All of their
|
# -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
|
# third-party dependencies are direct dependencies of tavolo-app, so this
|
||||||
# lock covers the full closure. The otel extra (kaya-otel and its
|
# lock covers the full closure. The otel extra (kaya-otel and its
|
||||||
# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the
|
# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
@@ -39,9 +39,19 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, snapshot.status_code)
|
self.assertEqual(200, snapshot.status_code)
|
||||||
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
|
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
|
||||||
|
|
||||||
|
# A logged-in non-player is a read-only spectator: no join
|
||||||
|
# code, and the view is marked as a spectator view.
|
||||||
with oidc_user("mallory"):
|
with oidc_user("mallory"):
|
||||||
forbidden = await client.get(f"/api/games/{game_id}")
|
watched = await client.get(f"/api/games/{game_id}")
|
||||||
self.assertEqual(403, forbidden.status_code)
|
self.assertEqual(200, watched.status_code)
|
||||||
|
self.assertTrue(watched.json()["spectator"])
|
||||||
|
self.assertNotIn("join_code", watched.json())
|
||||||
|
|
||||||
|
# Anonymous users may watch too.
|
||||||
|
anonymous = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertEqual(200, anonymous.status_code)
|
||||||
|
self.assertTrue(anonymous.json()["spectator"])
|
||||||
|
self.assertNotIn("join_code", anonymous.json())
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_join_fills_seats_and_starts_game(self) -> None:
|
async def test_join_fills_seats_and_starts_game(self) -> None:
|
||||||
@@ -61,6 +71,7 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
started = await client.post("/api/games/join", json={"code": code})
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
self.assertEqual(200, started.status_code)
|
self.assertEqual(200, started.status_code)
|
||||||
state = started.json()
|
state = started.json()
|
||||||
|
game_id = state["id"]
|
||||||
self.assertEqual("playing", state["phase"])
|
self.assertEqual("playing", state["phase"])
|
||||||
self.assertEqual(4, len(state["players"]))
|
self.assertEqual(4, len(state["players"]))
|
||||||
self.assertEqual([], state["table"])
|
self.assertEqual([], state["table"])
|
||||||
@@ -72,6 +83,21 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertNotIn("hand", state["players"][0])
|
self.assertNotIn("hand", state["players"][0])
|
||||||
self.assertEqual(1, state["turn"])
|
self.assertEqual(1, state["turn"])
|
||||||
|
|
||||||
|
# A spectator sees the table, the score and each player's
|
||||||
|
# captured count and scope — but no hand at all.
|
||||||
|
watched = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertEqual(200, watched.status_code)
|
||||||
|
spect = watched.json()
|
||||||
|
self.assertTrue(spect["spectator"])
|
||||||
|
self.assertNotIn("join_code", spect)
|
||||||
|
self.assertEqual([], spect["table"])
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, spect["scores"])
|
||||||
|
for participant in spect["players"]:
|
||||||
|
self.assertNotIn("hand", participant)
|
||||||
|
self.assertEqual(10, participant["cards_left"])
|
||||||
|
self.assertEqual(0, participant["captured_count"])
|
||||||
|
self.assertEqual(0, participant["scope"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_create_napola_option(self) -> None:
|
async def test_create_napola_option(self) -> None:
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
@@ -210,6 +236,52 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(400, text.status_code)
|
self.assertEqual(400, text.status_code)
|
||||||
self.assertEqual(400, huge.status_code)
|
self.assertEqual(400, huge.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_games_lists_my_ongoing_games(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"}
|
||||||
|
)
|
||||||
|
game_id = created.json()["id"]
|
||||||
|
|
||||||
|
with oidc_user("alice"):
|
||||||
|
listed = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(200, listed.status_code)
|
||||||
|
# The app suite shares one in-memory store, so other tests'
|
||||||
|
# games may be listed too: pick ours by id.
|
||||||
|
by_id = {g["id"]: g for g in listed.json()["results"]}
|
||||||
|
self.assertIn(game_id, by_id)
|
||||||
|
entry = by_id[game_id]
|
||||||
|
self.assertEqual("briscola", entry["game_type"])
|
||||||
|
self.assertEqual("Briscola", entry["game_name"])
|
||||||
|
self.assertTrue(entry["game_description"])
|
||||||
|
self.assertEqual("lobby", entry["phase"])
|
||||||
|
self.assertEqual(1, entry["seats_open"])
|
||||||
|
self.assertEqual(0, entry["your_seat"])
|
||||||
|
self.assertNotIn("join_code", entry)
|
||||||
|
|
||||||
|
# A logged-in non-player does not see the game.
|
||||||
|
with oidc_user("mallory"):
|
||||||
|
others = await client.get("/api/me/games")
|
||||||
|
self.assertNotIn(
|
||||||
|
game_id, [g["id"] for g in others.json()["results"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once the match starts it stays listed, for both players.
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
await client.post("/api/games/join", json={"code": code})
|
||||||
|
with oidc_user("bob"):
|
||||||
|
started = await client.get("/api/me/games")
|
||||||
|
bob_entry = next(
|
||||||
|
g for g in started.json()["results"] if g["id"] == game_id
|
||||||
|
)
|
||||||
|
self.assertEqual("playing", bob_entry["phase"])
|
||||||
|
self.assertEqual(1, bob_entry["your_seat"])
|
||||||
|
self.assertEqual(0, bob_entry["seats_open"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_get_unknown_game(self) -> None:
|
async def test_get_unknown_game(self) -> None:
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
@@ -227,16 +299,32 @@ class GameTypesRouteTest(unittest.TestCase):
|
|||||||
response = await client.get("/api/game-types")
|
response = await client.get("/api/game-types")
|
||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
|
self.assertEqual(
|
||||||
self.assertEqual("Scopone scientifico", results[0]["name"])
|
["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||||
self.assertTrue(results[0]["description"])
|
[g["id"] for g in results],
|
||||||
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"]
|
|
||||||
)
|
)
|
||||||
|
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_test
|
||||||
async def test_create_defaults_game_type(self) -> None:
|
async def test_create_defaults_game_type(self) -> None:
|
||||||
@@ -268,7 +356,7 @@ class GameTypesRouteTest(unittest.TestCase):
|
|||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
with oidc_user("alice"):
|
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})
|
non_string = await client.post("/api/games", json={"game_type": 42})
|
||||||
self.assertEqual(400, unknown.status_code)
|
self.assertEqual(400, unknown.status_code)
|
||||||
self.assertEqual(400, non_string.status_code)
|
self.assertEqual(400, non_string.status_code)
|
||||||
|
|||||||
@@ -136,5 +136,91 @@ class ScoponeStatsTest(unittest.TestCase):
|
|||||||
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(404, caught.exception.code)
|
self.assertEqual(404, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_non_player_is_closed(self) -> None:
|
async def test_non_player_watches_as_spectator(self) -> None:
|
||||||
api_transport = ASGITransport(app=app)
|
api_transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
state = await self._started_game(client)
|
state = await self._started_game(client)
|
||||||
@@ -143,20 +143,42 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
with ws_users([make_user("mallory")]):
|
with ws_users([make_user("mallory")]):
|
||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
first = await ws.receive_json()
|
||||||
pass
|
self.assertEqual("state", first["type"])
|
||||||
self.assertEqual(403, caught.exception.code)
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
self.assertIn("table", first["game"])
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, first["game"]["scores"])
|
||||||
|
for player in first["game"]["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
# Spectators may not act.
|
||||||
|
await ws.send_json({"action": "play", "card": "01D"})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_unauthenticated_is_closed(self) -> None:
|
async def test_anonymous_user_watches_as_spectator(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_game(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
with ws_users([]):
|
with ws_users([]):
|
||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
first = await ws.receive_json()
|
||||||
pass
|
self.assertEqual("state", first["type"])
|
||||||
self.assertEqual(401, caught.exception.code)
|
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("error", error["type"])
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
|
||||||
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||||
|
|||||||
+24
-9
@@ -105,11 +105,11 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
|||||||
/// A refused WebSocket handshake is invisible to the browser: the close
|
/// A refused WebSocket handshake is invisible to the browser: the close
|
||||||
/// event reports an abnormal closure (1006) with no HTTP status, so the
|
/// event reports an abnormal closure (1006) with no HTTP status, so the
|
||||||
/// game page asks the REST API to tell a deliberate rejection from a
|
/// game page asks the REST API to tell a deliberate rejection from a
|
||||||
/// transient network problem.
|
/// transient network problem. Anonymous users and non-players are
|
||||||
|
/// welcome as spectators, so the only deliberate refusal is an unknown
|
||||||
|
/// game.
|
||||||
pub enum GameEntry {
|
pub enum GameEntry {
|
||||||
/// Not logged in (`401` from `GET /api/games/{id}`).
|
/// Unknown game (`404`): the id is wrong or the game's TTL expired.
|
||||||
Unauthenticated,
|
|
||||||
/// Unknown game (`404`) or the user is not seated (`403`).
|
|
||||||
Unavailable,
|
Unavailable,
|
||||||
/// The API could not be asked (or answered unexpectedly): treat the
|
/// The API could not be asked (or answered unexpectedly): treat the
|
||||||
/// connection loss as transient and retry.
|
/// connection loss as transient and retry.
|
||||||
@@ -117,18 +117,31 @@ pub enum GameEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a failed game connection via `GET /api/games/{game_id}`,
|
/// Classify a failed game connection via `GET /api/games/{game_id}`,
|
||||||
/// mirroring the statuses used to refuse the websocket upgrade.
|
/// mirroring the status used to refuse the websocket upgrade.
|
||||||
pub async fn probe_game(game_id: &str) -> GameEntry {
|
pub async fn probe_game(game_id: &str) -> GameEntry {
|
||||||
match Request::get(&format!("/api/games/{game_id}")).send().await {
|
match Request::get(&format!("/api/games/{game_id}")).send().await {
|
||||||
Ok(resp) => match resp.status() {
|
Ok(resp) => match resp.status() {
|
||||||
401 => GameEntry::Unauthenticated,
|
404 => GameEntry::Unavailable,
|
||||||
403 | 404 => GameEntry::Unavailable,
|
|
||||||
_ => GameEntry::Transient,
|
_ => GameEntry::Transient,
|
||||||
},
|
},
|
||||||
Err(_) => GameEntry::Transient,
|
Err(_) => GameEntry::Transient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the live games the caller is seated in, for the lobby's
|
||||||
|
/// "Continue" list.
|
||||||
|
pub async fn my_games() -> Result<Vec<MyGame>, String> {
|
||||||
|
let resp = Request::get("/api/me/games")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(page.results)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||||
let url = match cursor {
|
let url = match cursor {
|
||||||
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
||||||
@@ -153,8 +166,10 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
|
||||||
let resp = Request::get("/api/leaderboard")
|
/// are all scoped to that game.
|
||||||
|
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
||||||
|
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! 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::{is_individual_game, DealSummary, GameView};
|
||||||
|
use crate::ws::GameSocket;
|
||||||
|
|
||||||
|
/// Names of the two sides: the players' names for games without
|
||||||
|
/// partnerships (classic two-player briscola), "Team A"/"Team B"
|
||||||
|
/// otherwise.
|
||||||
|
fn side_names(game: &GameView) -> (String, String) {
|
||||||
|
if is_individual_game(&game.game_type) {
|
||||||
|
(
|
||||||
|
game.player_name_for_team("A"),
|
||||||
|
game.player_name_for_team("B"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
("Team A".to_string(), "Team B".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(game: &GameView, summary: &DealSummary) -> View {
|
||||||
|
let a = summary.points.a;
|
||||||
|
let b = summary.points.b;
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
|
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!("{name_a} {a} · {name_b} {b}"),
|
||||||
|
cls,
|
||||||
|
chip,
|
||||||
|
);
|
||||||
|
let cards = summary.cards.map(|c| {
|
||||||
|
summary_row(
|
||||||
|
card_back("score-mini"),
|
||||||
|
"Cards captured",
|
||||||
|
format!("{name_a} {} · {name_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)
|
||||||
|
};
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
|
view! {
|
||||||
|
div(class="totals") {
|
||||||
|
div(class="total-row team-a") {
|
||||||
|
span(class="total-label") { (name_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") { (name_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<Option<GameSocket>>,
|
||||||
|
now: Signal<f64>,
|
||||||
|
) -> 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<String> = 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(&game, &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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Clipboard copy button, shared by the join-code and watch-link actions.
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
/// A button that copies `text` to the clipboard and shows "Copied!" for a
|
||||||
|
/// moment. `label` is the idle caption.
|
||||||
|
#[component(inline_props)]
|
||||||
|
pub fn CopyButton(text: String, label: String) -> View {
|
||||||
|
let copied = create_signal(false);
|
||||||
|
// Held so the reset timer is cancelled if the button is rebuilt (e.g.
|
||||||
|
// another player joins) while the "Copied!" feedback is showing:
|
||||||
|
// firing it afterwards would set a disposed signal and panic.
|
||||||
|
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
|
||||||
|
Rc::new(RefCell::new(None));
|
||||||
|
on_cleanup({
|
||||||
|
let reset_timer = reset_timer.clone();
|
||||||
|
move || {
|
||||||
|
reset_timer.borrow_mut().take();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_click = {
|
||||||
|
let text = text.clone();
|
||||||
|
move |_| {
|
||||||
|
if let Some(win) = web_sys::window() {
|
||||||
|
let _ = win.navigator().clipboard().write_text(&text);
|
||||||
|
}
|
||||||
|
copied.set(true);
|
||||||
|
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
|
||||||
|
1500,
|
||||||
|
move || {
|
||||||
|
copied.set(false);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
button(class="btn btn-outline-light", on:click=on_click) {
|
||||||
|
(move || if copied.get() { "Copied!".to_string() } else { label.clone() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
pub mod briscola;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
|
pub mod copy;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|||||||
@@ -260,7 +260,14 @@ pub fn hand_summary_modal(
|
|||||||
let totals_view = totals(&game);
|
let totals_view = totals(&game);
|
||||||
let title = format!("Hand {} — results", summary.hand);
|
let title = format!("Hand {} — results", summary.hand);
|
||||||
|
|
||||||
let action = if already_acked {
|
let action = if game.spectator {
|
||||||
|
// Spectators cannot acknowledge; they just watch the summary.
|
||||||
|
view! {
|
||||||
|
p(class="text-secondary small mb-0") {
|
||||||
|
"Spectating — the next hand is dealt when every player is ready."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if already_acked {
|
||||||
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
||||||
view! {
|
view! {
|
||||||
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
||||||
|
|||||||
+400
-1
@@ -26,12 +26,15 @@ pub struct PlayerView {
|
|||||||
pub captured_count: usize,
|
pub captured_count: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scope: i32,
|
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.
|
/// Own hand only; absent for the other players.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hand: Option<Vec<String>>,
|
pub hand: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||||
pub struct Scores {
|
pub struct Scores {
|
||||||
#[serde(rename = "A")]
|
#[serde(rename = "A")]
|
||||||
pub a: i32,
|
pub a: i32,
|
||||||
@@ -54,6 +57,38 @@ pub struct MoveView {
|
|||||||
pub pigliatutto: bool,
|
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<TrickCard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<TeamCounts>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
pub struct TeamCounts {
|
pub struct TeamCounts {
|
||||||
#[serde(rename = "A")]
|
#[serde(rename = "A")]
|
||||||
@@ -112,11 +147,16 @@ pub struct HandSummary {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct GameView {
|
pub struct GameView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// The lobby join code; withheld by the server from spectators.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub join_code: String,
|
pub join_code: String,
|
||||||
/// Which card game this match is (id from /api/game-types).
|
/// Which card game this match is (id from /api/game-types).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub game_type: String,
|
pub game_type: String,
|
||||||
|
/// Whether the viewer is watching without a seat: no hand, no join
|
||||||
|
/// code, and actions are rejected by the server.
|
||||||
|
#[serde(default)]
|
||||||
|
pub spectator: bool,
|
||||||
/// Whether the napola rule is scored in this match.
|
/// Whether the napola rule is scored in this match.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub napola: bool,
|
pub napola: bool,
|
||||||
@@ -163,6 +203,42 @@ pub struct GameView {
|
|||||||
/// Legal captures per hand card; present only for the player on turn.
|
/// Legal captures per hand card; present only for the player on turn.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub legal_moves: Option<HashMap<String, Vec<Vec<String>>>>,
|
pub legal_moves: Option<HashMap<String, Vec<Vec<String>>>>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<TrickCard>,
|
||||||
|
/// Briscola: seat that led the current trick.
|
||||||
|
#[serde(default)]
|
||||||
|
pub trick_leader: Option<usize>,
|
||||||
|
/// Briscola: the most recently completed trick.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_trick: Option<LastTrick>,
|
||||||
|
/// Briscola: scoring breakdown of the most recent deal.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_deal: Option<DealSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameView {
|
||||||
|
/// Display name of the player on team `team` ("A"/"B"), for games
|
||||||
|
/// without partnerships; falls back to the team label itself.
|
||||||
|
pub fn player_name_for_team(&self, team: &str) -> String {
|
||||||
|
self.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.team == team)
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.unwrap_or_else(|| team.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -344,6 +420,38 @@ pub struct GameTypesPage {
|
|||||||
pub results: Vec<GameTypeInfo>,
|
pub results: Vec<GameTypeInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The games to show in the lobby's and leaderboard's game selectors when
|
||||||
|
/// the `/api/game-types` fetch fails: match creation and per-game views
|
||||||
|
/// must still work.
|
||||||
|
pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// One configured OIDC login provider (GET /api/auth/providers).
|
/// One configured OIDC login provider (GET /api/auth/providers).
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
pub struct AuthProvider {
|
pub struct AuthProvider {
|
||||||
@@ -390,6 +498,66 @@ pub struct PreviewPlayer {
|
|||||||
pub team: Option<String>,
|
pub team: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One ongoing game the caller is seated in (GET /api/me/games): the
|
||||||
|
/// lobby payload plus the game's display name, for the "Continue" list.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct MyGame {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phase: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub players: Vec<PreviewPlayer>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seats_open: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub your_seat: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyGame {
|
||||||
|
/// Human-readable phase for the lobby list.
|
||||||
|
pub fn phase_label(&self) -> &'static str {
|
||||||
|
match self.phase.as_str() {
|
||||||
|
"lobby" => "Waiting for players",
|
||||||
|
"hand_end" => "Between hands",
|
||||||
|
_ => "In progress",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seated/total note, e.g. `"2/4 seated"`.
|
||||||
|
pub fn seats_text(&self) -> String {
|
||||||
|
match self.seats_open {
|
||||||
|
Some(open) => {
|
||||||
|
format!("{}/{} seated", self.players.len(), self.players.len() + open)
|
||||||
|
}
|
||||||
|
None => format!("{} seated", self.players.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seated players' names, for the list subtitle.
|
||||||
|
pub fn players_text(&self) -> String {
|
||||||
|
self.players
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct MyGamesPage {
|
||||||
|
#[serde(default)]
|
||||||
|
pub results: Vec<MyGame>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One option to show in the join preview, rendered from the game's
|
/// One option to show in the join preview, rendered from the game's
|
||||||
/// schema and the engine-echoed value.
|
/// schema and the engine-echoed value.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -562,6 +730,29 @@ pub fn card_asset(code: &str) -> String {
|
|||||||
format!("/assets/cards/{code}.svg")
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a game is played by two individuals rather than partnerships
|
||||||
|
/// (classic two-player briscola): team labels and team messages are
|
||||||
|
/// replaced by the players' names.
|
||||||
|
pub fn is_individual_game(game_type: &str) -> bool {
|
||||||
|
game_type == "briscola"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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";
|
pub const CARD_BACK: &str = "/assets/cards/back.svg";
|
||||||
|
|
||||||
/// Display order key for a hand: rank ascending (01..10, i.e. 1..7 then
|
/// Display order key for a hand: rank ascending (01..10, i.e. 1..7 then
|
||||||
@@ -880,11 +1071,39 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!("playing", g.phase);
|
assert_eq!("playing", g.phase);
|
||||||
assert!(g.asso_pigliatutto);
|
assert!(g.asso_pigliatutto);
|
||||||
|
assert!(!g.spectator);
|
||||||
assert_eq!(None, g.players[0].hand);
|
assert_eq!(None, g.players[0].hand);
|
||||||
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
|
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
|
||||||
assert_eq!(Some(true), g.your_turn);
|
assert_eq!(Some(true), g.your_turn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_view_parses_spectator_snapshot() {
|
||||||
|
// A spectator payload: no hand anywhere, no join code, but the
|
||||||
|
// public table state is present.
|
||||||
|
let g: GameView = serde_json::from_value(json!({
|
||||||
|
"id": "11111111-2222-3333-4444-555555555555",
|
||||||
|
"game_type": "scopone_scientifico",
|
||||||
|
"spectator": true,
|
||||||
|
"phase": "playing",
|
||||||
|
"target_score": 11,
|
||||||
|
"scores": {"A": 2, "B": 1},
|
||||||
|
"table": ["07C"],
|
||||||
|
"players": [
|
||||||
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
||||||
|
"cards_left": 5, "captured_count": 12, "scope": 1},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(g.spectator);
|
||||||
|
assert_eq!("", g.join_code);
|
||||||
|
assert_eq!(Some(Scores { a: 2, b: 1 }), g.scores);
|
||||||
|
assert_eq!(vec!["07C".to_string()], g.table);
|
||||||
|
assert_eq!(None, g.players[0].hand);
|
||||||
|
assert_eq!(12, g.players[0].captured_count);
|
||||||
|
assert_eq!(1, g.players[0].scope);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_view_parses_pigliatutto() {
|
fn move_view_parses_pigliatutto() {
|
||||||
let mv: MoveView = serde_json::from_value(json!({
|
let mv: MoveView = serde_json::from_value(json!({
|
||||||
@@ -1021,4 +1240,184 @@ mod tests {
|
|||||||
assert!(g.default_options().is_empty());
|
assert!(g.default_options().is_empty());
|
||||||
assert_eq!(0, g.min_players);
|
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 is_individual_game_only_matches_two_player_briscola() {
|
||||||
|
assert!(is_individual_game("briscola"));
|
||||||
|
assert!(!is_individual_game("briscola_a_quattro"));
|
||||||
|
assert!(!is_individual_game("scopone_scientifico"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_name_for_team_resolves_names() {
|
||||||
|
let g: GameView = serde_json::from_value(json!({
|
||||||
|
"id": "b1",
|
||||||
|
"game_type": "briscola",
|
||||||
|
"phase": "playing",
|
||||||
|
"players": [
|
||||||
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
||||||
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B"},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("Alice", g.player_name_for_team("A"));
|
||||||
|
assert_eq!("Bob", g.player_name_for_team("B"));
|
||||||
|
// Unknown labels fall back to the label itself.
|
||||||
|
assert_eq!("C", g.player_name_for_team("C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn my_game_phase_and_seats_text() {
|
||||||
|
let g: MyGame = serde_json::from_value(json!({
|
||||||
|
"id": "g1",
|
||||||
|
"game_type": "briscola_a_quattro",
|
||||||
|
"game_name": "Briscola a quattro",
|
||||||
|
"phase": "lobby",
|
||||||
|
"players": [
|
||||||
|
{"name": "Alice", "seat": 0, "team": "A"},
|
||||||
|
{"name": "Bob", "seat": 1, "team": "B"},
|
||||||
|
],
|
||||||
|
"seats_open": 2,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("Waiting for players", g.phase_label());
|
||||||
|
assert_eq!("2/4 seated", g.seats_text());
|
||||||
|
assert_eq!("Alice, Bob", g.players_text());
|
||||||
|
|
||||||
|
let playing: MyGame = serde_json::from_value(json!({
|
||||||
|
"id": "g2",
|
||||||
|
"game_type": "briscola",
|
||||||
|
"phase": "playing",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("In progress", playing.phase_label());
|
||||||
|
assert_eq!("0 seated", playing.seats_text());
|
||||||
|
|
||||||
|
let between: MyGame = serde_json::from_value(json!({
|
||||||
|
"id": "g3",
|
||||||
|
"phase": "hand_end",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("Between hands", between.phase_label());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_game_types_cover_the_registered_games() {
|
||||||
|
let types = fallback_game_types();
|
||||||
|
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
// The lobby's first game (the creation default) must exist.
|
||||||
|
assert_eq!("scopone_scientifico", types[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//! 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, is_individual_game, suit_name, GameView, PlayerView, Scores,
|
||||||
|
};
|
||||||
|
use crate::pages::game::watch_link;
|
||||||
|
|
||||||
|
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||||
|
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_badge = (!is_individual_game(&game.game_type)).then(|| {
|
||||||
|
let team = player.team.clone();
|
||||||
|
view! { span(class="team-badge") { "Team " (team) } }
|
||||||
|
});
|
||||||
|
let points = player.points;
|
||||||
|
let captured = player.captured_count;
|
||||||
|
let backs = (0..player.cards_left)
|
||||||
|
.map(|_| card_back("mini"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class=cls) {
|
||||||
|
div(class="seat-name") {
|
||||||
|
(name)
|
||||||
|
(team_badge)
|
||||||
|
}
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
view! { (cards) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announcement strip for the most recently completed trick.
|
||||||
|
fn last_trick_banner(game: &GameView) -> View {
|
||||||
|
let Some(trick) = game.last_trick.clone() else {
|
||||||
|
return view! {};
|
||||||
|
};
|
||||||
|
let name = player_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::<Vec<_>>();
|
||||||
|
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<f64>,
|
||||||
|
) -> View {
|
||||||
|
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||||
|
// hand, so they watch from seat 0's perspective.
|
||||||
|
let viewer_seat = game
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.hand.is_some())
|
||||||
|
.map(|p| p.seat)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let 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;
|
||||||
|
// Two-player briscola has no partnerships: show the players' names.
|
||||||
|
let score_line = if is_individual_game(&game.game_type) {
|
||||||
|
format!(
|
||||||
|
"{} {} — {} {} (target {})",
|
||||||
|
game.player_name_for_team("A"),
|
||||||
|
scores.a,
|
||||||
|
scores.b,
|
||||||
|
game.player_name_for_team("B"),
|
||||||
|
target
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target)
|
||||||
|
};
|
||||||
|
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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") { (score_line) }
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
-60
@@ -1,16 +1,21 @@
|
|||||||
//! Live game page: table view over the websocket.
|
//! Live game page: table view over the websocket.
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::components::briscola::{deal_rows, deal_summary_modal};
|
||||||
use crate::components::card::{card_back, card_img};
|
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::summary::{hand_summary_modal, summary_rows};
|
||||||
use crate::components::toast::toast;
|
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, is_individual_game, GameView, MoveView,
|
||||||
|
PlayerView, Scores, ServerMessage,
|
||||||
|
};
|
||||||
|
use crate::pages::briscola::briscola_table;
|
||||||
use crate::ws::{self, GameSocket};
|
use crate::ws::{self, GameSocket};
|
||||||
|
|
||||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||||
@@ -19,6 +24,14 @@ fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<V
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The absolute, shareable URL of this game's page, for the clipboard.
|
||||||
|
pub(crate) fn watch_link(id: &str) -> String {
|
||||||
|
let origin = web_sys::window()
|
||||||
|
.and_then(|w| w.location().origin().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{origin}/game/{id}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Panel for one player seat (name, team, hidden card count, stats).
|
/// Panel for one player seat (name, team, hidden card count, stats).
|
||||||
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||||
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
|
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
|
||||||
@@ -154,11 +167,6 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
|||||||
let alive = alive.clone();
|
let alive = alive.clone();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::probe_game(&id).await {
|
match api::probe_game(&id).await {
|
||||||
api::GameEntry::Unauthenticated => {
|
|
||||||
ctx.fatal.set(true);
|
|
||||||
ctx.error
|
|
||||||
.set(Some("Session expired — please log in again.".to_string()));
|
|
||||||
}
|
|
||||||
api::GameEntry::Unavailable => {
|
api::GameEntry::Unavailable => {
|
||||||
ctx.fatal.set(true);
|
ctx.fatal.set(true);
|
||||||
ctx.error
|
ctx.error
|
||||||
@@ -384,6 +392,10 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
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
|
// The live table is scopone-specific; other games get a
|
||||||
// fallback until they ship their own play view.
|
// fallback until they ship their own play view.
|
||||||
Some(g) if g.game_type == "scopone_scientifico" => {
|
Some(g) if g.game_type == "scopone_scientifico" => {
|
||||||
@@ -398,6 +410,9 @@ pub fn GamePage(id: String) -> View {
|
|||||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||||
hand_summary_modal(g, socket, now)
|
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! {},
|
_ => view! {},
|
||||||
})
|
})
|
||||||
(move || game_over_view(over.get_clone(), game.get_clone()))
|
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||||||
@@ -407,52 +422,34 @@ pub fn GamePage(id: String) -> View {
|
|||||||
|
|
||||||
/// Lobby view while waiting for the remaining players. The seat count
|
/// Lobby view while waiting for the remaining players. The seat count
|
||||||
/// comes from the payload (`seats_open`), so games with other player
|
/// comes from the payload (`seats_open`), so games with other player
|
||||||
/// counts render correctly.
|
/// counts render correctly. Spectators (no seat, no join code) get a
|
||||||
|
/// "not started yet" note and the watch link instead.
|
||||||
#[component(inline_props)]
|
#[component(inline_props)]
|
||||||
fn LobbyView(game: GameView) -> View {
|
fn LobbyView(game: GameView) -> View {
|
||||||
let seats_open = game
|
let seats_open = game
|
||||||
.seats_open
|
.seats_open
|
||||||
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
||||||
|
let spectator = game.spectator;
|
||||||
let join_code = game.join_code.clone();
|
let join_code = game.join_code.clone();
|
||||||
let copied = create_signal(false);
|
let watch_link = watch_link(&game.id);
|
||||||
// Held so the reset timer is cancelled if the waiting room is rebuilt
|
|
||||||
// (e.g. another player joins) while the "Copied!" feedback is showing:
|
|
||||||
// firing it afterwards would set a disposed signal and panic.
|
|
||||||
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
|
|
||||||
Rc::new(RefCell::new(None));
|
|
||||||
on_cleanup({
|
|
||||||
let reset_timer = reset_timer.clone();
|
|
||||||
move || {
|
|
||||||
reset_timer.borrow_mut().take();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let copy = {
|
|
||||||
let join_code = join_code.clone();
|
|
||||||
move |_| {
|
|
||||||
if let Some(win) = web_sys::window() {
|
|
||||||
let _ = win.navigator().clipboard().write_text(&join_code);
|
|
||||||
}
|
|
||||||
copied.set(true);
|
|
||||||
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(1500, move || {
|
|
||||||
copied.set(false);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
let show_teams = !is_individual_game(&game.game_type);
|
||||||
let players = game
|
let players = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let name = p.name.clone();
|
let name = p.name.clone();
|
||||||
let seat = p.seat;
|
let seat = p.seat;
|
||||||
|
let team_badge = show_teams.then(|| {
|
||||||
let team = p.team.clone();
|
let team = p.team.clone();
|
||||||
|
view! { span(class="badge text-bg-info") { "Team " (team) } }
|
||||||
|
});
|
||||||
view! {
|
view! {
|
||||||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||||
strong { (name) }
|
strong { (name) }
|
||||||
span {
|
span {
|
||||||
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
|
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
|
||||||
span(class="badge text-bg-info") { "Team " (team) }
|
(team_badge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,18 +469,39 @@ fn LobbyView(game: GameView) -> View {
|
|||||||
};
|
};
|
||||||
let percent_style = format!("width: {percent}%");
|
let percent_style = format!("width: {percent}%");
|
||||||
|
|
||||||
|
// The server withholds the join code from spectators; they get a
|
||||||
|
// "not started yet" note and can share the watch link instead.
|
||||||
|
let share = if spectator {
|
||||||
|
view! {
|
||||||
|
p(class="text-secondary") {
|
||||||
|
"This game hasn't started yet — waiting for the players to take their seats."
|
||||||
|
}
|
||||||
|
div(class="d-grid mb-4") {
|
||||||
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Separate clones: each interpolation/prop captures its own copy.
|
||||||
|
let display_code = join_code.clone();
|
||||||
|
let copy_code = join_code.clone();
|
||||||
|
view! {
|
||||||
|
p(class="text-secondary") { "Share this join code with your friends:" }
|
||||||
|
p(class="code-display") { (display_code) }
|
||||||
|
div(class="d-grid gap-2 mb-4") {
|
||||||
|
CopyButton(text=copy_code, label="Copy code".to_string())
|
||||||
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
div(class="container page-shell py-4") {
|
div(class="container page-shell py-4") {
|
||||||
div(class="card status-card") {
|
div(class="card status-card") {
|
||||||
div(class="card-body p-4") {
|
div(class="card-body p-4") {
|
||||||
h1(class="h4 mb-3") { "Waiting for players" }
|
h1(class="h4 mb-3") {
|
||||||
p(class="text-secondary") { "Share this join code with your friends:" }
|
(if spectator { "Game not started yet" } else { "Waiting for players" })
|
||||||
p(class="code-display") { (join_code) }
|
|
||||||
div(class="d-grid mb-4") {
|
|
||||||
button(class="btn btn-outline-light", on:click=copy) {
|
|
||||||
(move || if copied.get() { "Copied!" } else { "Copy code" })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
(share)
|
||||||
ul(class="list-group list-group-flush text-start mb-3") {
|
ul(class="list-group list-group-flush text-start mb-3") {
|
||||||
(players)
|
(players)
|
||||||
(open_seats)
|
(open_seats)
|
||||||
@@ -536,7 +554,8 @@ fn table_view(
|
|||||||
selected: Signal<Option<String>>,
|
selected: Signal<Option<String>>,
|
||||||
now: Signal<f64>,
|
now: Signal<f64>,
|
||||||
) -> View {
|
) -> View {
|
||||||
// Own seat: the only player entry carrying a hand.
|
// 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
|
let viewer_seat = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
@@ -622,18 +641,41 @@ fn table_view(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spectators have no hand: render the pivot seat as a regular panel
|
||||||
|
// so its captured count and scope 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_captured) " captured · " (my_scope) " scope"
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
div(class="table-wrap") {
|
div(class="table-wrap") {
|
||||||
div(class="hud") {
|
div(class="hud") {
|
||||||
a(href="/") { "← Lobby" }
|
a(href="/") { "← Lobby" }
|
||||||
span { "Hand " (hand_number) }
|
span { "Hand " (hand_number) }
|
||||||
(rule_badge)
|
(rule_badge)
|
||||||
|
(spectator_badge)
|
||||||
span(class="hud-scores") {
|
span(class="hud-scores") {
|
||||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
||||||
(target_score) ")"
|
(target_score) ")"
|
||||||
}
|
}
|
||||||
span(class=turn_cls) { (turn_note) }
|
span(class=turn_cls) { (turn_note) }
|
||||||
(countdown)
|
(countdown)
|
||||||
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
}
|
}
|
||||||
div(class="table-grid") {
|
div(class="table-grid") {
|
||||||
(top)
|
(top)
|
||||||
@@ -646,13 +688,7 @@ fn table_view(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(right)
|
(right)
|
||||||
div(class="seat-bottom") {
|
(bottom)
|
||||||
div(class="seat-stats own-stats") {
|
|
||||||
(my_captured) " captured · " (my_scope) " scope"
|
|
||||||
}
|
|
||||||
div(class="hand") { (hand_cards) }
|
|
||||||
(hint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,11 +731,11 @@ fn capture_picker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End-of-match overlay (scopone's team score; other games render
|
/// End-of-match overlay: the final hand/deal breakdown and the winner.
|
||||||
/// nothing until they ship their own play view).
|
|
||||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||||
|
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
|
||||||
if let Some(g) = &game {
|
if let Some(g) = &game {
|
||||||
if g.game_type != "scopone_scientifico" {
|
if g.game_type != "scopone_scientifico" && !briscola {
|
||||||
return view! {};
|
return view! {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,13 +748,34 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
|||||||
None => view! {},
|
None => view! {},
|
||||||
Some((scores, winner)) => {
|
Some((scores, winner)) => {
|
||||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||||
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
// Two-player briscola has no teams: name the winner instead.
|
||||||
// Explain the final hand's scoring before the result.
|
let winner_label = match game.as_ref() {
|
||||||
let final_summary = game
|
Some(g) if is_individual_game(&g.game_type) => {
|
||||||
.and_then(|g| g.last_hand)
|
g.player_name_for_team(&winner)
|
||||||
.map(|s| {
|
}
|
||||||
let rows = summary_rows(s);
|
_ => format!("Team {winner}"),
|
||||||
view! { (rows) }
|
};
|
||||||
|
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
|
||||||
|
// Spectators have no match history to look at.
|
||||||
|
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||||
|
// Explain the final hand's (scopone) or deal's (briscola)
|
||||||
|
// scoring before the result.
|
||||||
|
let final_summary: View = if briscola {
|
||||||
|
match game.as_ref() {
|
||||||
|
Some(g) => match g.last_deal.clone() {
|
||||||
|
Some(deal) => deal_rows(g, &deal),
|
||||||
|
None => view! {},
|
||||||
|
},
|
||||||
|
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" }
|
||||||
});
|
});
|
||||||
view! {
|
view! {
|
||||||
div(class="overlay") {
|
div(class="overlay") {
|
||||||
@@ -728,7 +785,7 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
|||||||
p(class="final-score") { (line) }
|
p(class="final-score") { (line) }
|
||||||
div(class="gameover-actions") {
|
div(class="gameover-actions") {
|
||||||
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
||||||
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
(my_matches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,53 @@
|
|||||||
//! Global leaderboard page.
|
//! Per-game leaderboard page.
|
||||||
|
//!
|
||||||
|
//! Elo ratings are stored per game type, so the board is scoped to the
|
||||||
|
//! game chosen in the dropdown: Elo, wins, matches and points all come
|
||||||
|
//! from that game's matches only.
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{fmt_points, LeaderboardPage};
|
use crate::model::{fallback_game_types, fmt_points, LeaderboardPage};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LeaderboardPage() -> View {
|
pub fn LeaderboardPage() -> View {
|
||||||
let page = create_signal(Option::<LeaderboardPage>::None);
|
let page = create_signal(Option::<LeaderboardPage>::None);
|
||||||
let error = create_signal(Option::<String>::None);
|
let error = create_signal(Option::<String>::None);
|
||||||
|
let game_types = create_signal(fallback_game_types());
|
||||||
|
let selected_game = create_signal("scopone_scientifico".to_string());
|
||||||
|
|
||||||
|
// Reload whenever the selection changes; a response for a game the
|
||||||
|
// user has already switched away from is dropped.
|
||||||
|
create_effect(move || {
|
||||||
|
let game_type = selected_game.get_clone();
|
||||||
|
page.set(None);
|
||||||
|
spawn_local(async move {
|
||||||
|
match api::leaderboard(&game_type).await {
|
||||||
|
Ok(p) => {
|
||||||
|
if selected_game.get_clone() == game_type {
|
||||||
|
page.set(Some(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if selected_game.get_clone() == game_type {
|
||||||
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::leaderboard().await {
|
match api::game_types().await {
|
||||||
Ok(p) => page.set(Some(p)),
|
Ok(types) if !types.is_empty() => {
|
||||||
Err(e) => error.set(Some(e)),
|
let current = selected_game.get_clone();
|
||||||
|
if !types.iter().any(|g| g.id == current) {
|
||||||
|
selected_game.set(types[0].id.clone());
|
||||||
|
}
|
||||||
|
game_types.set(types);
|
||||||
|
}
|
||||||
|
_ => {} // keep the fallback list
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,7 +55,30 @@ pub fn LeaderboardPage() -> View {
|
|||||||
div {
|
div {
|
||||||
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
||||||
div(class="container page-shell py-4") {
|
div(class="container page-shell py-4") {
|
||||||
h1(class="h3 mb-4") { "Leaderboard" }
|
div(
|
||||||
|
class="d-flex flex-wrap gap-3 align-items-center \
|
||||||
|
justify-content-between mb-4"
|
||||||
|
) {
|
||||||
|
h1(class="h3 mb-0") { "Leaderboard" }
|
||||||
|
div(class="d-flex align-items-center gap-2") {
|
||||||
|
label(class="form-label mb-0", r#for="leaderboard-game") {
|
||||||
|
"Game"
|
||||||
|
}
|
||||||
|
select(
|
||||||
|
class="form-select w-auto",
|
||||||
|
id="leaderboard-game",
|
||||||
|
bind:value=selected_game,
|
||||||
|
) {
|
||||||
|
Keyed(
|
||||||
|
list=game_types,
|
||||||
|
view=|g| view! {
|
||||||
|
option(value=g.id.clone()) { (g.name) }
|
||||||
|
},
|
||||||
|
key=|g| g.id.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
(toast(error))
|
(toast(error))
|
||||||
(move || match page.get_clone() {
|
(move || match page.get_clone() {
|
||||||
None => view! {
|
None => view! {
|
||||||
|
|||||||
+78
-16
@@ -15,21 +15,10 @@ use crate::api;
|
|||||||
use crate::components::card::card_back;
|
use crate::components::card::card_back;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User,
|
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||||
|
MyGame, OptionField, OptionKind, PlayerRating, User,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Used when the game-types fetch fails: match creation must still work.
|
|
||||||
fn fallback_game_types() -> Vec<GameTypeInfo> {
|
|
||||||
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,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Human-readable label for a provider name ("google" -> "Google").
|
/// Human-readable label for a provider name ("google" -> "Google").
|
||||||
fn provider_label(name: &str) -> String {
|
fn provider_label(name: &str) -> String {
|
||||||
let mut chars = name.chars();
|
let mut chars = name.chars();
|
||||||
@@ -248,15 +237,20 @@ fn join_preview_panel(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let seated = p.players.len();
|
let seated = p.players.len();
|
||||||
|
let show_teams = !is_individual_game(&p.game_type);
|
||||||
let players = p
|
let players = p
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|player| {
|
.map(|player| {
|
||||||
let name = player.name.clone();
|
let name = player.name.clone();
|
||||||
let team = player.team.as_ref().map(|t| {
|
let team = if show_teams {
|
||||||
|
player.team.as_ref().map(|t| {
|
||||||
let label = t.clone();
|
let label = t.clone();
|
||||||
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
||||||
});
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
view! {
|
view! {
|
||||||
li(
|
li(
|
||||||
class="list-group-item d-flex justify-content-between \
|
class="list-group-item d-flex justify-content-between \
|
||||||
@@ -298,6 +292,8 @@ pub fn LobbyPage() -> View {
|
|||||||
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
||||||
// The player's Elo ratings, one row per game type played.
|
// The player's Elo ratings, one row per game type played.
|
||||||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||||||
|
// Live games the player is seated in, for the "Continue" list.
|
||||||
|
let my_games = create_signal(Vec::<MyGame>::new());
|
||||||
let error = create_signal(Option::<String>::None);
|
let error = create_signal(Option::<String>::None);
|
||||||
let code = create_signal(String::new());
|
let code = create_signal(String::new());
|
||||||
let game_types = create_signal(fallback_game_types());
|
let game_types = create_signal(fallback_game_types());
|
||||||
@@ -392,6 +388,9 @@ pub fn LobbyPage() -> View {
|
|||||||
if let Ok(p) = api::my_ratings().await {
|
if let Ok(p) = api::my_ratings().await {
|
||||||
ratings.set(p.results);
|
ratings.set(p.results);
|
||||||
}
|
}
|
||||||
|
if let Ok(games) = api::my_games().await {
|
||||||
|
my_games.set(games);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
user.set(Some(me));
|
user.set(Some(me));
|
||||||
@@ -552,9 +551,72 @@ pub fn LobbyPage() -> View {
|
|||||||
div(class="lobby-hero text-center") {
|
div(class="lobby-hero text-center") {
|
||||||
h1(class="display-5 fw-bold") { "tavolo" }
|
h1(class="display-5 fw-bold") { "tavolo" }
|
||||||
p(class="lead text-secondary mb-0") {
|
p(class="lead text-secondary mb-0") {
|
||||||
"Play scopone scientifico with friends."
|
"Play scopone scientifico and briscola with friends."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ongoing games: a "Continue" link back into a
|
||||||
|
// match whose page was accidentally closed.
|
||||||
|
(move || {
|
||||||
|
let games = my_games.get_clone();
|
||||||
|
if games.is_empty() {
|
||||||
|
return view! {};
|
||||||
|
}
|
||||||
|
let types = game_types.get_clone();
|
||||||
|
let rows = games
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| {
|
||||||
|
let name = if g.game_name.is_empty() {
|
||||||
|
types
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == g.game_type)
|
||||||
|
.map(|t| t.name.clone())
|
||||||
|
.unwrap_or_else(|| g.game_type.clone())
|
||||||
|
} else {
|
||||||
|
g.game_name.clone()
|
||||||
|
};
|
||||||
|
let phase = g.phase_label();
|
||||||
|
let seats = g.seats_text();
|
||||||
|
let players = g.players_text();
|
||||||
|
let players_note = if players.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" · {players}")
|
||||||
|
};
|
||||||
|
let href = format!("/game/{}", g.id);
|
||||||
|
view! {
|
||||||
|
li(
|
||||||
|
class="list-group-item d-flex flex-wrap \
|
||||||
|
justify-content-between \
|
||||||
|
align-items-center gap-2"
|
||||||
|
) {
|
||||||
|
div {
|
||||||
|
div(class="fw-semibold") { (name) }
|
||||||
|
div(class="text-secondary small") {
|
||||||
|
(phase) " · " (seats)
|
||||||
|
(players_note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
class="btn btn-sm btn-warning",
|
||||||
|
href=href,
|
||||||
|
) {
|
||||||
|
"Continue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class="card mb-4") {
|
||||||
|
div(class="card-header") { "Your games" }
|
||||||
|
div(class="card-body p-0") {
|
||||||
|
ul(class="list-group list-group-flush") {
|
||||||
|
(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
div(class="row g-4") {
|
div(class="row g-4") {
|
||||||
div(class="col-lg-7") {
|
div(class="col-lg-7") {
|
||||||
div(class="card h-100") {
|
div(class="card h-100") {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod briscola;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod leaderboard;
|
pub mod leaderboard;
|
||||||
|
|||||||
+8
-2
@@ -83,8 +83,14 @@ pub fn connect(
|
|||||||
while let Some(msg) = read.next().await {
|
while let Some(msg) = read.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(Message::Text(text)) => {
|
Ok(Message::Text(text)) => {
|
||||||
if let Ok(parsed) = serde_json::from_str::<ServerMessage>(&text) {
|
match serde_json::from_str::<ServerMessage>(&text) {
|
||||||
on_message(parsed);
|
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(_)) => {}
|
Ok(Message::Bytes(_)) => {}
|
||||||
|
|||||||
+209
-7
@@ -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,
|
* The felt palette and the Bootstrap theme overrides live in theme.css,
|
||||||
* which is loaded before this file. */
|
* which is loaded before this file. */
|
||||||
@@ -37,7 +37,8 @@ body {
|
|||||||
|
|
||||||
.hud {
|
.hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
@@ -76,7 +77,10 @@ body {
|
|||||||
.table-grid {
|
.table-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2.4fr 1fr;
|
/* minmax(0, …) lets the tracks shrink below their content's
|
||||||
|
* min-content width; without it the center's move banner and the
|
||||||
|
* seat panels push the grid wider than the screen on phones. */
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2.4fr) minmax(0, 1fr);
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
". top ."
|
". top ."
|
||||||
@@ -114,6 +118,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-height: 116px;
|
min-height: 116px;
|
||||||
|
min-width: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -126,7 +131,8 @@ body {
|
|||||||
.seat-name {
|
.seat-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.15rem 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -167,6 +173,7 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 230px;
|
min-height: 230px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-cards {
|
.table-cards {
|
||||||
@@ -240,8 +247,11 @@ body {
|
|||||||
|
|
||||||
.move-banner {
|
.move-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
max-width: 100%;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.35rem 1rem;
|
padding: 0.35rem 1rem;
|
||||||
@@ -266,6 +276,15 @@ body {
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spectator-badge {
|
||||||
|
background: var(--panel-light);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- overlays ---------- */
|
/* ---------- overlays ---------- */
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -283,7 +302,8 @@ body {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 320px;
|
min-width: min(320px, calc(100vw - 1.5rem));
|
||||||
|
max-width: calc(100vw - 1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-options {
|
.capture-options {
|
||||||
@@ -318,8 +338,8 @@ body {
|
|||||||
/* ---------- hand-end scoring summary ---------- */
|
/* ---------- hand-end scoring summary ---------- */
|
||||||
|
|
||||||
.summary-panel {
|
.summary-panel {
|
||||||
min-width: 420px;
|
min-width: min(420px, calc(100vw - 1.5rem));
|
||||||
max-width: 560px;
|
max-width: min(560px, calc(100vw - 1.5rem));
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,3 +485,185 @@ body {
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
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: -18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- phones ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.hud {
|
||||||
|
gap: 0.3rem 0.75rem;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-grid {
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat {
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 0.35rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-stats {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex-wrap lets the hidden-hand backs drop to the next row instead
|
||||||
|
* of forcing the seat wider than its track. */
|
||||||
|
.seat-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
min-height: 180px;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-banner {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.mini {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.table-card {
|
||||||
|
height: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.move-card {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.hand-card {
|
||||||
|
height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand {
|
||||||
|
min-height: 100px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.stock-card {
|
||||||
|
height: 38px;
|
||||||
|
margin-left: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.briscola-card {
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row {
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-icon {
|
||||||
|
width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.score-mini {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
grid-template-columns: 3.5rem 1fr 3.2rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user