Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6932a3272c
|
||
|
|
89cf0a4c96
|
||
|
|
d9cdba33a1
|
@@ -23,7 +23,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gitea.woggioni.net/woggioni/scopa
|
||||
images: gitea.woggioni.net/woggioni/tavolo
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=match,pattern=release/(.*),group=1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# scopa
|
||||
# tavolo
|
||||
|
||||
Multiplayer **scopone scientifico** — the four-player, fixed-partnership
|
||||
Italian card game — as a web application:
|
||||
A platform for multiplayer card games. The first game is **scopone
|
||||
scientifico** — the four-player, fixed-partnership Italian card game:
|
||||
|
||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||
@@ -22,12 +22,28 @@ Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`,
|
||||
frontend and API — listens on `http://127.0.0.1:8080`.
|
||||
|
||||
Because both the browser and the app talk to the OIDC issuer at
|
||||
`http://mockoauth:8180/scopa`, add a host entry once:
|
||||
`http://mockoauth:8180/tavolo`, add a host entry once:
|
||||
|
||||
```sh
|
||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
## Between hands
|
||||
|
||||
When a hand ends but the match is not decided, the game pauses on a
|
||||
**scoring summary screen**: every player sees how each category was won
|
||||
(carte, denara, settebello, primiera, scope) with the running totals and
|
||||
must click "Understood" before the next hand is dealt. If someone is away
|
||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||
(default 30s). The match-ending hand is explained on the final screen.
|
||||
|
||||
## On your turn
|
||||
|
||||
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
||||
player does not move — disconnected or fallen asleep — the server plays a
|
||||
random legal card for them (randomizing among the legal captures when the
|
||||
rules require a capture), so one absent player cannot stall the table.
|
||||
|
||||
## Development
|
||||
|
||||
Backend (from `server/`):
|
||||
@@ -55,7 +71,7 @@ run the backend with:
|
||||
```sh
|
||||
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||
.venv/bin/granian --host 127.0.0.1 --port 8080 scopa.app:app
|
||||
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||||
```
|
||||
|
||||
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||||
|
||||
+13
-11
@@ -2,15 +2,15 @@ services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_DB: scopa
|
||||
POSTGRES_USER: scopa
|
||||
POSTGRES_PASSWORD: scopa
|
||||
POSTGRES_DB: tavolo
|
||||
POSTGRES_USER: tavolo
|
||||
POSTGRES_PASSWORD: tavolo
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U scopa"]
|
||||
test: ["CMD-SHELL", "pg_isready -U tavolo"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
# The mock does not validate clients, so any client id/secret works.
|
||||
#
|
||||
# It listens on 8180 both inside and outside the network so the OIDC
|
||||
# issuer URL (http://mockoauth:8180/scopa) is identical for
|
||||
# issuer URL (http://mockoauth:8180/tavolo) is identical for
|
||||
# container-to-container calls and for browser redirects (via the
|
||||
# /etc/hosts entry documented in the README).
|
||||
mockoauth:
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
- mockoauth
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
until curl -sf http://mockoauth:8180/scopa/.well-known/openid-configuration > /dev/null; do
|
||||
until curl -sf http://mockoauth:8180/tavolo/.well-known/openid-configuration > /dev/null; do
|
||||
echo 'waiting for mockoauth...'; sleep 2;
|
||||
done
|
||||
"
|
||||
@@ -71,12 +71,12 @@ services:
|
||||
working_dir: /app
|
||||
command: ["aerich", "upgrade"]
|
||||
environment:
|
||||
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
|
||||
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
scopa:
|
||||
tavolo:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
@@ -90,16 +90,18 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
|
||||
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
|
||||
# By default the app and browsers reach the mock IdP under the same
|
||||
# name (see README /etc/hosts note); override OIDC_ISSUER and
|
||||
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/scopa}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
|
||||
# The mock OIDC server does not validate clients: any id/secret works.
|
||||
OIDC_CLIENT_ID: scopa
|
||||
OIDC_CLIENT_ID: tavolo
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||
|
||||
|
||||
+14
-6
@@ -1,15 +1,15 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=scopa
|
||||
POSTGRES_USER=scopa
|
||||
POSTGRES_PASSWORD=scopa
|
||||
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa
|
||||
POSTGRES_DB=tavolo
|
||||
POSTGRES_USER=tavolo
|
||||
POSTGRES_PASSWORD=tavolo
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||
|
||||
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any
|
||||
# client id/secret works. For a real IdP like Keycloak, use its values here.)
|
||||
OIDC_ISSUER=http://localhost:8180/scopa
|
||||
OIDC_CLIENT_ID=scopa
|
||||
OIDC_ISSUER=http://localhost:8180/tavolo
|
||||
OIDC_CLIENT_ID=tavolo
|
||||
OIDC_CLIENT_SECRET=dev-secret
|
||||
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
|
||||
|
||||
@@ -20,6 +20,14 @@ REDIS_URL=redis://localhost:6379/0
|
||||
# How long a live game survives in Redis without activity.
|
||||
GAME_TTL_SECONDS=86400
|
||||
|
||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||
# before dealing the next hand anyway.
|
||||
HAND_ACK_TIMEOUT_SECONDS=30
|
||||
|
||||
# Seconds a player has to play before the server plays a random legal card
|
||||
# for them (covers disconnects and idle players).
|
||||
TURN_TIMEOUT_SECONDS=30
|
||||
|
||||
# App server
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Multi-stage build for the scopa stack (kaya backend + Sycamore/WASM
|
||||
# Multi-stage build for the tavolo stack (kaya backend + Sycamore/WASM
|
||||
# frontend). The Docker build context is the REPOSITORY ROOT (see
|
||||
# docker-compose.yml) so this single image assembles both parts:
|
||||
#
|
||||
@@ -86,4 +86,4 @@ EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
|
||||
|
||||
CMD ["granian", "scopa.app:app"]
|
||||
CMD ["granian", "tavolo.app:app"]
|
||||
|
||||
+42
-16
@@ -1,8 +1,9 @@
|
||||
# scopa
|
||||
# tavolo
|
||||
|
||||
A multiplayer backend for **scopone scientifico** (the four-player,
|
||||
fixed-partnership variant of the classic Italian card game), built on the
|
||||
[kaya](../kaya) framework.
|
||||
The backend for a multiplayer card-game platform, built on the
|
||||
[kaya](../kaya) framework. The first game implemented is **scopone
|
||||
scientifico**, the four-player, fixed-partnership variant of the classic
|
||||
Italian card game.
|
||||
|
||||
Players authenticate with the configured **OIDC** provider. Live game state
|
||||
is kept in **Redis** (with real-time play over WebSocket), and completed
|
||||
@@ -24,7 +25,7 @@ serves the Sycamore/WASM frontend (built from `../web/` by the Docker
|
||||
image), so the UI is available at that address.
|
||||
|
||||
Because the browser and the app both talk to the OIDC issuer at
|
||||
`http://mockoauth:8180/scopa`, add a host entry once:
|
||||
`http://mockoauth:8180/tavolo`, add a host entry once:
|
||||
|
||||
```sh
|
||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||
@@ -43,26 +44,28 @@ All configuration comes from environment variables (see `.env.example`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://scopa:scopa@localhost:5432/scopa` | Postgres DSN for match statistics |
|
||||
| `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics |
|
||||
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
|
||||
| `OIDC_ISSUER` | `http://localhost:8180/scopa` | OIDC issuer URL |
|
||||
| `OIDC_CLIENT_ID` | `scopa` | OIDC client id |
|
||||
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
|
||||
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
|
||||
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||
|
||||
## Data model
|
||||
|
||||
### Redis (live games)
|
||||
|
||||
- `scopa:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
|
||||
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
|
||||
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
|
||||
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
|
||||
(`GAME_TTL_SECONDS`).
|
||||
- `scopa:code:<JOINCODE>` — the 6-character join code → game id index.
|
||||
- `scopa:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
||||
- `scopa:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
||||
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
|
||||
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
||||
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
||||
signals; every open WebSocket reloads the state and pushes the
|
||||
personalized view to its player.
|
||||
|
||||
@@ -109,6 +112,7 @@ Client → server messages:
|
||||
```json
|
||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||
{"action": "play", "card": "07D"}
|
||||
{"action": "ack"}
|
||||
{"action": "state"}
|
||||
```
|
||||
|
||||
@@ -117,10 +121,32 @@ Client → server messages:
|
||||
settebello).
|
||||
- `capture` lists the table cards to take. When a capture is legal it is
|
||||
mandatory to provide one; when no capture exists it must be omitted.
|
||||
- `ack` acknowledges the hand-end scoring summary (see below). The next
|
||||
hand is dealt once all four players have acknowledged, or automatically
|
||||
after `HAND_ACK_TIMEOUT_SECONDS`.
|
||||
- `state` asks for a fresh snapshot.
|
||||
|
||||
After every accepted move the new state is broadcast to all four players.
|
||||
|
||||
### Turn timeout
|
||||
|
||||
The state carries a `turn_deadline` while a hand is being played. If the
|
||||
player on turn does not move before it, the server plays a random legal
|
||||
card for them (picking one of the legal captures at random when a capture
|
||||
is required), so a disconnected or idle player cannot stall the match. The
|
||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other.
|
||||
|
||||
### Hand-end summary
|
||||
|
||||
When a hand finishes but the match continues, the game enters the
|
||||
`hand_end` phase instead of dealing immediately: the state carries
|
||||
`last_hand` (a full scoring breakdown with an `award` map naming the team
|
||||
that won each category), the `acknowledged` seats and a
|
||||
`hand_end_deadline`. The frontend renders this as a screen every player
|
||||
must dismiss. A play attempted in this phase is rejected with an
|
||||
`illegal_move` error.
|
||||
|
||||
## Rules implemented
|
||||
|
||||
- 40-card Italian deck, ten cards per player, empty table at hand start.
|
||||
@@ -153,11 +179,11 @@ for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
|
||||
|
||||
The Postgres schema is owned by aerich migrations in `migrations/`. The
|
||||
`db-migrate` compose service runs `aerich upgrade` before the app starts.
|
||||
To add a migration after changing `src/scopa/models.py`:
|
||||
To add a migration after changing `src/tavolo/models.py`:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate
|
||||
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich upgrade
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
|
||||
```
|
||||
|
||||
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
||||
@@ -169,7 +195,7 @@ baseline.)
|
||||
Everything lives under `server/`:
|
||||
|
||||
```
|
||||
src/scopa/
|
||||
src/tavolo/
|
||||
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
|
||||
├── config.py # env -> frozen Settings
|
||||
├── auth.py # auth helpers (HTTP + WebSocket)
|
||||
|
||||
+3
-3
@@ -3,14 +3,14 @@
|
||||
Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``)
|
||||
so they need no running Postgres, and with ``REDIS_URL`` unset so sessions
|
||||
and live games use their in-memory stores. The environment is set before
|
||||
:mod:`scopa.app` is imported by the test modules.
|
||||
:mod:`tavolo.app` is imported by the test modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "scopa")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||
os.environ.pop("REDIS_URL", None)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Dev OIDC provider: navikt/mock-oauth2-server with the scopa test
|
||||
# Dev OIDC provider: navikt/mock-oauth2-server with the tavolo test
|
||||
# configuration (four ready-made players) baked in. The config is
|
||||
# COPYed instead of bind-mounted so this also works against containerized
|
||||
# (e.g. rootless/DinD) docker daemons that cannot see the host workspace.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"interactiveLogin": true,
|
||||
"tokenCallbacks": [
|
||||
{
|
||||
"issuerId": "scopa",
|
||||
"issuerId": "tavolo",
|
||||
"tokenExpiry": 3600,
|
||||
"requestMappings": [
|
||||
{
|
||||
|
||||
@@ -3,9 +3,9 @@ requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "scopa"
|
||||
name = "tavolo"
|
||||
version = "0.1.0"
|
||||
description = "Scopone scientifico multiplayer backend built on the kaya framework"
|
||||
description = "Multiplayer card-game platform backend built on the kaya framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
@@ -37,7 +37,7 @@ namespaces = false
|
||||
|
||||
# Database migrations (aerich). See the Migrations section in README.md.
|
||||
[tool.aerich]
|
||||
tortoise_orm = "scopa.aerich_config.TORTOISE_ORM"
|
||||
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
||||
location = "./migrations"
|
||||
|
||||
[tool.mypy]
|
||||
@@ -49,13 +49,13 @@ plugins = []
|
||||
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
|
||||
# only declare the relation field. These are real attributes, not bugs.
|
||||
[[tool.mypy.overrides]]
|
||||
module = "scopa.models"
|
||||
module = "tavolo.models"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "scopa.routes.*"
|
||||
module = "tavolo.routes.*"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "scopa.game.*"
|
||||
module = "tavolo.game.*"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
+14
-14
@@ -8,7 +8,7 @@
|
||||
--extra-index-url https://pypi.org/simple
|
||||
|
||||
aerich==0.10.1
|
||||
# via scopa (pyproject.toml)
|
||||
# via tavolo (pyproject.toml)
|
||||
aiosqlite==0.22.1
|
||||
# via tortoise-orm
|
||||
anyio==4.15.1
|
||||
@@ -19,7 +19,7 @@ anyio==4.15.1
|
||||
asyncclick==8.4.2.1
|
||||
# via aerich
|
||||
asyncpg==0.31.0
|
||||
# via scopa (pyproject.toml)
|
||||
# via tavolo (pyproject.toml)
|
||||
certifi==2026.7.22
|
||||
# via
|
||||
# httpcore
|
||||
@@ -35,7 +35,7 @@ dictdiffer==0.10.0
|
||||
granian==2.8.3
|
||||
# via
|
||||
# kaya-rsgi
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via httpcore
|
||||
httpcore==1.0.9
|
||||
@@ -43,7 +43,7 @@ httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
# via
|
||||
# kaya-oidc
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
idna==3.19
|
||||
# via
|
||||
# anyio
|
||||
@@ -56,42 +56,42 @@ kaya-core==0.0.3
|
||||
# kaya-openapi
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
kaya-oidc==0.0.3
|
||||
# via scopa (pyproject.toml)
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-openapi==0.0.3
|
||||
# via scopa (pyproject.toml)
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-rsgi==0.0.3
|
||||
# via scopa (pyproject.toml)
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-session==0.0.3
|
||||
# via
|
||||
# kaya-oidc
|
||||
# kaya-session-redis
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
kaya-session-redis==0.0.3
|
||||
# via scopa (pyproject.toml)
|
||||
# via tavolo (pyproject.toml)
|
||||
pwo==0.1.2
|
||||
# via
|
||||
# kaya-core
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
pycparser==3.0
|
||||
# via cffi
|
||||
pyjwt[crypto]==2.14.0
|
||||
# via
|
||||
# kaya-oidc
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
pypika-tortoise==0.6.5
|
||||
# via tortoise-orm
|
||||
redis==8.1.0
|
||||
# via
|
||||
# kaya-session-redis
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
tortoise-orm==1.1.8
|
||||
# via
|
||||
# aerich
|
||||
# scopa (pyproject.toml)
|
||||
# tavolo (pyproject.toml)
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# anyio
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"""WebSocket endpoint for live play.
|
||||
|
||||
Clients connect to ``/ws/games/{game_id}`` using their session cookie (the
|
||||
OIDC login stores the user in the session, which the session mixin loads
|
||||
onto the websocket). Only seated players are accepted.
|
||||
|
||||
Protocol
|
||||
--------
|
||||
Server -> client messages are JSON objects with a ``type``:
|
||||
|
||||
* ``state`` — the personalized game view (own hand visible, others hidden).
|
||||
* ``game_over`` — sent once when the match ends, with the final scores.
|
||||
* ``error`` — a rejected action or malformed message.
|
||||
|
||||
Client -> server messages are JSON objects::
|
||||
|
||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||
{"action": "play", "card": "07D"}
|
||||
{"action": "state"}
|
||||
|
||||
``capture`` lists the table cards to take and must be a legal capture when
|
||||
one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted
|
||||
when the played card cannot capture.
|
||||
|
||||
Mutations run under the per-game lock; after a successful move the new
|
||||
state is saved to Redis and a change signal is published. Every connected
|
||||
websocket is subscribed to that signal and re-renders the state, so all
|
||||
players see the move immediately (and consistently across workers).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from kaya.core import WebSocket
|
||||
|
||||
from . import auth
|
||||
from .app import app, game_store
|
||||
from .game import engine
|
||||
from .game.errors import GameError
|
||||
from .game.state import PHASE_FINISHED, GameState
|
||||
from .stats import save_match_result
|
||||
|
||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
||||
return {"type": "error", "code": code, "message": message}
|
||||
|
||||
|
||||
def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
|
||||
return {"type": "state", "game": engine.state_for_player(state, sub)}
|
||||
|
||||
|
||||
@app.websocket("/ws/games/${game_id}")
|
||||
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||
user = auth.get_ws_user(ws)
|
||||
if user is None:
|
||||
await ws.close(4401)
|
||||
return
|
||||
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
await ws.close(4404)
|
||||
return
|
||||
if not state.seated(user.sub):
|
||||
await ws.close(4403)
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
|
||||
send_lock = asyncio.Lock()
|
||||
|
||||
async def send(payload: Dict[str, Any]) -> None:
|
||||
async with send_lock:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
|
||||
await send(_state_message(state, user.sub))
|
||||
|
||||
async with game_store.subscribe(game_id) as events:
|
||||
forward = asyncio.create_task(
|
||||
_forward(events, game_id, user.sub, send)
|
||||
)
|
||||
try:
|
||||
async for message in ws:
|
||||
if message.kind == "close":
|
||||
break
|
||||
if message.kind != "text" or not isinstance(message.data, str):
|
||||
await send(_error("expected a text frame with a JSON object"))
|
||||
continue
|
||||
await _handle_message(send, game_id, user.sub, message.data)
|
||||
finally:
|
||||
forward.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await forward
|
||||
|
||||
|
||||
async def _forward(
|
||||
events,
|
||||
game_id: str,
|
||||
sub: str,
|
||||
send: Send,
|
||||
) -> None:
|
||||
async for _ in events:
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
return
|
||||
await send(_state_message(state, sub))
|
||||
if state.phase == PHASE_FINISHED:
|
||||
await send(
|
||||
{
|
||||
"type": "game_over",
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": "A" if state.winner == 0 else "B",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
await send(_error("invalid JSON"))
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
await send(_error("message must be a JSON object"))
|
||||
return
|
||||
|
||||
action = data.get("action")
|
||||
if action == "play":
|
||||
await _handle_play(send, game_id, sub, data)
|
||||
elif action in ("state", "sync"):
|
||||
state = await game_store.load(game_id)
|
||||
if state is not None:
|
||||
await send(_state_message(state, sub))
|
||||
else:
|
||||
await send(_error(f"unknown action: {action!r}"))
|
||||
|
||||
|
||||
async def _handle_play(
|
||||
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
||||
) -> None:
|
||||
card = data.get("card")
|
||||
capture = data.get("capture")
|
||||
if not isinstance(card, str):
|
||||
await send(_error("'card' must be a card code string"))
|
||||
return
|
||||
if capture is not None and (
|
||||
not isinstance(capture, list)
|
||||
or any(not isinstance(item, str) for item in capture)
|
||||
):
|
||||
await send(_error("'capture' must be a list of card codes"))
|
||||
return
|
||||
|
||||
async with game_store.lock(game_id):
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
await send(_error("game not found", code="not_found"))
|
||||
return
|
||||
try:
|
||||
engine.play(state, sub, card, capture)
|
||||
except GameError as exc:
|
||||
await send(_error(str(exc), code="illegal_move"))
|
||||
return
|
||||
except ValueError:
|
||||
await send(_error("invalid card code", code="illegal_move"))
|
||||
return
|
||||
|
||||
if state.phase == PHASE_FINISHED:
|
||||
await save_match_result(state)
|
||||
await game_store.save(state)
|
||||
await game_store.publish(game_id)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tortoise ORM configuration consumed by the aerich CLI.
|
||||
|
||||
Kept separate from :mod:`scopa.app` so ``aerich`` can import it without
|
||||
Kept separate from :mod:`tavolo.app` so ``aerich`` can import it without
|
||||
assembling the whole application (mixins, routes). The database URL comes
|
||||
from the same :class:`~scopa.config.Settings` the app uses, so the CLI
|
||||
from the same :class:`~tavolo.config.Settings` the app uses, so the CLI
|
||||
and the app always point at the same database.
|
||||
|
||||
``aerich.models`` is required alongside the app models: it provides the
|
||||
@@ -16,7 +16,7 @@ TORTOISE_ORM = {
|
||||
"connections": {"default": settings.database_url},
|
||||
"apps": {
|
||||
"models": {
|
||||
"models": ["scopa.models", "aerich.models"],
|
||||
"models": ["tavolo.models", "aerich.models"],
|
||||
"default_connection": "default",
|
||||
}
|
||||
},
|
||||
@@ -6,7 +6,7 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
|
||||
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
|
||||
otherwise an in-memory store — e.g. for tests)
|
||||
- :class:`~kaya.oidc.OIDCMixin` (OIDC login)
|
||||
- :class:`~scopa.tortoise_mixin.TortoiseMixin` (Postgres match statistics;
|
||||
- :class:`~tavolo.tortoise_mixin.TortoiseMixin` (Postgres match statistics;
|
||||
skipped for ``/api/health`` and the OpenAPI documentation endpoints)
|
||||
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||
@@ -57,15 +57,15 @@ oidc_mixin = OIDCMixin(
|
||||
session=session_mixin,
|
||||
)
|
||||
openapi_mixin = OpenAPIMixin(
|
||||
title="scopa",
|
||||
version=_pkg_version("scopa"),
|
||||
title="tavolo",
|
||||
version=_pkg_version("tavolo"),
|
||||
description="Scopone scientifico multiplayer API",
|
||||
spec_path="/api/openapi.json",
|
||||
docs_path="/api/docs",
|
||||
)
|
||||
tortoise_mixin = TortoiseMixin(
|
||||
database_url=settings.database_url,
|
||||
models_modules=["scopa.models"],
|
||||
models_modules=["tavolo.models"],
|
||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Authentication helpers on top of the kaya-oidc mixin.
|
||||
|
||||
Scopa has no application roles: every authenticated user may create and
|
||||
Tavolo has no application roles: every authenticated user may create and
|
||||
join games. Authorization beyond login is game membership, checked against
|
||||
the live game state in Redis.
|
||||
"""
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Environment-driven configuration for the scopa application.
|
||||
"""Environment-driven configuration for the tavolo application.
|
||||
|
||||
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
||||
dataclass. No pydantic-settings, no settings module.
|
||||
@@ -40,13 +40,19 @@ class Settings:
|
||||
# Directory holding the compiled frontend (trunk's dist output),
|
||||
# served for every path that is not under /api or /auth.
|
||||
static_dir: str
|
||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||
# before dealing the next hand anyway.
|
||||
hand_ack_timeout_seconds: int
|
||||
# Seconds a player has to play before the server plays a random legal
|
||||
# card for them (covering disconnects and idle players).
|
||||
turn_timeout_seconds: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
return Settings(
|
||||
database_url=_env("DATABASE_URL", "postgres://scopa:scopa@localhost:5432/scopa"),
|
||||
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/scopa"),
|
||||
oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"),
|
||||
database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"),
|
||||
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
|
||||
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
||||
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
||||
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
||||
@@ -59,6 +65,8 @@ class Settings:
|
||||
redis_url=os.environ.get("REDIS_URL"),
|
||||
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Pure rules engine for scopone scientifico.
|
||||
|
||||
Every function here is deterministic and I/O-free: it mutates (or reads)
|
||||
:class:`~scopa.game.state.GameState` and raises
|
||||
:class:`~scopa.game.errors.GameError` subclasses on rule violations. This
|
||||
:class:`~tavolo.game.state.GameState` and raises
|
||||
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
|
||||
makes the whole rule set unit-testable without Redis, Postgres or HTTP.
|
||||
|
||||
Rules implemented
|
||||
@@ -27,7 +27,7 @@ Rules implemented
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import combinations
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
@@ -43,6 +43,7 @@ from .errors import (
|
||||
from .state import (
|
||||
DEFAULT_TARGET_SCORE,
|
||||
PHASE_FINISHED,
|
||||
PHASE_HAND_END,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
SUITS,
|
||||
@@ -58,6 +59,16 @@ from .state import (
|
||||
HAND_SIZE = 10
|
||||
PLAYERS = 4
|
||||
|
||||
# Default seconds the hand-end summary waits before dealing anyway. Games
|
||||
# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via
|
||||
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
||||
|
||||
# Default seconds a player has to play before the server plays a random
|
||||
# legal card for them. Games carry their own copy in
|
||||
# ``GameState.turn_timeout`` (configurable via TURN_TIMEOUT_SECONDS).
|
||||
DEFAULT_TURN_TIMEOUT_SECONDS = 30
|
||||
|
||||
# Primiera card values: sevens are best, then sixes, then aces, then the
|
||||
# remaining ranks in descending order. All of 8/9/10 are worth 10.
|
||||
PRIMIERA_VALUES: Dict[int, int] = {
|
||||
@@ -117,6 +128,8 @@ def create_game(
|
||||
creator_sub: str,
|
||||
creator_name: str,
|
||||
target_score: int = DEFAULT_TARGET_SCORE,
|
||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
) -> GameState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
if target_score < 1 or target_score > 100:
|
||||
@@ -128,6 +141,8 @@ def create_game(
|
||||
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,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
@@ -154,6 +169,12 @@ def start_game(state: GameState) -> None:
|
||||
_deal_hand(state)
|
||||
|
||||
|
||||
def _set_turn_deadline(state: GameState) -> 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_hand(state: GameState) -> None:
|
||||
deck = shuffled_deck()
|
||||
for player in state.players:
|
||||
@@ -165,6 +186,7 @@ def _deal_hand(state: GameState) -> None:
|
||||
# Dealer rotates each hand; the first card is played by the player to
|
||||
# the dealer's left.
|
||||
state.turn = (state.dealer + 1) % PLAYERS
|
||||
_set_turn_deadline(state)
|
||||
for offset in range(HAND_SIZE):
|
||||
for seat in range(PLAYERS):
|
||||
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
||||
@@ -188,11 +210,13 @@ def play(
|
||||
|
||||
``capture_codes`` selects which table cards to capture; it must be a
|
||||
legal capture (see :func:`legal_captures`) when one exists and empty
|
||||
otherwise. Raises a :class:`~scopa.game.errors.GameError` subclass on
|
||||
otherwise. Raises a :class:`~tavolo.game.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 hand is over; acknowledge the summary to continue")
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
|
||||
@@ -244,6 +268,7 @@ def play(
|
||||
_end_hand(state)
|
||||
else:
|
||||
state.turn = (state.turn + 1) % PLAYERS
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _match_option(
|
||||
@@ -259,8 +284,42 @@ def _match_option(
|
||||
return None
|
||||
|
||||
|
||||
def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Play a random legal move for the player currently on turn.
|
||||
|
||||
A card is drawn at random from that player's hand; if it can capture,
|
||||
one of the legal captures is chosen at random (the rules require a
|
||||
capture when one exists). Delegates to :func:`play`, so the move is
|
||||
fully validated and can end the hand 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)
|
||||
options = legal_captures(state.table, card)
|
||||
capture = chooser.choice(options) if options else None
|
||||
play(
|
||||
state,
|
||||
player.sub,
|
||||
card.code,
|
||||
[c.code for c in capture] if capture else None,
|
||||
)
|
||||
|
||||
|
||||
def _end_hand(state: GameState) -> None:
|
||||
"""Sweep the table, score the hand and either deal again or finish."""
|
||||
"""Sweep the table and score the hand.
|
||||
|
||||
If the match continues, the game pauses in the ``hand_end`` phase so
|
||||
every player can read the scoring summary; the next hand is dealt by
|
||||
:func:`acknowledge_hand` once all four players have acknowledged (or by
|
||||
the hand-end timeout in the websocket layer). If the match is over the
|
||||
game goes to ``finished`` immediately.
|
||||
"""
|
||||
state.turn_deadline = None
|
||||
if state.table and state.last_taker is not None:
|
||||
taker = _player_at(state, state.last_taker)
|
||||
taker.captured.extend(state.table)
|
||||
@@ -282,8 +341,37 @@ def _end_hand(state: GameState) -> None:
|
||||
state.finished_at = datetime.now(timezone.utc).isoformat()
|
||||
return
|
||||
|
||||
# Pause for the scoring 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: GameState, sub: str) -> None:
|
||||
"""Record that ``sub`` has read the hand-end summary.
|
||||
|
||||
When all four players have acknowledged, the next hand 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 hand 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) < PLAYERS:
|
||||
return
|
||||
|
||||
state.hand_number += 1
|
||||
state.dealer = (state.dealer + 1) % PLAYERS
|
||||
state.acked = []
|
||||
state.hand_end_deadline = None
|
||||
state.last_move = None
|
||||
state.phase = PHASE_PLAYING
|
||||
_deal_hand(state)
|
||||
|
||||
|
||||
@@ -308,26 +396,41 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
||||
scope[player.team] += player.scope
|
||||
|
||||
points = [0, 0]
|
||||
award: Dict[str, Optional[str]] = {}
|
||||
# Carte: most captured cards. Ties award nothing.
|
||||
cards = [len(piles[0]), len(piles[1])]
|
||||
if cards[0] != cards[1]:
|
||||
points[0 if cards[0] > cards[1] else 1] += 1
|
||||
winner = 0 if cards[0] > cards[1] else 1
|
||||
points[winner] += 1
|
||||
award["carte"] = TEAM_NAMES[winner]
|
||||
else:
|
||||
award["carte"] = None
|
||||
# Denara: most diamond cards. Ties award nothing.
|
||||
coins = [
|
||||
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
|
||||
]
|
||||
if coins[0] != coins[1]:
|
||||
points[0 if coins[0] > coins[1] else 1] += 1
|
||||
# Settebello: the 7 of diamonds.
|
||||
winner = 0 if coins[0] > coins[1] else 1
|
||||
points[winner] += 1
|
||||
award["denara"] = TEAM_NAMES[winner]
|
||||
else:
|
||||
award["denara"] = None
|
||||
# Settebello: the 7 of diamonds always belongs to someone.
|
||||
settebello = [
|
||||
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
|
||||
]
|
||||
if settebello[0] != settebello[1]:
|
||||
points[0 if settebello[0] else 1] += 1
|
||||
winner = 0 if settebello[0] else 1
|
||||
points[winner] += 1
|
||||
award["settebello"] = TEAM_NAMES[winner]
|
||||
# Primiera: highest value, only if the team holds all four suits.
|
||||
primiera = [primiera_score(piles[t]) for t in (0, 1)]
|
||||
if primiera[0] != primiera[1]:
|
||||
points[0 if primiera[0] > primiera[1] else 1] += 1
|
||||
winner = 0 if primiera[0] > primiera[1] else 1
|
||||
points[winner] += 1
|
||||
award["primiera"] = TEAM_NAMES[winner]
|
||||
else:
|
||||
award["primiera"] = None
|
||||
# Scope: one point each.
|
||||
points[0] += scope[0]
|
||||
points[1] += scope[1]
|
||||
@@ -338,6 +441,7 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
||||
"settebello": {"A": settebello[0], "B": settebello[1]},
|
||||
"primiera": {"A": primiera[0], "B": primiera[1]},
|
||||
"scope": {"A": scope[0], "B": scope[1]},
|
||||
"award": award,
|
||||
}
|
||||
return points, details
|
||||
|
||||
@@ -346,7 +450,7 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
||||
"""Serialize ``state`` hiding other players' hands.
|
||||
|
||||
Hands are reduced to a count, except for the requesting player's own
|
||||
hand. Raises :class:`~scopa.game.errors.GameNotFound`-style access via
|
||||
hand. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via
|
||||
the caller; this function assumes ``sub`` may or may not be seated and
|
||||
simply omits the hand for non-seated viewers.
|
||||
"""
|
||||
@@ -380,6 +484,9 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
||||
"players": players,
|
||||
"last_hand": state.hand_scores[-1] if state.hand_scores else None,
|
||||
"last_move": state.last_move.to_json() if state.last_move 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
|
||||
@@ -1,7 +1,7 @@
|
||||
"""In-memory representation of a scopone scientifico game.
|
||||
|
||||
The whole mutable game lives in :class:`GameState`, which is serialized to
|
||||
and from plain JSON for storage in Redis (see :mod:`scopa.store`). Keeping
|
||||
and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping
|
||||
the representation JSON-native means the store needs no custom codecs and
|
||||
the state is inspectable with ``redis-cli``.
|
||||
|
||||
@@ -26,6 +26,10 @@ TEAM_NAMES = ("A", "B")
|
||||
|
||||
PHASE_LOBBY = "lobby"
|
||||
PHASE_PLAYING = "playing"
|
||||
# Between hands of an unfinished match: scoring summary shown to every
|
||||
# player; the next hand is dealt once all four acknowledge (or the
|
||||
# hand-end timeout elapses).
|
||||
PHASE_HAND_END = "hand_end"
|
||||
PHASE_FINISHED = "finished"
|
||||
|
||||
DEFAULT_TARGET_SCORE = 11
|
||||
@@ -162,6 +166,16 @@ class GameState:
|
||||
finished_at: Optional[str] = None
|
||||
# The most recent play in the current hand, for move announcements.
|
||||
last_move: Optional[Move] = None
|
||||
# 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 hand-end summary waits before dealing anyway.
|
||||
hand_ack_timeout: int = 30
|
||||
# While phase == "playing": when the server plays a random legal card
|
||||
# for the player on turn. Copied from settings at creation.
|
||||
turn_deadline: Optional[str] = None
|
||||
turn_timeout: int = 30
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
|
||||
@@ -185,6 +199,11 @@ class GameState:
|
||||
"created_at": self.created_at,
|
||||
"finished_at": self.finished_at,
|
||||
"last_move": self.last_move.to_json() if self.last_move else None,
|
||||
"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
|
||||
@@ -208,6 +227,11 @@ class GameState:
|
||||
created_at=data.get("created_at"),
|
||||
finished_at=data.get("finished_at"),
|
||||
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
||||
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", 30)),
|
||||
turn_deadline=data.get("turn_deadline"),
|
||||
turn_timeout=int(data.get("turn_timeout", 30)),
|
||||
)
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tortoise ORM models: match statistics persisted in Postgres.
|
||||
|
||||
Live game state lives in Redis (see :mod:`scopa.store`); only completed
|
||||
Live game state lives in Redis (see :mod:`tavolo.store`); only completed
|
||||
matches are written here. The two tables answer the question "every match
|
||||
a player took part in, with the final score":
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
A game starts as a lobby: the creator is seated first and shares the
|
||||
six-character ``join_code``. When the fourth player joins, the engine deals
|
||||
the first hand and the match begins. Live play then happens over the
|
||||
``/ws/games/{id}`` websocket (see :mod:`scopa.ws`); these endpoints cover
|
||||
``/ws/games/{id}`` websocket (see :mod:`tavolo.ws`); these endpoints cover
|
||||
creation, joining and snapshotting state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -18,6 +18,7 @@ from kaya.openapi import operation
|
||||
from .. import auth
|
||||
from ..app import app, game_store, oidc_mixin
|
||||
from ..auth import require_auth
|
||||
from ..config import settings
|
||||
from ..game import engine
|
||||
from ..game.errors import GameError
|
||||
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
|
||||
@@ -103,6 +104,8 @@ async def create_game(ctx: HttpContext) -> None:
|
||||
creator_sub=user.sub,
|
||||
creator_name=auth.display_name(user),
|
||||
target_score=target_score,
|
||||
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
||||
turn_timeout=settings.turn_timeout_seconds,
|
||||
)
|
||||
except GameError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Player statistics endpoints, served from Postgres.
|
||||
|
||||
Every finished match is persisted by :func:`scopa.stats.save_match_result`.
|
||||
Every finished match is persisted by :func:`tavolo.stats.save_match_result`.
|
||||
These endpoints expose a player's own match history and a global
|
||||
leaderboard aggregated from the same two tables.
|
||||
"""
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Persistence for live games.
|
||||
|
||||
Game state is small, mutable and short-lived, which makes Redis a natural
|
||||
fit: the whole match is a single JSON value under ``scopa:game:<id>`` with
|
||||
fit: the whole match is a single JSON value under ``tavolo:game:<id>`` with
|
||||
a sliding TTL, and a join-code index maps the short code a player shares to
|
||||
that id. Completed matches are copied to Postgres (see
|
||||
:mod:`scopa.models`); Redis keeps serving the finished state until it
|
||||
:mod:`tavolo.models`); Redis keeps serving the finished state until it
|
||||
expires.
|
||||
|
||||
Two implementations satisfy the same interface:
|
||||
@@ -31,9 +31,9 @@ from redis.asyncio import Redis
|
||||
|
||||
from .game.state import GameState
|
||||
|
||||
GAME_KEY_PREFIX = "scopa:game:"
|
||||
CODE_KEY_PREFIX = "scopa:code:"
|
||||
CHANNEL_PREFIX = "scopa:game:"
|
||||
GAME_KEY_PREFIX = "tavolo:game:"
|
||||
CODE_KEY_PREFIX = "tavolo:code:"
|
||||
CHANNEL_PREFIX = "tavolo:game:"
|
||||
|
||||
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
||||
_BUMP = b"update"
|
||||
@@ -0,0 +1,306 @@
|
||||
"""WebSocket endpoint for live play.
|
||||
|
||||
Clients connect to ``/ws/games/{game_id}`` using their session cookie (the
|
||||
OIDC login stores the user in the session, which the session mixin loads
|
||||
onto the websocket). Only seated players are accepted.
|
||||
|
||||
Protocol
|
||||
--------
|
||||
Server -> client messages are JSON objects with a ``type``:
|
||||
|
||||
* ``state`` — the personalized game view (own hand visible, others hidden).
|
||||
* ``game_over`` — sent once when the match ends, with the final scores.
|
||||
* ``error`` — a rejected action or malformed message.
|
||||
|
||||
Client -> server messages are JSON objects::
|
||||
|
||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||
{"action": "play", "card": "07D"}
|
||||
{"action": "ack"}
|
||||
{"action": "state"}
|
||||
|
||||
``capture`` lists the table cards to take and must be a legal capture when
|
||||
one exists (see :func:`tavolo.game.engine.legal_captures`); it is omitted
|
||||
when the played card cannot capture. ``ack`` acknowledges the hand-end
|
||||
scoring summary; the next hand is dealt when all four players have
|
||||
acknowledged or the timeout fires.
|
||||
|
||||
Mutations run under the per-game lock; after a successful move the new
|
||||
state is saved to Redis and a change signal is published. Every connected
|
||||
websocket is subscribed to that signal and re-renders the state, so all
|
||||
players see the move immediately (and consistently across workers).
|
||||
|
||||
If a player does not move before the per-game ``turn_timeout``, the server
|
||||
plays a random card (with a random legal capture when one is required) for
|
||||
them, so a disconnected or idle player cannot stall the match. The timer is
|
||||
re-armed by every client connection and state broadcast, and fires
|
||||
immediately when a reconnect finds the deadline already past.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from kaya.core import WebSocket
|
||||
|
||||
from . import auth
|
||||
from .app import app, game_store
|
||||
from .game import engine
|
||||
from .game.errors import GameError
|
||||
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
|
||||
from .stats import save_match_result
|
||||
|
||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
||||
return {"type": "error", "code": code, "message": message}
|
||||
|
||||
|
||||
def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
|
||||
return {"type": "state", "game": engine.state_for_player(state, sub)}
|
||||
|
||||
|
||||
@app.websocket("/ws/games/${game_id}")
|
||||
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||
user = auth.get_ws_user(ws)
|
||||
if user is None:
|
||||
await ws.close(4401)
|
||||
return
|
||||
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
await ws.close(4404)
|
||||
return
|
||||
if not state.seated(user.sub):
|
||||
await ws.close(4403)
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
|
||||
send_lock = asyncio.Lock()
|
||||
|
||||
async def send(payload: Dict[str, Any]) -> None:
|
||||
async with send_lock:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
|
||||
await send(_state_message(state, user.sub))
|
||||
schedule_turn_timer(game_id, state)
|
||||
|
||||
async with game_store.subscribe(game_id) as events:
|
||||
forward = asyncio.create_task(
|
||||
_forward(events, game_id, user.sub, send)
|
||||
)
|
||||
try:
|
||||
async for message in ws:
|
||||
if message.kind == "close":
|
||||
break
|
||||
if message.kind != "text" or not isinstance(message.data, str):
|
||||
await send(_error("expected a text frame with a JSON object"))
|
||||
continue
|
||||
await _handle_message(send, game_id, user.sub, message.data)
|
||||
finally:
|
||||
forward.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await forward
|
||||
|
||||
|
||||
async def _forward(
|
||||
events,
|
||||
game_id: str,
|
||||
sub: str,
|
||||
send: Send,
|
||||
) -> None:
|
||||
async for _ in events:
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
return
|
||||
schedule_turn_timer(game_id, state)
|
||||
await send(_state_message(state, sub))
|
||||
if state.phase == PHASE_FINISHED:
|
||||
await send(
|
||||
{
|
||||
"type": "game_over",
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": "A" if state.winner == 0 else "B",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
await send(_error("invalid JSON"))
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
await send(_error("message must be a JSON object"))
|
||||
return
|
||||
|
||||
action = data.get("action")
|
||||
if action == "play":
|
||||
await _handle_play(send, game_id, sub, data)
|
||||
elif action == "ack":
|
||||
await _handle_ack(send, game_id, sub)
|
||||
elif action in ("state", "sync"):
|
||||
state = await game_store.load(game_id)
|
||||
if state is not None:
|
||||
await send(_state_message(state, sub))
|
||||
else:
|
||||
await send(_error(f"unknown action: {action!r}"))
|
||||
|
||||
|
||||
# --- hand-end acknowledgement ------------------------------------------------
|
||||
|
||||
# Running auto-continue timers, keyed by (game_id, hand_number), so a hand's
|
||||
# timeout is scheduled only once even when several clients are connected.
|
||||
_hand_end_timers: Dict[tuple, asyncio.Task] = {}
|
||||
|
||||
|
||||
async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
|
||||
async with game_store.lock(game_id):
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
await send(_error("game not found", code="not_found"))
|
||||
return
|
||||
try:
|
||||
engine.acknowledge_hand(state, sub)
|
||||
except GameError as exc:
|
||||
await send(_error(str(exc), code="illegal_move"))
|
||||
return
|
||||
await game_store.save(state)
|
||||
await game_store.publish(game_id)
|
||||
|
||||
|
||||
def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None:
|
||||
"""Deal the next hand after the acknowledgement timeout, even if not
|
||||
everyone has clicked. Fizzles if the hand already advanced."""
|
||||
key = (game_id, hand_number)
|
||||
if key in _hand_end_timers:
|
||||
return
|
||||
|
||||
async def _auto_advance() -> None:
|
||||
try:
|
||||
await asyncio.sleep(timeout)
|
||||
async with game_store.lock(game_id):
|
||||
state = await game_store.load(game_id)
|
||||
if (
|
||||
state is None
|
||||
or state.phase != engine.PHASE_HAND_END
|
||||
or state.hand_number != hand_number
|
||||
):
|
||||
return
|
||||
for player in state.players:
|
||||
engine.acknowledge_hand(state, player.sub)
|
||||
await game_store.save(state)
|
||||
await game_store.publish(game_id)
|
||||
finally:
|
||||
_hand_end_timers.pop(key, None)
|
||||
|
||||
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
|
||||
|
||||
|
||||
# --- auto-play on turn timeout ------------------------------------------------
|
||||
|
||||
# Running turn timers, keyed by (game_id, hand_number, turn, deadline), so a
|
||||
# turn's timeout is scheduled only once even when several clients are
|
||||
# connected. Including the deadline means a re-arm after a reconnect cannot
|
||||
# duplicate a timer for a turn that was already auto-played.
|
||||
_turn_timers: Dict[tuple, asyncio.Task] = {}
|
||||
|
||||
|
||||
def schedule_turn_timer(game_id: str, state: GameState) -> None:
|
||||
"""Auto-play a random legal card if the player on turn misses the
|
||||
deadline. Fizzles if the turn already advanced."""
|
||||
if state.phase != PHASE_PLAYING or not state.turn_deadline:
|
||||
return
|
||||
key = (game_id, state.hand_number, state.turn, state.turn_deadline)
|
||||
if key in _turn_timers:
|
||||
return
|
||||
|
||||
hand_number = state.hand_number
|
||||
turn = state.turn
|
||||
deadline_raw = state.turn_deadline
|
||||
try:
|
||||
deadline = datetime.fromisoformat(deadline_raw)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
async def _auto_play() -> None:
|
||||
try:
|
||||
delay = (deadline - datetime.now(timezone.utc)).total_seconds()
|
||||
await asyncio.sleep(max(delay, 0))
|
||||
async with game_store.lock(game_id):
|
||||
state = await game_store.load(game_id)
|
||||
if (
|
||||
state is None
|
||||
or state.phase != PHASE_PLAYING
|
||||
or state.hand_number != hand_number
|
||||
or state.turn != turn
|
||||
or state.turn_deadline != deadline_raw
|
||||
):
|
||||
# The turn moved on (or the game ended) without this
|
||||
# timer firing: make sure the current turn is armed.
|
||||
if state is not None:
|
||||
schedule_turn_timer(game_id, state)
|
||||
return
|
||||
try:
|
||||
engine.auto_play(state)
|
||||
except GameError:
|
||||
return
|
||||
await _after_play(state, game_id)
|
||||
finally:
|
||||
_turn_timers.pop(key, None)
|
||||
|
||||
_turn_timers[key] = asyncio.create_task(_auto_play())
|
||||
|
||||
|
||||
async def _after_play(state: GameState, game_id: str) -> None:
|
||||
"""Persist a successful move and notify every connected player.
|
||||
|
||||
Callers must hold the per-game lock. Handles the two terminal
|
||||
transitions: the match result is written to Postgres once, and a
|
||||
hand-end summary schedules the auto-continue timeout.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
await save_match_result(state)
|
||||
elif state.phase == PHASE_HAND_END:
|
||||
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
|
||||
await game_store.save(state)
|
||||
await game_store.publish(game_id)
|
||||
|
||||
|
||||
async def _handle_play(
|
||||
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
||||
) -> None:
|
||||
card = data.get("card")
|
||||
capture = data.get("capture")
|
||||
if not isinstance(card, str):
|
||||
await send(_error("'card' must be a card code string"))
|
||||
return
|
||||
if capture is not None and (
|
||||
not isinstance(capture, list)
|
||||
or any(not isinstance(item, str) for item in capture)
|
||||
):
|
||||
await send(_error("'capture' must be a list of card codes"))
|
||||
return
|
||||
|
||||
async with game_store.lock(game_id):
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
await send(_error("game not found", code="not_found"))
|
||||
return
|
||||
try:
|
||||
engine.play(state, sub, card, capture)
|
||||
except GameError as exc:
|
||||
await send(_error(str(exc), code="illegal_move"))
|
||||
return
|
||||
except ValueError:
|
||||
await send(_error("invalid card code", code="illegal_move"))
|
||||
return
|
||||
|
||||
await _after_play(state, game_id)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test package init.
|
||||
|
||||
Sets environment overrides BEFORE any test module imports
|
||||
:mod:`scopa.app` (which evaluates :data:`scopa.config.settings`
|
||||
:mod:`tavolo.app` (which evaluates :data:`tavolo.config.settings`
|
||||
at import time). Works under both ``python -m unittest discover`` and
|
||||
``pytest``; conftest.py mirrors this for pytest-only collection.
|
||||
"""
|
||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "scopa")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
||||
os.environ.pop("REDIS_URL", None)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Helpers for faking the OIDC authenticated user during tests.
|
||||
|
||||
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
|
||||
through :func:`scopa.auth.get_ws_user`. Patching those two entry points
|
||||
through :func:`tavolo.auth.get_ws_user`. Patching those two entry points
|
||||
lets route and websocket tests run entirely in-process with no IdP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -13,10 +13,10 @@ from typing import Iterator, Optional, Sequence
|
||||
from kaya.oidc import OIDCUser
|
||||
|
||||
# Import the app first: it pulls in the route modules, which import
|
||||
# ``scopa.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
||||
# ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
||||
# partially initialized module (same constraint as reimpasto).
|
||||
from scopa.app import oidc_mixin
|
||||
from scopa import auth
|
||||
from tavolo.app import oidc_mixin
|
||||
from tavolo import auth
|
||||
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
|
||||
+136
-3
@@ -1,16 +1,18 @@
|
||||
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from scopa.game import engine
|
||||
from scopa.game.errors import (
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.errors import (
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from scopa.game.state import (
|
||||
from tavolo.game.state import (
|
||||
PHASE_FINISHED,
|
||||
PHASE_PLAYING,
|
||||
Card,
|
||||
@@ -297,6 +299,10 @@ class MatchFlowTest(unittest.TestCase):
|
||||
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||
if state.phase == "hand_end":
|
||||
for p in state.players:
|
||||
engine.acknowledge_hand(state, p.sub)
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = engine.legal_captures(state.table, played)
|
||||
@@ -313,5 +319,132 @@ class MatchFlowTest(unittest.TestCase):
|
||||
self.assertTrue(state.finished_at)
|
||||
|
||||
|
||||
class HandEndAckTest(unittest.TestCase):
|
||||
def _hand_end_state(self) -> GameState:
|
||||
"""Drive a game into the hand_end phase with a one-card hand."""
|
||||
state = make_state([["02D"], [], [], []],
|
||||
table=["02C"], target=11)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
return state
|
||||
|
||||
def test_end_of_hand_pauses_for_acknowledgement(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
self.assertEqual("hand_end", state.phase)
|
||||
# Nobody has acknowledged yet, and no new hand was dealt.
|
||||
self.assertEqual([], state.acked)
|
||||
self.assertEqual(1, state.hand_number)
|
||||
self.assertTrue(state.hand_end_deadline)
|
||||
# Capture piles stay visible during the summary.
|
||||
self.assertEqual(["02C", "02D"],
|
||||
[c.code for c in state.players[0].captured])
|
||||
# The summary carries the award map.
|
||||
summary = state.hand_scores[-1]
|
||||
self.assertEqual(1, summary["hand"])
|
||||
self.assertIn("award", summary)
|
||||
|
||||
def test_play_during_hand_end_is_rejected(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
def test_ack_all_four_deals_next_hand(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
dealer_before = state.dealer
|
||||
for i, sub in enumerate(("p0", "p1", "p2")):
|
||||
engine.acknowledge_hand(state, sub)
|
||||
self.assertEqual(list(range(i + 1)), state.acked)
|
||||
self.assertEqual("hand_end", state.phase)
|
||||
engine.acknowledge_hand(state, "p3")
|
||||
self.assertEqual("playing", state.phase)
|
||||
self.assertEqual(2, state.hand_number)
|
||||
self.assertEqual((dealer_before + 1) % 4, state.dealer)
|
||||
self.assertEqual([], state.acked)
|
||||
self.assertIsNone(state.hand_end_deadline)
|
||||
self.assertIsNone(state.last_move)
|
||||
for player in state.players:
|
||||
self.assertEqual(10, len(player.hand))
|
||||
self.assertEqual([], player.captured)
|
||||
self.assertEqual((dealer_before + 2) % 4, state.turn)
|
||||
|
||||
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"], ["03D"], ["04D"], ["05D"]], table=[])
|
||||
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")
|
||||
|
||||
def test_state_exposes_ack_progress(self) -> None:
|
||||
state = self._hand_end_state()
|
||||
engine.acknowledge_hand(state, "p1")
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertEqual([1], view["acknowledged"])
|
||||
self.assertTrue(view["hand_end_deadline"])
|
||||
self.assertIsNotNone(view["last_hand"])
|
||||
self.assertIn("award", view["last_hand"])
|
||||
|
||||
|
||||
class AutoPlayTest(unittest.TestCase):
|
||||
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
|
||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["09B"])
|
||||
state.turn_deadline = "2000-01-01T00:00:00+00:00"
|
||||
engine.auto_play(state, random.Random(7))
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertEqual(1, len(state.players[0].hand))
|
||||
# The played card could not capture the nine, so the table grew.
|
||||
self.assertEqual(2, len(state.table))
|
||||
self.assertIsNotNone(state.last_move)
|
||||
assert state.last_move is not None
|
||||
self.assertEqual(0, state.last_move.seat)
|
||||
self.assertNotEqual("2000-01-01T00:00:00+00:00", state.turn_deadline)
|
||||
|
||||
def test_auto_play_takes_a_mandatory_capture(self) -> None:
|
||||
# p0 holds only the five of denari, which must capture the equal
|
||||
# five of coppe instead of the unrelated nine on the table.
|
||||
state = make_state([["05D"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["05C", "09B"])
|
||||
engine.auto_play(state)
|
||||
self.assertIsNotNone(state.last_move)
|
||||
assert state.last_move is not None
|
||||
self.assertEqual("05D", state.last_move.card)
|
||||
self.assertEqual(["05C"], state.last_move.captured)
|
||||
self.assertEqual(["09B"], [c.code for c in state.table])
|
||||
self.assertEqual(["05C", "05D"],
|
||||
[c.code for c in state.players[0].captured])
|
||||
|
||||
def test_auto_play_can_end_the_hand_and_clears_deadline(self) -> None:
|
||||
state = make_state([["02D"], [], [], []], table=["02C"])
|
||||
state.turn_deadline = "2000-01-01T00:00:00+00:00"
|
||||
engine.auto_play(state)
|
||||
self.assertEqual("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"], ["04D"], ["05D"], ["06D"]], table=[])
|
||||
state.phase = "hand_end"
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.auto_play(state)
|
||||
|
||||
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
|
||||
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7)
|
||||
self.assertEqual(7, state.turn_timeout)
|
||||
for i in range(1, 4):
|
||||
engine.join_game(state, f"p{i}", f"p{i}")
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertTrue(state.turn_deadline)
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertTrue(view["turn_deadline"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app
|
||||
from tavolo.app import app
|
||||
from tests.helpers import oidc_user
|
||||
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ from unittest import mock
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app
|
||||
from scopa.config import settings
|
||||
from tavolo.app import app
|
||||
from tavolo.config import settings
|
||||
from tests.helpers import oidc_user
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class StaticRouteTest(unittest.TestCase):
|
||||
(cards / "07D.svg").write_text("<svg/>")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("scopa.routes.static.settings", patched):
|
||||
with mock.patch("tavolo.routes.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
index = await client.get("/")
|
||||
@@ -74,7 +74,7 @@ class StaticRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_missing_dist_returns_404(self) -> None:
|
||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||
with mock.patch("scopa.routes.static.settings", patched):
|
||||
with mock.patch("tavolo.routes.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/")
|
||||
|
||||
@@ -8,11 +8,11 @@ from datetime import datetime, timezone
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app, tortoise_mixin
|
||||
from scopa.game import engine
|
||||
from scopa.game.state import GameState
|
||||
from scopa.models import Match, MatchPlayer
|
||||
from scopa.stats import save_match_result
|
||||
from tavolo.app import app, tortoise_mixin
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import GameState
|
||||
from tavolo.models import Match, MatchPlayer
|
||||
from tavolo.stats import save_match_result
|
||||
from tests.helpers import oidc_user
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def _finished_state() -> GameState:
|
||||
turn=0,
|
||||
table=[engine.parse_card("02C")],
|
||||
)
|
||||
from scopa.game.state import PlayerState, Card
|
||||
from tavolo.game.state import PlayerState, Card
|
||||
|
||||
state.players = [
|
||||
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
|
||||
|
||||
@@ -6,8 +6,8 @@ import unittest
|
||||
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.game import engine
|
||||
from scopa.store import InMemoryGameStore
|
||||
from tavolo.game import engine
|
||||
from tavolo.store import InMemoryGameStore
|
||||
|
||||
|
||||
class InMemoryGameStoreTest(unittest.TestCase):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -8,7 +9,9 @@ from httpx_ws import WebSocketDisconnect, aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
from pwo import async_test
|
||||
|
||||
from scopa.app import app
|
||||
from tavolo.app import app, game_store
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import Card, GameState, PlayerState
|
||||
from tests.helpers import make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
@@ -127,5 +130,158 @@ class WebSocketTest(unittest.TestCase):
|
||||
self.assertEqual(4401, caught.exception.code)
|
||||
|
||||
|
||||
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||
"""Seed a game where a single play ends the hand: p0 holds the only
|
||||
card left and can capture the only table card."""
|
||||
state = GameState(
|
||||
id="hand-end-1",
|
||||
join_code="HEND01",
|
||||
creator_sub="alice",
|
||||
target_score=11,
|
||||
phase="playing",
|
||||
turn=0,
|
||||
table=[Card.parse("02C")],
|
||||
)
|
||||
state.players = [
|
||||
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||
PlayerState(sub="bob", name="Bob", seat=1),
|
||||
PlayerState(sub="carol", name="Carol", seat=2),
|
||||
PlayerState(sub="dave", name="Dave", seat=3),
|
||||
]
|
||||
state.hand_ack_timeout = hand_ack_timeout
|
||||
await game_store.save(state)
|
||||
return state.id
|
||||
|
||||
|
||||
class HandEndWebSocketTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_hand_end_ack_flow(self) -> None:
|
||||
import contextlib
|
||||
|
||||
game_id = await _seed_last_play_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(name) for name in PLAYERS]):
|
||||
sockets = [
|
||||
await stack.enter_async_context(
|
||||
aconnect_ws(f"/ws/games/{game_id}", ws_client)
|
||||
)
|
||||
for _ in PLAYERS
|
||||
]
|
||||
for ws in sockets:
|
||||
await ws.receive_json() # initial state
|
||||
|
||||
# Alice plays the last card: the hand ends and the game
|
||||
# pauses for acknowledgements.
|
||||
await sockets[0].send_json(
|
||||
{"action": "play", "card": "02D", "capture": ["02C"]}
|
||||
)
|
||||
summaries = [await ws.receive_json() for ws in sockets]
|
||||
for summary in summaries:
|
||||
self.assertEqual("state", summary["type"])
|
||||
self.assertEqual("hand_end", summary["game"]["phase"])
|
||||
self.assertEqual([], summary["game"]["acknowledged"])
|
||||
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
|
||||
award = summary["game"]["last_hand"]["award"]
|
||||
self.assertEqual("A", award["carte"])
|
||||
self.assertEqual("A", award["denara"])
|
||||
|
||||
# Everyone acknowledges; the fourth ack deals the next hand.
|
||||
for i, ws in enumerate(sockets):
|
||||
await ws.send_json({"action": "ack"})
|
||||
updates = [await other.receive_json() for other in sockets]
|
||||
for update in updates:
|
||||
if i < 3:
|
||||
self.assertEqual("hand_end", update["game"]["phase"])
|
||||
self.assertEqual(
|
||||
list(range(i + 1)),
|
||||
update["game"]["acknowledged"],
|
||||
)
|
||||
else:
|
||||
self.assertEqual("playing", update["game"]["phase"])
|
||||
self.assertEqual(2, update["game"]["hand_number"])
|
||||
self.assertEqual(
|
||||
10, update["game"]["players"][i]["cards_left"]
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_hand_end_timeout_deals_next_hand(self) -> None:
|
||||
game_id = await _seed_last_play_state(hand_ack_timeout=1)
|
||||
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:
|
||||
await ws.receive_json() # initial state
|
||||
await ws.send_json(
|
||||
{"action": "play", "card": "02D", "capture": ["02C"]}
|
||||
)
|
||||
summary = await ws.receive_json()
|
||||
self.assertEqual("hand_end", summary["game"]["phase"])
|
||||
# Nobody acks: the timer must deal the next hand.
|
||||
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"]["phase"] == "playing"
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
self.assertEqual("playing", update["game"]["phase"])
|
||||
self.assertEqual(2, update["game"]["hand_number"])
|
||||
|
||||
|
||||
class TurnTimeoutWebSocketTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_turn_timeout_auto_plays_a_card(self) -> None:
|
||||
state = engine.create_game(
|
||||
"turn-timeout-1", "TT0001", "alice", "Alice",
|
||||
target_score=11, turn_timeout=1,
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join_game(state, name, name.capitalize())
|
||||
await game_store.save(state)
|
||||
|
||||
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/{state.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"])
|
||||
deadline = first["game"]["turn_deadline"]
|
||||
self.assertIsNotNone(deadline)
|
||||
|
||||
# Nobody plays: the timer must play a random card for Bob.
|
||||
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"]["turn"] == 2
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
self.assertEqual(2, update["game"]["turn"])
|
||||
self.assertEqual(1, update["game"]["last_move"]["seat"])
|
||||
self.assertEqual(
|
||||
9, update["game"]["players"][1]["cards_left"]
|
||||
)
|
||||
self.assertNotEqual(deadline, update["game"]["turn_deadline"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Generated
+28
-16
@@ -174,6 +174,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-utils"
|
||||
version = "0.2.0"
|
||||
@@ -359,22 +369,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scopa-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"futures",
|
||||
"gloo-net",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sycamore",
|
||||
"sycamore-router",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -567,6 +561,24 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tavolo-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"futures",
|
||||
"gloo-net",
|
||||
"gloo-timers",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sycamore",
|
||||
"sycamore-router",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
||||
+4
-2
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "scopa-web"
|
||||
name = "tavolo-web"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Sycamore/WASM frontend for the scopone scientifico backend"
|
||||
description = "Sycamore/WASM frontend for the tavolo card-game platform"
|
||||
|
||||
[dependencies]
|
||||
sycamore = "0.9"
|
||||
@@ -14,6 +14,8 @@ wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
futures = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
|
||||
js-sys = "0.3"
|
||||
gloo-timers = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
[profile.release]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Scopone scientifico</title>
|
||||
<title>Tavolo</title>
|
||||
<link data-trunk rel="rust" href="Cargo.toml">
|
||||
<link data-trunk rel="css" href="style.css">
|
||||
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//! REST client for the scopa backend. Same-origin requests carry the
|
||||
//! REST client for the tavolo backend. Same-origin requests carry the
|
||||
//! session cookie automatically.
|
||||
use crate::model::*;
|
||||
use gloo_net::http::Request;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod card;
|
||||
pub mod summary;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! The hand-end scoring summary screen.
|
||||
//!
|
||||
//! Shown when a hand finishes but the match continues: explains, in plain
|
||||
//! language, how each scoring category played out and how the running
|
||||
//! totals moved toward the target. Every player must acknowledge it before
|
||||
//! the next hand is dealt (or the server-side timeout deals anyway).
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::model::{GameView, HandSummary, Scores};
|
||||
use crate::ws::GameSocket;
|
||||
|
||||
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
||||
fn award_row(
|
||||
icon: View,
|
||||
title: &'static str,
|
||||
text: String,
|
||||
winner: Option<String>,
|
||||
points: &'static str,
|
||||
) -> View {
|
||||
let cls = match winner.as_deref() {
|
||||
Some("A") => "score-row team-a",
|
||||
Some("B") => "score-row team-b",
|
||||
_ => "score-row tie",
|
||||
};
|
||||
let chip = match &winner {
|
||||
Some(t) => format!("Team {t} {points}"),
|
||||
None => "tie".to_string(),
|
||||
};
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Winner's count first, then the loser's, for a natural sentence.
|
||||
fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
|
||||
match winner.map(String::as_str) {
|
||||
Some("B") => (b, a),
|
||||
_ => (a, b),
|
||||
}
|
||||
}
|
||||
|
||||
/// The five scoring rows of a completed hand.
|
||||
pub fn summary_rows(summary: HandSummary) -> View {
|
||||
let rows: Vec<View> = vec![
|
||||
// Carte
|
||||
award_row(
|
||||
card_back("score-mini"),
|
||||
"Carte",
|
||||
match &summary.award.carte {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
|
||||
format!("Team {t} captured more cards ({w} vs {l})")
|
||||
}
|
||||
None => format!(
|
||||
"Both teams captured {} cards — no point",
|
||||
summary.cards.a
|
||||
),
|
||||
},
|
||||
summary.award.carte.clone(),
|
||||
"+1",
|
||||
),
|
||||
// Denara
|
||||
award_row(
|
||||
card_img("02D".to_string(), "score-mini"),
|
||||
"Denara",
|
||||
match &summary.award.denara {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
|
||||
format!("Team {t} collected more denari cards ({w} vs {l})")
|
||||
}
|
||||
None => format!(
|
||||
"Both teams collected {} denari cards — no point",
|
||||
summary.denara.a
|
||||
),
|
||||
},
|
||||
summary.award.denara.clone(),
|
||||
"+1",
|
||||
),
|
||||
// Settebello
|
||||
award_row(
|
||||
card_img("07D".to_string(), "score-mini"),
|
||||
"Settebello",
|
||||
match &summary.award.settebello {
|
||||
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
|
||||
None => "Nobody captured the Settebello".to_string(),
|
||||
},
|
||||
summary.award.settebello.clone(),
|
||||
"+1",
|
||||
),
|
||||
// Primiera
|
||||
award_row(
|
||||
card_img("10D".to_string(), "score-mini"),
|
||||
"Primiera",
|
||||
match &summary.award.primiera {
|
||||
Some(t) => {
|
||||
let (w, l) =
|
||||
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
|
||||
format!("Team {t} holds the strongest primiera ({w} vs {l})")
|
||||
}
|
||||
None => format!(
|
||||
"Both primiere are worth {} — no point",
|
||||
summary.primiera.a
|
||||
),
|
||||
},
|
||||
summary.award.primiera.clone(),
|
||||
"+1",
|
||||
),
|
||||
// Scope
|
||||
{
|
||||
let a = summary.scope.a;
|
||||
let b = summary.scope.b;
|
||||
let text = if a == 0 && b == 0 {
|
||||
"No scope this hand".to_string()
|
||||
} else {
|
||||
format!("Team A made {a} scope · Team B made {b} scope")
|
||||
};
|
||||
let chip = format!("+{a} · +{b}");
|
||||
view! {
|
||||
div(class="score-row scope-row") {
|
||||
div(class="score-icon") {
|
||||
(card_back("score-mini"))
|
||||
}
|
||||
div(class="score-body") {
|
||||
div(class="score-title") { "Scope" }
|
||||
div(class="score-text") { (text) }
|
||||
}
|
||||
div(class="score-points") { (chip) }
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
view! {
|
||||
div(class="score-rows") { (rows) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Running totals with progress toward the target score, including the
|
||||
/// points gained in the hand just played.
|
||||
pub fn totals(game: &GameView) -> View {
|
||||
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||
let target = game.target_score.max(1);
|
||||
let pct_a = (100 * scores.a / target).min(100);
|
||||
let pct_b = (100 * scores.b / target).min(100);
|
||||
let (gained_a, gained_b) = game
|
||||
.last_hand
|
||||
.as_ref()
|
||||
.map(|s| (s.team_a_points, s.team_b_points))
|
||||
.unwrap_or((0, 0));
|
||||
view! {
|
||||
div(class="totals") {
|
||||
div(class="total-row team-a") {
|
||||
span(class="total-label") { "Team A" }
|
||||
div(class="progress") {
|
||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(scores.a) " / " (target)
|
||||
span(class="gained") { "+" (gained_a) }
|
||||
}
|
||||
}
|
||||
div(class="total-row team-b") {
|
||||
span(class="total-label") { "Team B" }
|
||||
div(class="progress") {
|
||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||
}
|
||||
span(class="total-value") {
|
||||
(scores.b) " / " (target)
|
||||
span(class="gained") { "+" (gained_b) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full hand-end modal: explanation + totals + acknowledgement button.
|
||||
pub fn hand_summary_modal(
|
||||
game: GameView,
|
||||
socket: Signal<Option<GameSocket>>,
|
||||
now: Signal<f64>,
|
||||
) -> View {
|
||||
let Some(summary) = game.last_hand.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="hint") {
|
||||
"Auto-continuing in "
|
||||
(move || {
|
||||
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||||
})
|
||||
"s"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let rows = summary_rows(summary.clone());
|
||||
let totals_view = totals(&game);
|
||||
let title = format!("Hand {} — results", summary.hand);
|
||||
|
||||
let action = if already_acked {
|
||||
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
||||
view! {
|
||||
button(class="button primary", disabled=true) { (waiting_text) }
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
button(class="button primary", on:click=move |_| {
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.ack();
|
||||
}
|
||||
}) { "Understood — next hand" }
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker summary-panel") {
|
||||
h2 { (title) }
|
||||
(rows)
|
||||
(totals_view)
|
||||
div(class="summary-actions") { (action) }
|
||||
(countdown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,54 @@ pub struct MoveView {
|
||||
pub scopa: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
pub struct TeamCounts {
|
||||
#[serde(rename = "A")]
|
||||
pub a: i32,
|
||||
#[serde(rename = "B")]
|
||||
pub b: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TeamBools {
|
||||
#[serde(rename = "A")]
|
||||
pub a: bool,
|
||||
#[serde(rename = "B")]
|
||||
pub b: bool,
|
||||
}
|
||||
|
||||
/// Which team (if any) won each scoring category of a hand.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Award {
|
||||
#[serde(default)]
|
||||
pub carte: Option<String>,
|
||||
#[serde(default)]
|
||||
pub denara: Option<String>,
|
||||
#[serde(default)]
|
||||
pub settebello: Option<String>,
|
||||
#[serde(default)]
|
||||
pub primiera: Option<String>,
|
||||
}
|
||||
|
||||
/// The scoring breakdown of one completed hand.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct HandSummary {
|
||||
pub cards: TeamCounts,
|
||||
pub denara: TeamCounts,
|
||||
pub settebello: TeamBools,
|
||||
pub primiera: TeamCounts,
|
||||
pub scope: TeamCounts,
|
||||
pub award: Award,
|
||||
#[serde(default)]
|
||||
pub hand: i32,
|
||||
#[serde(default)]
|
||||
pub team_a_points: i32,
|
||||
#[serde(default)]
|
||||
pub team_b_points: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct GameView {
|
||||
@@ -74,6 +122,20 @@ pub struct GameView {
|
||||
pub seats_open: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub last_move: Option<MoveView>,
|
||||
/// Scoring breakdown of the most recent hand (present once a hand has
|
||||
/// been completed).
|
||||
#[serde(default)]
|
||||
pub last_hand: Option<HandSummary>,
|
||||
/// Seats that acknowledged the hand-end summary.
|
||||
#[serde(default)]
|
||||
pub acknowledged: Vec<usize>,
|
||||
/// ISO-8601 instant at which the next hand is dealt automatically.
|
||||
#[serde(default)]
|
||||
pub hand_end_deadline: Option<String>,
|
||||
/// ISO-8601 instant at which the server plays a random legal card for
|
||||
/// the player on turn.
|
||||
#[serde(default)]
|
||||
pub turn_deadline: Option<String>,
|
||||
#[serde(default)]
|
||||
pub your_turn: Option<bool>,
|
||||
/// Legal captures per hand card; present only for the player on turn.
|
||||
|
||||
+38
-4
@@ -2,6 +2,7 @@
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
||||
use crate::ws::{self, GameSocket};
|
||||
|
||||
@@ -77,6 +78,9 @@ pub fn GamePage(id: String) -> View {
|
||||
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||
let closed = create_signal(false);
|
||||
let socket = create_signal(Option::<GameSocket>::None);
|
||||
// Ticking clock driving the hand-end countdown display.
|
||||
let now = create_signal(js_sys::Date::now());
|
||||
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
|
||||
|
||||
{
|
||||
let on_message = move |msg: ServerMessage| match msg {
|
||||
@@ -135,11 +139,15 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||
Some(g) => table_view(g, on_hand_card, selected),
|
||||
Some(g) => table_view(g, on_hand_card, selected, now),
|
||||
})
|
||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||
capture_picker(card, options, socket, capture_choice)
|
||||
}))
|
||||
(move || match game.get_clone() {
|
||||
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
|
||||
_ => view! {},
|
||||
})
|
||||
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||||
}
|
||||
}
|
||||
@@ -185,6 +193,7 @@ fn table_view(
|
||||
game: GameView,
|
||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||
selected: Signal<Option<String>>,
|
||||
now: Signal<f64>,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand.
|
||||
let viewer_seat = game
|
||||
@@ -199,6 +208,8 @@ fn table_view(
|
||||
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" {
|
||||
"Hand finished".to_string()
|
||||
} else if my_turn {
|
||||
"Your turn".to_string()
|
||||
} else {
|
||||
@@ -208,6 +219,19 @@ fn table_view(
|
||||
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 table_cards = game
|
||||
@@ -262,6 +286,7 @@ fn table_view(
|
||||
(target_score) ")"
|
||||
}
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
(countdown)
|
||||
}
|
||||
div(class="table-grid") {
|
||||
(top)
|
||||
@@ -321,7 +346,8 @@ fn capture_picker(
|
||||
/// End-of-match overlay.
|
||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||
let result = over.or_else(|| {
|
||||
game.filter(|g| g.phase == "finished")
|
||||
game.clone()
|
||||
.filter(|g| g.phase == "finished")
|
||||
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
|
||||
});
|
||||
match result {
|
||||
@@ -329,11 +355,19 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
Some((scores, winner)) => {
|
||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
||||
// Explain the final hand's scoring before the result.
|
||||
let final_summary = game
|
||||
.and_then(|g| g.last_hand)
|
||||
.map(|s| {
|
||||
let rows = summary_rows(s);
|
||||
view! { (rows) }
|
||||
});
|
||||
view! {
|
||||
div(class="overlay") {
|
||||
div(class="picker") {
|
||||
div(class="picker summary-panel") {
|
||||
h2 { "Match over" }
|
||||
p { (line) }
|
||||
(final_summary)
|
||||
p(class="final-score") { (line) }
|
||||
div(class="gameover-actions") {
|
||||
a(class="button primary", href="/") { "Back to lobby" }
|
||||
a(class="button", href="/history") { "My matches" }
|
||||
|
||||
@@ -40,6 +40,11 @@ impl GameSocket {
|
||||
self.send_json(serde_json::json!({ "action": "state" }));
|
||||
}
|
||||
|
||||
/// Acknowledge the hand-end scoring summary.
|
||||
pub fn ack(&self) {
|
||||
self.send_json(serde_json::json!({ "action": "ack" }));
|
||||
}
|
||||
|
||||
fn send_json(&self, value: serde_json::Value) {
|
||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||
}
|
||||
|
||||
+158
@@ -9,6 +9,8 @@
|
||||
--muted: #a9b7ab;
|
||||
--accent: #e8c547;
|
||||
--danger: #d9534f;
|
||||
--team-a: #7db4e8;
|
||||
--team-b: #e8967d;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -210,6 +212,11 @@ table.matches td.lost {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.turn-timer {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -448,3 +455,154 @@ table.matches td.lost {
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ---------- hand-end scoring summary ---------- */
|
||||
|
||||
.summary-panel {
|
||||
min-width: 420px;
|
||||
max-width: 560px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.summary-panel h2 {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.score-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--panel-light);
|
||||
border-left: 4px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
}
|
||||
|
||||
.score-row.team-a {
|
||||
border-left-color: var(--team-a);
|
||||
}
|
||||
|
||||
.score-row.team-b {
|
||||
border-left-color: var(--team-b);
|
||||
}
|
||||
|
||||
.score-row.tie {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.score-icon {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-img.score-mini {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.score-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.score-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.score-points {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score-row.team-a .score-points {
|
||||
color: var(--team-a);
|
||||
}
|
||||
|
||||
.score-row.team-b .score-points {
|
||||
color: var(--team-b);
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr 4rem;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.gained {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.total-row.team-a .gained {
|
||||
color: var(--team-a);
|
||||
}
|
||||
|
||||
.total-row.team-b .gained {
|
||||
color: var(--team-b);
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.total-row.team-a .progress-fill {
|
||||
background: var(--team-a);
|
||||
}
|
||||
|
||||
.total-row.team-b .progress-fill {
|
||||
background: var(--team-b);
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.final-score {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user