Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047f43fa21 |
@@ -23,7 +23,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: gitea.woggioni.net/woggioni/tavolo
|
images: gitea.woggioni.net/woggioni/scopa
|
||||||
flavor: latest=false
|
flavor: latest=false
|
||||||
tags: |
|
tags: |
|
||||||
type=match,pattern=release/(.*),group=1
|
type=match,pattern=release/(.*),group=1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# tavolo
|
# scopa
|
||||||
|
|
||||||
A platform for multiplayer card games. The first game is **scopone
|
Multiplayer **scopone scientifico** — the four-player, fixed-partnership
|
||||||
scientifico** — the four-player, fixed-partnership Italian card game:
|
Italian card game — as a web application:
|
||||||
|
|
||||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||||
@@ -22,7 +22,7 @@ Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`,
|
|||||||
frontend and API — listens on `http://127.0.0.1:8080`.
|
frontend and API — listens on `http://127.0.0.1:8080`.
|
||||||
|
|
||||||
Because both the browser and the app talk to the OIDC issuer at
|
Because both the browser and the app talk to the OIDC issuer at
|
||||||
`http://mockoauth:8180/tavolo`, add a host entry once:
|
`http://mockoauth:8180/scopa`, add a host entry once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||||
@@ -37,13 +37,6 @@ must click "Understood" before the next hand is dealt. If someone is away
|
|||||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||||
(default 30s). The match-ending hand is explained on the final screen.
|
(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
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
Backend (from `server/`):
|
||||||
@@ -71,7 +64,7 @@ run the backend with:
|
|||||||
```sh
|
```sh
|
||||||
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||||
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||||
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
.venv/bin/granian --host 127.0.0.1 --port 8080 scopa.app:app
|
||||||
```
|
```
|
||||||
|
|
||||||
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||||||
|
|||||||
+11
-12
@@ -2,15 +2,15 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: tavolo
|
POSTGRES_DB: scopa
|
||||||
POSTGRES_USER: tavolo
|
POSTGRES_USER: scopa
|
||||||
POSTGRES_PASSWORD: tavolo
|
POSTGRES_PASSWORD: scopa
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql
|
- pgdata:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U tavolo"]
|
test: ["CMD-SHELL", "pg_isready -U scopa"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
# The mock does not validate clients, so any client id/secret works.
|
# 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
|
# It listens on 8180 both inside and outside the network so the OIDC
|
||||||
# issuer URL (http://mockoauth:8180/tavolo) is identical for
|
# issuer URL (http://mockoauth:8180/scopa) is identical for
|
||||||
# container-to-container calls and for browser redirects (via the
|
# container-to-container calls and for browser redirects (via the
|
||||||
# /etc/hosts entry documented in the README).
|
# /etc/hosts entry documented in the README).
|
||||||
mockoauth:
|
mockoauth:
|
||||||
@@ -43,7 +43,7 @@ services:
|
|||||||
- mockoauth
|
- mockoauth
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
until curl -sf http://mockoauth:8180/tavolo/.well-known/openid-configuration > /dev/null; do
|
until curl -sf http://mockoauth:8180/scopa/.well-known/openid-configuration > /dev/null; do
|
||||||
echo 'waiting for mockoauth...'; sleep 2;
|
echo 'waiting for mockoauth...'; sleep 2;
|
||||||
done
|
done
|
||||||
"
|
"
|
||||||
@@ -71,12 +71,12 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: ["aerich", "upgrade"]
|
command: ["aerich", "upgrade"]
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
|
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
tavolo:
|
scopa:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
@@ -90,18 +90,17 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
|
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
|
||||||
# By default the app and browsers reach the mock IdP under the same
|
# By default the app and browsers reach the mock IdP under the same
|
||||||
# name (see README /etc/hosts note); override OIDC_ISSUER and
|
# name (see README /etc/hosts note); override OIDC_ISSUER and
|
||||||
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
||||||
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
|
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/scopa}
|
||||||
# The mock OIDC server does not validate clients: any id/secret works.
|
# The mock OIDC server does not validate clients: any id/secret works.
|
||||||
OIDC_CLIENT_ID: tavolo
|
OIDC_CLIENT_ID: scopa
|
||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
||||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
|||||||
+6
-10
@@ -1,15 +1,15 @@
|
|||||||
# Postgres
|
# Postgres
|
||||||
POSTGRES_HOST=localhost
|
POSTGRES_HOST=localhost
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=tavolo
|
POSTGRES_DB=scopa
|
||||||
POSTGRES_USER=tavolo
|
POSTGRES_USER=scopa
|
||||||
POSTGRES_PASSWORD=tavolo
|
POSTGRES_PASSWORD=scopa
|
||||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo
|
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa
|
||||||
|
|
||||||
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any
|
# 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.)
|
# client id/secret works. For a real IdP like Keycloak, use its values here.)
|
||||||
OIDC_ISSUER=http://localhost:8180/tavolo
|
OIDC_ISSUER=http://localhost:8180/scopa
|
||||||
OIDC_CLIENT_ID=tavolo
|
OIDC_CLIENT_ID=scopa
|
||||||
OIDC_CLIENT_SECRET=dev-secret
|
OIDC_CLIENT_SECRET=dev-secret
|
||||||
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
|
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
|
||||||
|
|
||||||
@@ -24,10 +24,6 @@ GAME_TTL_SECONDS=86400
|
|||||||
# before dealing the next hand anyway.
|
# before dealing the next hand anyway.
|
||||||
HAND_ACK_TIMEOUT_SECONDS=30
|
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 server
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Multi-stage build for the tavolo stack (kaya backend + Sycamore/WASM
|
# Multi-stage build for the scopa stack (kaya backend + Sycamore/WASM
|
||||||
# frontend). The Docker build context is the REPOSITORY ROOT (see
|
# frontend). The Docker build context is the REPOSITORY ROOT (see
|
||||||
# docker-compose.yml) so this single image assembles both parts:
|
# docker-compose.yml) so this single image assembles both parts:
|
||||||
#
|
#
|
||||||
@@ -86,4 +86,4 @@ EXPOSE 8080
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
|
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
|
||||||
|
|
||||||
CMD ["granian", "tavolo.app:app"]
|
CMD ["granian", "scopa.app:app"]
|
||||||
|
|||||||
+16
-27
@@ -1,9 +1,8 @@
|
|||||||
# tavolo
|
# scopa
|
||||||
|
|
||||||
The backend for a multiplayer card-game platform, built on the
|
A multiplayer backend for **scopone scientifico** (the four-player,
|
||||||
[kaya](../kaya) framework. The first game implemented is **scopone
|
fixed-partnership variant of the classic Italian card game), built on the
|
||||||
scientifico**, the four-player, fixed-partnership variant of the classic
|
[kaya](../kaya) framework.
|
||||||
Italian card game.
|
|
||||||
|
|
||||||
Players authenticate with the configured **OIDC** provider. Live game state
|
Players authenticate with the configured **OIDC** provider. Live game state
|
||||||
is kept in **Redis** (with real-time play over WebSocket), and completed
|
is kept in **Redis** (with real-time play over WebSocket), and completed
|
||||||
@@ -25,7 +24,7 @@ serves the Sycamore/WASM frontend (built from `../web/` by the Docker
|
|||||||
image), so the UI is available at that address.
|
image), so the UI is available at that address.
|
||||||
|
|
||||||
Because the browser and the app both talk to the OIDC issuer at
|
Because the browser and the app both talk to the OIDC issuer at
|
||||||
`http://mockoauth:8180/tavolo`, add a host entry once:
|
`http://mockoauth:8180/scopa`, add a host entry once:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||||
@@ -44,28 +43,27 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics |
|
| `DATABASE_URL` | `postgres://scopa:scopa@localhost:5432/scopa` | Postgres DSN for match statistics |
|
||||||
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
|
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
|
||||||
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
|
| `OIDC_ISSUER` | `http://localhost:8180/scopa` | OIDC issuer URL |
|
||||||
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
|
| `OIDC_CLIENT_ID` | `scopa` | OIDC client id |
|
||||||
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||||
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
||||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
| `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 |
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
### Redis (live games)
|
### Redis (live games)
|
||||||
|
|
||||||
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
|
- `scopa: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,
|
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
|
||||||
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
|
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
|
||||||
(`GAME_TTL_SECONDS`).
|
(`GAME_TTL_SECONDS`).
|
||||||
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
|
- `scopa:code:<JOINCODE>` — the 6-character join code → game id index.
|
||||||
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
- `scopa:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
||||||
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
- `scopa:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
||||||
signals; every open WebSocket reloads the state and pushes the
|
signals; every open WebSocket reloads the state and pushes the
|
||||||
personalized view to its player.
|
personalized view to its player.
|
||||||
|
|
||||||
@@ -128,15 +126,6 @@ Client → server messages:
|
|||||||
|
|
||||||
After every accepted move the new state is broadcast to all four players.
|
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
|
### Hand-end summary
|
||||||
|
|
||||||
When a hand finishes but the match continues, the game enters the
|
When a hand finishes but the match continues, the game enters the
|
||||||
@@ -179,11 +168,11 @@ for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
|
|||||||
|
|
||||||
The Postgres schema is owned by aerich migrations in `migrations/`. The
|
The Postgres schema is owned by aerich migrations in `migrations/`. The
|
||||||
`db-migrate` compose service runs `aerich upgrade` before the app starts.
|
`db-migrate` compose service runs `aerich upgrade` before the app starts.
|
||||||
To add a migration after changing `src/tavolo/models.py`:
|
To add a migration after changing `src/scopa/models.py`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate
|
||||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
|
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich upgrade
|
||||||
```
|
```
|
||||||
|
|
||||||
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
||||||
@@ -195,7 +184,7 @@ baseline.)
|
|||||||
Everything lives under `server/`:
|
Everything lives under `server/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/tavolo/
|
src/scopa/
|
||||||
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
|
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
|
||||||
├── config.py # env -> frozen Settings
|
├── config.py # env -> frozen Settings
|
||||||
├── auth.py # auth helpers (HTTP + WebSocket)
|
├── auth.py # auth helpers (HTTP + WebSocket)
|
||||||
|
|||||||
+3
-3
@@ -3,14 +3,14 @@
|
|||||||
Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``)
|
Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``)
|
||||||
so they need no running Postgres, and with ``REDIS_URL`` unset so sessions
|
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
|
and live games use their in-memory stores. The environment is set before
|
||||||
:mod:`tavolo.app` is imported by the test modules.
|
:mod:`scopa.app` is imported by the test modules.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa")
|
||||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
os.environ.setdefault("OIDC_CLIENT_ID", "scopa")
|
||||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||||
os.environ.pop("REDIS_URL", None)
|
os.environ.pop("REDIS_URL", None)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Dev OIDC provider: navikt/mock-oauth2-server with the tavolo test
|
# Dev OIDC provider: navikt/mock-oauth2-server with the scopa test
|
||||||
# configuration (four ready-made players) baked in. The config is
|
# configuration (four ready-made players) baked in. The config is
|
||||||
# COPYed instead of bind-mounted so this also works against containerized
|
# COPYed instead of bind-mounted so this also works against containerized
|
||||||
# (e.g. rootless/DinD) docker daemons that cannot see the host workspace.
|
# (e.g. rootless/DinD) docker daemons that cannot see the host workspace.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"interactiveLogin": true,
|
"interactiveLogin": true,
|
||||||
"tokenCallbacks": [
|
"tokenCallbacks": [
|
||||||
{
|
{
|
||||||
"issuerId": "tavolo",
|
"issuerId": "scopa",
|
||||||
"tokenExpiry": 3600,
|
"tokenExpiry": 3600,
|
||||||
"requestMappings": [
|
"requestMappings": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ requires = ["setuptools>=68"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tavolo"
|
name = "scopa"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Multiplayer card-game platform backend built on the kaya framework"
|
description = "Scopone scientifico multiplayer backend built on the kaya framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -37,7 +37,7 @@ namespaces = false
|
|||||||
|
|
||||||
# Database migrations (aerich). See the Migrations section in README.md.
|
# Database migrations (aerich). See the Migrations section in README.md.
|
||||||
[tool.aerich]
|
[tool.aerich]
|
||||||
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
tortoise_orm = "scopa.aerich_config.TORTOISE_ORM"
|
||||||
location = "./migrations"
|
location = "./migrations"
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
@@ -49,13 +49,13 @@ plugins = []
|
|||||||
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
|
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
|
||||||
# only declare the relation field. These are real attributes, not bugs.
|
# only declare the relation field. These are real attributes, not bugs.
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "tavolo.models"
|
module = "scopa.models"
|
||||||
disable_error_code = ["attr-defined"]
|
disable_error_code = ["attr-defined"]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "tavolo.routes.*"
|
module = "scopa.routes.*"
|
||||||
disable_error_code = ["attr-defined"]
|
disable_error_code = ["attr-defined"]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "tavolo.game.*"
|
module = "scopa.game.*"
|
||||||
disable_error_code = ["attr-defined"]
|
disable_error_code = ["attr-defined"]
|
||||||
|
|||||||
+14
-14
@@ -8,7 +8,7 @@
|
|||||||
--extra-index-url https://pypi.org/simple
|
--extra-index-url https://pypi.org/simple
|
||||||
|
|
||||||
aerich==0.10.1
|
aerich==0.10.1
|
||||||
# via tavolo (pyproject.toml)
|
# via scopa (pyproject.toml)
|
||||||
aiosqlite==0.22.1
|
aiosqlite==0.22.1
|
||||||
# via tortoise-orm
|
# via tortoise-orm
|
||||||
anyio==4.15.1
|
anyio==4.15.1
|
||||||
@@ -19,7 +19,7 @@ anyio==4.15.1
|
|||||||
asyncclick==8.4.2.1
|
asyncclick==8.4.2.1
|
||||||
# via aerich
|
# via aerich
|
||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
# via tavolo (pyproject.toml)
|
# via scopa (pyproject.toml)
|
||||||
certifi==2026.7.22
|
certifi==2026.7.22
|
||||||
# via
|
# via
|
||||||
# httpcore
|
# httpcore
|
||||||
@@ -35,7 +35,7 @@ dictdiffer==0.10.0
|
|||||||
granian==2.8.3
|
granian==2.8.3
|
||||||
# via
|
# via
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
# via httpcore
|
# via httpcore
|
||||||
httpcore==1.0.9
|
httpcore==1.0.9
|
||||||
@@ -43,7 +43,7 @@ httpcore==1.0.9
|
|||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
# via
|
# via
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
idna==3.19
|
idna==3.19
|
||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
@@ -56,42 +56,42 @@ kaya-core==0.0.3
|
|||||||
# kaya-openapi
|
# kaya-openapi
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
kaya-oidc==0.0.3
|
kaya-oidc==0.0.3
|
||||||
# via tavolo (pyproject.toml)
|
# via scopa (pyproject.toml)
|
||||||
kaya-openapi==0.0.3
|
kaya-openapi==0.0.3
|
||||||
# via tavolo (pyproject.toml)
|
# via scopa (pyproject.toml)
|
||||||
kaya-rsgi==0.0.3
|
kaya-rsgi==0.0.3
|
||||||
# via tavolo (pyproject.toml)
|
# via scopa (pyproject.toml)
|
||||||
kaya-session==0.0.3
|
kaya-session==0.0.3
|
||||||
# via
|
# via
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# kaya-session-redis
|
# kaya-session-redis
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
kaya-session-redis==0.0.3
|
kaya-session-redis==0.0.3
|
||||||
# via tavolo (pyproject.toml)
|
# via scopa (pyproject.toml)
|
||||||
pwo==0.1.2
|
pwo==0.1.2
|
||||||
# via
|
# via
|
||||||
# kaya-core
|
# kaya-core
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
# via cffi
|
# via cffi
|
||||||
pyjwt[crypto]==2.14.0
|
pyjwt[crypto]==2.14.0
|
||||||
# via
|
# via
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
pypika-tortoise==0.6.5
|
pypika-tortoise==0.6.5
|
||||||
# via tortoise-orm
|
# via tortoise-orm
|
||||||
redis==8.1.0
|
redis==8.1.0
|
||||||
# via
|
# via
|
||||||
# kaya-session-redis
|
# kaya-session-redis
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
tortoise-orm==1.1.8
|
tortoise-orm==1.1.8
|
||||||
# via
|
# via
|
||||||
# aerich
|
# aerich
|
||||||
# tavolo (pyproject.toml)
|
# scopa (pyproject.toml)
|
||||||
typing-extensions==4.16.0
|
typing-extensions==4.16.0
|
||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Tortoise ORM configuration consumed by the aerich CLI.
|
"""Tortoise ORM configuration consumed by the aerich CLI.
|
||||||
|
|
||||||
Kept separate from :mod:`tavolo.app` so ``aerich`` can import it without
|
Kept separate from :mod:`scopa.app` so ``aerich`` can import it without
|
||||||
assembling the whole application (mixins, routes). The database URL comes
|
assembling the whole application (mixins, routes). The database URL comes
|
||||||
from the same :class:`~tavolo.config.Settings` the app uses, so the CLI
|
from the same :class:`~scopa.config.Settings` the app uses, so the CLI
|
||||||
and the app always point at the same database.
|
and the app always point at the same database.
|
||||||
|
|
||||||
``aerich.models`` is required alongside the app models: it provides the
|
``aerich.models`` is required alongside the app models: it provides the
|
||||||
@@ -16,7 +16,7 @@ TORTOISE_ORM = {
|
|||||||
"connections": {"default": settings.database_url},
|
"connections": {"default": settings.database_url},
|
||||||
"apps": {
|
"apps": {
|
||||||
"models": {
|
"models": {
|
||||||
"models": ["tavolo.models", "aerich.models"],
|
"models": ["scopa.models", "aerich.models"],
|
||||||
"default_connection": "default",
|
"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,
|
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
|
||||||
otherwise an in-memory store — e.g. for tests)
|
otherwise an in-memory store — e.g. for tests)
|
||||||
- :class:`~kaya.oidc.OIDCMixin` (OIDC login)
|
- :class:`~kaya.oidc.OIDCMixin` (OIDC login)
|
||||||
- :class:`~tavolo.tortoise_mixin.TortoiseMixin` (Postgres match statistics;
|
- :class:`~scopa.tortoise_mixin.TortoiseMixin` (Postgres match statistics;
|
||||||
skipped for ``/api/health`` and the OpenAPI documentation endpoints)
|
skipped for ``/api/health`` and the OpenAPI documentation endpoints)
|
||||||
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||||
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||||
@@ -57,15 +57,15 @@ oidc_mixin = OIDCMixin(
|
|||||||
session=session_mixin,
|
session=session_mixin,
|
||||||
)
|
)
|
||||||
openapi_mixin = OpenAPIMixin(
|
openapi_mixin = OpenAPIMixin(
|
||||||
title="tavolo",
|
title="scopa",
|
||||||
version=_pkg_version("tavolo"),
|
version=_pkg_version("scopa"),
|
||||||
description="Scopone scientifico multiplayer API",
|
description="Scopone scientifico multiplayer API",
|
||||||
spec_path="/api/openapi.json",
|
spec_path="/api/openapi.json",
|
||||||
docs_path="/api/docs",
|
docs_path="/api/docs",
|
||||||
)
|
)
|
||||||
tortoise_mixin = TortoiseMixin(
|
tortoise_mixin = TortoiseMixin(
|
||||||
database_url=settings.database_url,
|
database_url=settings.database_url,
|
||||||
models_modules=["tavolo.models"],
|
models_modules=["scopa.models"],
|
||||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Authentication helpers on top of the kaya-oidc mixin.
|
"""Authentication helpers on top of the kaya-oidc mixin.
|
||||||
|
|
||||||
Tavolo has no application roles: every authenticated user may create and
|
Scopa has no application roles: every authenticated user may create and
|
||||||
join games. Authorization beyond login is game membership, checked against
|
join games. Authorization beyond login is game membership, checked against
|
||||||
the live game state in Redis.
|
the live game state in Redis.
|
||||||
"""
|
"""
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Environment-driven configuration for the tavolo application.
|
"""Environment-driven configuration for the scopa application.
|
||||||
|
|
||||||
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
||||||
dataclass. No pydantic-settings, no settings module.
|
dataclass. No pydantic-settings, no settings module.
|
||||||
@@ -43,16 +43,13 @@ class Settings:
|
|||||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||||
# before dealing the next hand anyway.
|
# before dealing the next hand anyway.
|
||||||
hand_ack_timeout_seconds: int
|
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
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
return Settings(
|
return Settings(
|
||||||
database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"),
|
database_url=_env("DATABASE_URL", "postgres://scopa:scopa@localhost:5432/scopa"),
|
||||||
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
|
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/scopa"),
|
||||||
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"),
|
||||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
||||||
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
||||||
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
||||||
@@ -66,7 +63,6 @@ class Settings:
|
|||||||
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
||||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
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.
|
"""Pure rules engine for scopone scientifico.
|
||||||
|
|
||||||
Every function here is deterministic and I/O-free: it mutates (or reads)
|
Every function here is deterministic and I/O-free: it mutates (or reads)
|
||||||
:class:`~tavolo.game.state.GameState` and raises
|
:class:`~scopa.game.state.GameState` and raises
|
||||||
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
|
:class:`~scopa.game.errors.GameError` subclasses on rule violations. This
|
||||||
makes the whole rule set unit-testable without Redis, Postgres or HTTP.
|
makes the whole rule set unit-testable without Redis, Postgres or HTTP.
|
||||||
|
|
||||||
Rules implemented
|
Rules implemented
|
||||||
@@ -64,11 +64,6 @@ PLAYERS = 4
|
|||||||
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
||||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
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
|
# 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.
|
# remaining ranks in descending order. All of 8/9/10 are worth 10.
|
||||||
PRIMIERA_VALUES: Dict[int, int] = {
|
PRIMIERA_VALUES: Dict[int, int] = {
|
||||||
@@ -129,7 +124,6 @@ def create_game(
|
|||||||
creator_name: str,
|
creator_name: str,
|
||||||
target_score: int = DEFAULT_TARGET_SCORE,
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
|
||||||
) -> GameState:
|
) -> GameState:
|
||||||
"""Create a lobby game with the creator seated first."""
|
"""Create a lobby game with the creator seated first."""
|
||||||
if target_score < 1 or target_score > 100:
|
if target_score < 1 or target_score > 100:
|
||||||
@@ -142,7 +136,6 @@ def create_game(
|
|||||||
phase=PHASE_LOBBY,
|
phase=PHASE_LOBBY,
|
||||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
hand_ack_timeout=hand_ack_timeout,
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
turn_timeout=turn_timeout,
|
|
||||||
created_at=datetime.now(timezone.utc).isoformat(),
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -169,12 +162,6 @@ def start_game(state: GameState) -> None:
|
|||||||
_deal_hand(state)
|
_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:
|
def _deal_hand(state: GameState) -> None:
|
||||||
deck = shuffled_deck()
|
deck = shuffled_deck()
|
||||||
for player in state.players:
|
for player in state.players:
|
||||||
@@ -186,7 +173,6 @@ def _deal_hand(state: GameState) -> None:
|
|||||||
# Dealer rotates each hand; the first card is played by the player to
|
# Dealer rotates each hand; the first card is played by the player to
|
||||||
# the dealer's left.
|
# the dealer's left.
|
||||||
state.turn = (state.dealer + 1) % PLAYERS
|
state.turn = (state.dealer + 1) % PLAYERS
|
||||||
_set_turn_deadline(state)
|
|
||||||
for offset in range(HAND_SIZE):
|
for offset in range(HAND_SIZE):
|
||||||
for seat in range(PLAYERS):
|
for seat in range(PLAYERS):
|
||||||
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
||||||
@@ -210,7 +196,7 @@ def play(
|
|||||||
|
|
||||||
``capture_codes`` selects which table cards to capture; it must be a
|
``capture_codes`` selects which table cards to capture; it must be a
|
||||||
legal capture (see :func:`legal_captures`) when one exists and empty
|
legal capture (see :func:`legal_captures`) when one exists and empty
|
||||||
otherwise. Raises a :class:`~tavolo.game.errors.GameError` subclass on
|
otherwise. Raises a :class:`~scopa.game.errors.GameError` subclass on
|
||||||
any violation.
|
any violation.
|
||||||
"""
|
"""
|
||||||
if state.phase == PHASE_FINISHED:
|
if state.phase == PHASE_FINISHED:
|
||||||
@@ -268,7 +254,6 @@ def play(
|
|||||||
_end_hand(state)
|
_end_hand(state)
|
||||||
else:
|
else:
|
||||||
state.turn = (state.turn + 1) % PLAYERS
|
state.turn = (state.turn + 1) % PLAYERS
|
||||||
_set_turn_deadline(state)
|
|
||||||
|
|
||||||
|
|
||||||
def _match_option(
|
def _match_option(
|
||||||
@@ -284,32 +269,6 @@ def _match_option(
|
|||||||
return None
|
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:
|
def _end_hand(state: GameState) -> None:
|
||||||
"""Sweep the table and score the hand.
|
"""Sweep the table and score the hand.
|
||||||
|
|
||||||
@@ -319,7 +278,6 @@ def _end_hand(state: GameState) -> None:
|
|||||||
the hand-end timeout in the websocket layer). If the match is over the
|
the hand-end timeout in the websocket layer). If the match is over the
|
||||||
game goes to ``finished`` immediately.
|
game goes to ``finished`` immediately.
|
||||||
"""
|
"""
|
||||||
state.turn_deadline = None
|
|
||||||
if state.table and state.last_taker is not None:
|
if state.table and state.last_taker is not None:
|
||||||
taker = _player_at(state, state.last_taker)
|
taker = _player_at(state, state.last_taker)
|
||||||
taker.captured.extend(state.table)
|
taker.captured.extend(state.table)
|
||||||
@@ -450,7 +408,7 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
|||||||
"""Serialize ``state`` hiding other players' hands.
|
"""Serialize ``state`` hiding other players' hands.
|
||||||
|
|
||||||
Hands are reduced to a count, except for the requesting player's own
|
Hands are reduced to a count, except for the requesting player's own
|
||||||
hand. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via
|
hand. Raises :class:`~scopa.game.errors.GameNotFound`-style access via
|
||||||
the caller; this function assumes ``sub`` may or may not be seated and
|
the caller; this function assumes ``sub`` may or may not be seated and
|
||||||
simply omits the hand for non-seated viewers.
|
simply omits the hand for non-seated viewers.
|
||||||
"""
|
"""
|
||||||
@@ -486,7 +444,6 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
|||||||
"last_move": state.last_move.to_json() if state.last_move else None,
|
"last_move": state.last_move.to_json() if state.last_move else None,
|
||||||
"acknowledged": list(state.acked),
|
"acknowledged": list(state.acked),
|
||||||
"hand_end_deadline": state.hand_end_deadline,
|
"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:
|
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
|
||||||
payload["your_turn"] = True
|
payload["your_turn"] = True
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""In-memory representation of a scopone scientifico game.
|
"""In-memory representation of a scopone scientifico game.
|
||||||
|
|
||||||
The whole mutable game lives in :class:`GameState`, which is serialized to
|
The whole mutable game lives in :class:`GameState`, which is serialized to
|
||||||
and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping
|
and from plain JSON for storage in Redis (see :mod:`scopa.store`). Keeping
|
||||||
the representation JSON-native means the store needs no custom codecs and
|
the representation JSON-native means the store needs no custom codecs and
|
||||||
the state is inspectable with ``redis-cli``.
|
the state is inspectable with ``redis-cli``.
|
||||||
|
|
||||||
@@ -172,10 +172,6 @@ class GameState:
|
|||||||
hand_end_deadline: Optional[str] = None
|
hand_end_deadline: Optional[str] = None
|
||||||
# Seconds the hand-end summary waits before dealing anyway.
|
# Seconds the hand-end summary waits before dealing anyway.
|
||||||
hand_ack_timeout: int = 30
|
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 ----------------------------------------------------
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
@@ -202,8 +198,6 @@ class GameState:
|
|||||||
"acked": list(self.acked),
|
"acked": list(self.acked),
|
||||||
"hand_end_deadline": self.hand_end_deadline,
|
"hand_end_deadline": self.hand_end_deadline,
|
||||||
"hand_ack_timeout": self.hand_ack_timeout,
|
"hand_ack_timeout": self.hand_ack_timeout,
|
||||||
"turn_deadline": self.turn_deadline,
|
|
||||||
"turn_timeout": self.turn_timeout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -230,8 +224,6 @@ class GameState:
|
|||||||
acked=[int(s) for s in data.get("acked", [])],
|
acked=[int(s) for s in data.get("acked", [])],
|
||||||
hand_end_deadline=data.get("hand_end_deadline"),
|
hand_end_deadline=data.get("hand_end_deadline"),
|
||||||
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
|
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 ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tortoise ORM models: match statistics persisted in Postgres.
|
"""Tortoise ORM models: match statistics persisted in Postgres.
|
||||||
|
|
||||||
Live game state lives in Redis (see :mod:`tavolo.store`); only completed
|
Live game state lives in Redis (see :mod:`scopa.store`); only completed
|
||||||
matches are written here. The two tables answer the question "every match
|
matches are written here. The two tables answer the question "every match
|
||||||
a player took part in, with the final score":
|
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
|
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
|
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
|
the first hand and the match begins. Live play then happens over the
|
||||||
``/ws/games/{id}`` websocket (see :mod:`tavolo.ws`); these endpoints cover
|
``/ws/games/{id}`` websocket (see :mod:`scopa.ws`); these endpoints cover
|
||||||
creation, joining and snapshotting state.
|
creation, joining and snapshotting state.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -105,7 +105,6 @@ async def create_game(ctx: HttpContext) -> None:
|
|||||||
creator_name=auth.display_name(user),
|
creator_name=auth.display_name(user),
|
||||||
target_score=target_score,
|
target_score=target_score,
|
||||||
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
||||||
turn_timeout=settings.turn_timeout_seconds,
|
|
||||||
)
|
)
|
||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
await send_error(ctx, 400, str(exc))
|
await send_error(ctx, 400, str(exc))
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Player statistics endpoints, served from Postgres.
|
"""Player statistics endpoints, served from Postgres.
|
||||||
|
|
||||||
Every finished match is persisted by :func:`tavolo.stats.save_match_result`.
|
Every finished match is persisted by :func:`scopa.stats.save_match_result`.
|
||||||
These endpoints expose a player's own match history and a global
|
These endpoints expose a player's own match history and a global
|
||||||
leaderboard aggregated from the same two tables.
|
leaderboard aggregated from the same two tables.
|
||||||
"""
|
"""
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Persistence for live games.
|
"""Persistence for live games.
|
||||||
|
|
||||||
Game state is small, mutable and short-lived, which makes Redis a natural
|
Game state is small, mutable and short-lived, which makes Redis a natural
|
||||||
fit: the whole match is a single JSON value under ``tavolo:game:<id>`` with
|
fit: the whole match is a single JSON value under ``scopa:game:<id>`` with
|
||||||
a sliding TTL, and a join-code index maps the short code a player shares to
|
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
|
that id. Completed matches are copied to Postgres (see
|
||||||
:mod:`tavolo.models`); Redis keeps serving the finished state until it
|
:mod:`scopa.models`); Redis keeps serving the finished state until it
|
||||||
expires.
|
expires.
|
||||||
|
|
||||||
Two implementations satisfy the same interface:
|
Two implementations satisfy the same interface:
|
||||||
@@ -31,9 +31,9 @@ from redis.asyncio import Redis
|
|||||||
|
|
||||||
from .game.state import GameState
|
from .game.state import GameState
|
||||||
|
|
||||||
GAME_KEY_PREFIX = "tavolo:game:"
|
GAME_KEY_PREFIX = "scopa:game:"
|
||||||
CODE_KEY_PREFIX = "tavolo:code:"
|
CODE_KEY_PREFIX = "scopa:code:"
|
||||||
CHANNEL_PREFIX = "tavolo:game:"
|
CHANNEL_PREFIX = "scopa:game:"
|
||||||
|
|
||||||
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
||||||
_BUMP = b"update"
|
_BUMP = b"update"
|
||||||
@@ -20,7 +20,7 @@ Client -> server messages are JSON objects::
|
|||||||
{"action": "state"}
|
{"action": "state"}
|
||||||
|
|
||||||
``capture`` lists the table cards to take and must be a legal capture when
|
``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
|
one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted
|
||||||
when the played card cannot capture. ``ack`` acknowledges the hand-end
|
when the played card cannot capture. ``ack`` acknowledges the hand-end
|
||||||
scoring summary; the next hand is dealt when all four players have
|
scoring summary; the next hand is dealt when all four players have
|
||||||
acknowledged or the timeout fires.
|
acknowledged or the timeout fires.
|
||||||
@@ -29,19 +29,12 @@ 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
|
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
|
websocket is subscribed to that signal and re-renders the state, so all
|
||||||
players see the move immediately (and consistently across workers).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
from kaya.core import WebSocket
|
from kaya.core import WebSocket
|
||||||
@@ -50,7 +43,7 @@ from . import auth
|
|||||||
from .app import app, game_store
|
from .app import app, game_store
|
||||||
from .game import engine
|
from .game import engine
|
||||||
from .game.errors import GameError
|
from .game.errors import GameError
|
||||||
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
|
from .game.state import PHASE_FINISHED, PHASE_HAND_END, GameState
|
||||||
from .stats import save_match_result
|
from .stats import save_match_result
|
||||||
|
|
||||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||||
@@ -88,7 +81,6 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
|
|||||||
await ws.send_text(json.dumps(payload))
|
await ws.send_text(json.dumps(payload))
|
||||||
|
|
||||||
await send(_state_message(state, user.sub))
|
await send(_state_message(state, user.sub))
|
||||||
schedule_turn_timer(game_id, state)
|
|
||||||
|
|
||||||
async with game_store.subscribe(game_id) as events:
|
async with game_store.subscribe(game_id) as events:
|
||||||
forward = asyncio.create_task(
|
forward = asyncio.create_task(
|
||||||
@@ -118,7 +110,6 @@ async def _forward(
|
|||||||
state = await game_store.load(game_id)
|
state = await game_store.load(game_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
schedule_turn_timer(game_id, state)
|
|
||||||
await send(_state_message(state, sub))
|
await send(_state_message(state, sub))
|
||||||
if state.phase == PHASE_FINISHED:
|
if state.phase == PHASE_FINISHED:
|
||||||
await send(
|
await send(
|
||||||
@@ -204,76 +195,6 @@ def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> Non
|
|||||||
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
|
_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(
|
async def _handle_play(
|
||||||
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -303,4 +224,9 @@ async def _handle_play(
|
|||||||
await send(_error("invalid card code", code="illegal_move"))
|
await send(_error("invalid card code", code="illegal_move"))
|
||||||
return
|
return
|
||||||
|
|
||||||
await _after_play(state, game_id)
|
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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Test package init.
|
"""Test package init.
|
||||||
|
|
||||||
Sets environment overrides BEFORE any test module imports
|
Sets environment overrides BEFORE any test module imports
|
||||||
:mod:`tavolo.app` (which evaluates :data:`tavolo.config.settings`
|
:mod:`scopa.app` (which evaluates :data:`scopa.config.settings`
|
||||||
at import time). Works under both ``python -m unittest discover`` and
|
at import time). Works under both ``python -m unittest discover`` and
|
||||||
``pytest``; conftest.py mirrors this for pytest-only collection.
|
``pytest``; conftest.py mirrors this for pytest-only collection.
|
||||||
"""
|
"""
|
||||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa")
|
||||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
os.environ.setdefault("OIDC_CLIENT_ID", "scopa")
|
||||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||||
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
||||||
os.environ.pop("REDIS_URL", None)
|
os.environ.pop("REDIS_URL", None)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Helpers for faking the OIDC authenticated user during tests.
|
"""Helpers for faking the OIDC authenticated user during tests.
|
||||||
|
|
||||||
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
|
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
|
||||||
through :func:`tavolo.auth.get_ws_user`. Patching those two entry points
|
through :func:`scopa.auth.get_ws_user`. Patching those two entry points
|
||||||
lets route and websocket tests run entirely in-process with no IdP.
|
lets route and websocket tests run entirely in-process with no IdP.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,10 +13,10 @@ from typing import Iterator, Optional, Sequence
|
|||||||
from kaya.oidc import OIDCUser
|
from kaya.oidc import OIDCUser
|
||||||
|
|
||||||
# Import the app first: it pulls in the route modules, which import
|
# Import the app first: it pulls in the route modules, which import
|
||||||
# ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
# ``scopa.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
||||||
# partially initialized module (same constraint as reimpasto).
|
# partially initialized module (same constraint as reimpasto).
|
||||||
from tavolo.app import oidc_mixin
|
from scopa.app import oidc_mixin
|
||||||
from tavolo import auth
|
from scopa import auth
|
||||||
|
|
||||||
|
|
||||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from tavolo.game import engine
|
from scopa.game import engine
|
||||||
from tavolo.game.errors import (
|
from scopa.game.errors import (
|
||||||
CardNotInHand,
|
CardNotInHand,
|
||||||
GameFinished,
|
GameFinished,
|
||||||
GameNotStarted,
|
|
||||||
IllegalMove,
|
IllegalMove,
|
||||||
NotYourTurn,
|
NotYourTurn,
|
||||||
)
|
)
|
||||||
from tavolo.game.state import (
|
from scopa.game.state import (
|
||||||
PHASE_FINISHED,
|
PHASE_FINISHED,
|
||||||
PHASE_PLAYING,
|
PHASE_PLAYING,
|
||||||
Card,
|
Card,
|
||||||
@@ -392,59 +390,5 @@ class HandEndAckTest(unittest.TestCase):
|
|||||||
self.assertIn("award", 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import unittest
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
|
|
||||||
from tavolo.app import app
|
from scopa.app import app
|
||||||
from tests.helpers import oidc_user
|
from tests.helpers import oidc_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from unittest import mock
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
|
|
||||||
from tavolo.app import app
|
from scopa.app import app
|
||||||
from tavolo.config import settings
|
from scopa.config import settings
|
||||||
from tests.helpers import oidc_user
|
from tests.helpers import oidc_user
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class StaticRouteTest(unittest.TestCase):
|
|||||||
(cards / "07D.svg").write_text("<svg/>")
|
(cards / "07D.svg").write_text("<svg/>")
|
||||||
|
|
||||||
patched = dataclasses.replace(settings, static_dir=dist)
|
patched = dataclasses.replace(settings, static_dir=dist)
|
||||||
with mock.patch("tavolo.routes.static.settings", patched):
|
with mock.patch("scopa.routes.static.settings", patched):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
index = await client.get("/")
|
index = await client.get("/")
|
||||||
@@ -74,7 +74,7 @@ class StaticRouteTest(unittest.TestCase):
|
|||||||
@async_test
|
@async_test
|
||||||
async def test_missing_dist_returns_404(self) -> None:
|
async def test_missing_dist_returns_404(self) -> None:
|
||||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||||
with mock.patch("tavolo.routes.static.settings", patched):
|
with mock.patch("scopa.routes.static.settings", patched):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from datetime import datetime, timezone
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
|
|
||||||
from tavolo.app import app, tortoise_mixin
|
from scopa.app import app, tortoise_mixin
|
||||||
from tavolo.game import engine
|
from scopa.game import engine
|
||||||
from tavolo.game.state import GameState
|
from scopa.game.state import GameState
|
||||||
from tavolo.models import Match, MatchPlayer
|
from scopa.models import Match, MatchPlayer
|
||||||
from tavolo.stats import save_match_result
|
from scopa.stats import save_match_result
|
||||||
from tests.helpers import oidc_user
|
from tests.helpers import oidc_user
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ def _finished_state() -> GameState:
|
|||||||
turn=0,
|
turn=0,
|
||||||
table=[engine.parse_card("02C")],
|
table=[engine.parse_card("02C")],
|
||||||
)
|
)
|
||||||
from tavolo.game.state import PlayerState, Card
|
from scopa.game.state import PlayerState, Card
|
||||||
|
|
||||||
state.players = [
|
state.players = [
|
||||||
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
|
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import unittest
|
|||||||
|
|
||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
|
|
||||||
from tavolo.game import engine
|
from scopa.game import engine
|
||||||
from tavolo.store import InMemoryGameStore
|
from scopa.store import InMemoryGameStore
|
||||||
|
|
||||||
|
|
||||||
class InMemoryGameStoreTest(unittest.TestCase):
|
class InMemoryGameStoreTest(unittest.TestCase):
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from httpx_ws import WebSocketDisconnect, aconnect_ws
|
|||||||
from httpx_ws.transport import ASGIWebSocketTransport
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
|
|
||||||
from tavolo.app import app, game_store
|
from scopa.app import app, game_store
|
||||||
from tavolo.game import engine
|
from scopa.game import engine
|
||||||
from tavolo.game.state import Card, GameState, PlayerState
|
from scopa.game.state import Card, GameState, PlayerState
|
||||||
from tests.helpers import make_user, oidc_user, ws_users
|
from tests.helpers import make_user, oidc_user, ws_users
|
||||||
|
|
||||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||||
@@ -238,50 +238,5 @@ class HandEndWebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(2, update["game"]["hand_number"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Generated
+18
-18
@@ -369,6 +369,24 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopa-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]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -561,24 +579,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tavolo-web"
|
name = "scopa-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Sycamore/WASM frontend for the tavolo card-game platform"
|
description = "Sycamore/WASM frontend for the scopone scientifico backend"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
sycamore = "0.9"
|
sycamore = "0.9"
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Tavolo</title>
|
<title>Scopone scientifico</title>
|
||||||
<link data-trunk rel="rust" href="Cargo.toml">
|
<link data-trunk rel="rust" href="Cargo.toml">
|
||||||
<link data-trunk rel="css" href="style.css">
|
<link data-trunk rel="css" href="style.css">
|
||||||
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
//! REST client for the tavolo backend. Same-origin requests carry the
|
//! REST client for the scopa backend. Same-origin requests carry the
|
||||||
//! session cookie automatically.
|
//! session cookie automatically.
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
|
|||||||
@@ -132,10 +132,6 @@ pub struct GameView {
|
|||||||
/// ISO-8601 instant at which the next hand is dealt automatically.
|
/// ISO-8601 instant at which the next hand is dealt automatically.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hand_end_deadline: Option<String>,
|
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)]
|
#[serde(default)]
|
||||||
pub your_turn: Option<bool>,
|
pub your_turn: Option<bool>,
|
||||||
/// Legal captures per hand card; present only for the player on turn.
|
/// Legal captures per hand card; present only for the player on turn.
|
||||||
|
|||||||
+1
-16
@@ -139,7 +139,7 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||||
Some(g) => table_view(g, on_hand_card, selected, now),
|
Some(g) => table_view(g, on_hand_card, selected),
|
||||||
})
|
})
|
||||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||||
capture_picker(card, options, socket, capture_choice)
|
capture_picker(card, options, socket, capture_choice)
|
||||||
@@ -193,7 +193,6 @@ fn table_view(
|
|||||||
game: GameView,
|
game: GameView,
|
||||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||||
selected: Signal<Option<String>>,
|
selected: Signal<Option<String>>,
|
||||||
now: Signal<f64>,
|
|
||||||
) -> View {
|
) -> View {
|
||||||
// Own seat: the only player entry carrying a hand.
|
// Own seat: the only player entry carrying a hand.
|
||||||
let viewer_seat = game
|
let viewer_seat = game
|
||||||
@@ -219,19 +218,6 @@ fn table_view(
|
|||||||
format!("{name}'s turn")
|
format!("{name}'s turn")
|
||||||
};
|
};
|
||||||
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
|
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 scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||||
let table_cards = game
|
let table_cards = game
|
||||||
@@ -286,7 +272,6 @@ fn table_view(
|
|||||||
(target_score) ")"
|
(target_score) ")"
|
||||||
}
|
}
|
||||||
span(class=turn_cls) { (turn_note) }
|
span(class=turn_cls) { (turn_note) }
|
||||||
(countdown)
|
|
||||||
}
|
}
|
||||||
div(class="table-grid") {
|
div(class="table-grid") {
|
||||||
(top)
|
(top)
|
||||||
|
|||||||
@@ -212,11 +212,6 @@ table.matches td.lost {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turn-timer {
|
|
||||||
color: var(--muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user