Author SHA1 Message Date
woggioni 6932a3272c Rename the app from scopa to tavolo
CI / Build and push docker image (push) Successful in 3m12s
The platform now hosts multiple card games, with scopone scientifico as
the first one. Rename the brand wherever it is not a game rule:

- move the Python package to server/src/tavolo and update imports
- rename the Postgres database/user, OIDC issuer path, client id and
  Redis key prefixes to tavolo (clean break: existing pgdata volumes and
  live games are not migrated)
- rename the Cargo package to tavolo-web and set the page title to Tavolo
- update docs and the Docker image path to woggioni/tavolo

The scopa game term (clearing the table) in the engine, state and web UI
is intentionally left untouched.
2026-09-16 21:46:06 +08:00
woggioni 89cf0a4c96 Auto-play a random legal card when the turn timeout expires 2026-09-16 21:46:06 +08:00
woggioni d9cdba33a1 Add hand-end scoring summary screen with acknowledgement
After each hand of an unfinished match the game now pauses in a new
hand_end phase instead of dealing immediately:

- engine: hand_points gains an 'award' map (which team won each category),
  _end_hand stops at hand_end with a deadline, new acknowledge_hand deals
  the next hand once all four players have acked; plays are rejected while
  the summary is up
- state: acked seats, hand_end_deadline and hand_ack_timeout are persisted
  and exposed in the personalized view (also on the finished state, so the
  final hand is explained before the result)
- ws: new {"action": "ack"}; a per-hand timer force-deals the next hand
  after HAND_ACK_TIMEOUT_SECONDS (new env var, default 30s) so an away
  player cannot stall the match
- web: modal explaining each category in plain language with icons (card
  images for denara/settebello/primiera), team-coloured rows, running
  totals with progress bars, an 'Understood — next hand' button that turns
  into 'Waiting for …' plus an auto-continue countdown; the final screen
  shows the last hand's breakdown too

Verified in the browser against the compose stack: hand played to
completion, summary rendered (including a carte tie), ack from all four
players dealt the next hand live, and the auto-continue path fired when
nobody acked. 60 backend tests + mypy + cargo tests green.
2026-09-16 21:46:01 +08:00
53 changed files with 1459 additions and 321 deletions
+1 -1
View File
@@ -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
+21 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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.
+1 -1
View File
@@ -2,7 +2,7 @@
"interactiveLogin": true,
"tokenCallbacks": [
{
"issuerId": "scopa",
"issuerId": "tavolo",
"tokenExpiry": 3600,
"requestMappings": [
{
+6 -6
View File
@@ -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
View File
@@ -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
-175
View File
@@ -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"
+306
View File
@@ -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)
+3 -3
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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("/")
+6 -6
View File
@@ -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")]),
+2 -2
View File
@@ -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):
+157 -1
View File
@@ -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()
+28 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1,2 @@
pub mod card;
pub mod summary;
+250
View File
@@ -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)
}
}
}
}
+62
View File
@@ -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
View File
@@ -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" }
+5
View File
@@ -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
View File
@@ -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;
}