Replace requirements.txt with a uv workspace and lockfile, install the five tavolo packages from packages/* in development and non-editable in the image, and build the Docker image with uv. Move dev tools to a PEP 735 dependency group and refresh the locked dependency versions.
154 lines
6.8 KiB
Markdown
154 lines
6.8 KiB
Markdown
# tavolo
|
||
|
||
A platform for multiplayer card games. Four games are included:
|
||
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||
game — **scopa**, in its classic two-player and four-player partnership
|
||
(*scopa a quattro*) variants, with the *asso pigliatutto* option turning
|
||
either into **scopa d'assi** — **briscola**, in its classic two-player
|
||
and four-player partnership (*briscola a quattro*) variants, and
|
||
**briscola chiamata** — the five-player variant with a calling auction
|
||
and a secret partner:
|
||
|
||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||
A monorepo of five distributions sharing the `tavolo` namespace: the
|
||
game-independent `tavolo-platform`, the `tavolo-scopone`,
|
||
`tavolo-scopa` and `tavolo-briscola` games, and the `tavolo-app`
|
||
composition root wiring them together. See
|
||
[server/README.md](server/README.md).
|
||
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
||
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
||
`web/vendor/` so builds work offline) plus the custom felt theme in
|
||
`web/theme.css`. Also an installable PWA — see [PWA](#pwa). Card images
|
||
are the CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||
|
||
## Run the whole stack
|
||
|
||
```sh
|
||
docker compose up --build
|
||
```
|
||
|
||
Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`,
|
||
`dave`), the database migrator and the app all come up together. The app —
|
||
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/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**: scopone and scopa players see how each
|
||
category was won (carte, denara, settebello, primiera, scope — plus
|
||
napola and asso pigliatutto when enabled), briscola players see the
|
||
deal's points and any *patta*; both see the running totals and must click
|
||
"Understood" before the next hand is dealt. If someone is away the next
|
||
hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` (default
|
||
30s). The match-ending hand is explained on the final screen. Briscola
|
||
chiamata plays a single deal per match, so it goes straight from the last
|
||
trick to the final scoring screen; a 60–60 *patta* is replayed
|
||
immediately.
|
||
|
||
## 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 (choosing among the legal captures when the
|
||
rules require a capture), so one absent player cannot stall the table.
|
||
Briscola chiamata arms the same countdown for its auction turns and for
|
||
the auction winner's called-card choice: an absent bidder passes, the
|
||
last player standing with no bid is made to bid the ace, and a timed-out
|
||
call names a random suit of the won rank.
|
||
|
||
## Watching
|
||
|
||
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
||
spectators see the table, the score and every player's captured cards
|
||
(and scope for scopone), but never a hand and never the join code. In
|
||
briscola chiamata they also see no role labels and no side totals until
|
||
the called card is played, so the secret partner stays secret. The
|
||
"Copy watch link" button in the waiting room and in the game HUD copies
|
||
the URL to share. A spectator who opens the URL before the match starts
|
||
sees a "game not started yet" waiting room.
|
||
|
||
## PWA
|
||
|
||
The frontend is an installable PWA: `web/manifest.webmanifest` describes
|
||
the app (standalone display, felt theme colors, PNG icons), and `web/sw.js`
|
||
is a caching-free service worker — it deliberately caches nothing, since
|
||
live state always comes from the server and hashed asset names change every
|
||
build — that also handles clicks on move notifications. Browsers offer to
|
||
install Tavolo over HTTPS (or on localhost in development).
|
||
|
||
Both files must be served from the site root, or the worker's scope and the
|
||
manifest's `start_url` could not cover the app: Granian only serves
|
||
`/static` and `/assets`, so `tavolo/static.py` answers
|
||
`/manifest.webmanifest` and `/sw.js` (revalidated on every load) from the
|
||
same build directory as the shell. Icons are committed under
|
||
`web/assets/icons/`; `web/make-icons.sh` regenerates them with
|
||
`rsvg-convert` after editing the vector sources.
|
||
|
||
## Move notifications
|
||
|
||
Players can opt into desktop notifications for the other players' card
|
||
plays with the bell in the game HUD (the first click asks for the browser
|
||
permission; the preference is stored per device and applies to every
|
||
game). Notifications only appear while the game tab is in the background
|
||
or its window is unfocused — never while the player is watching the table
|
||
— and only for games the viewer is seated in, never for their own plays.
|
||
They are delivered through the service worker (so installed PWAs get them
|
||
too) under a per-game tag, which replaces the previous move instead of
|
||
stacking one notification per play; clicking one focuses the game tab.
|
||
The game page must stay open to receive them, since that is where the
|
||
live-play websocket lives.
|
||
|
||
## Development
|
||
|
||
Backend (from `server/`), using [uv](https://docs.astral.sh/uv/):
|
||
|
||
```sh
|
||
cd server
|
||
uv sync --package tavolo-app --extra otel
|
||
uv run python -m unittest discover -s tests -t .
|
||
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||
```
|
||
|
||
The `otel` extra is optional; without it the telemetry integration tests
|
||
skip. Development tools (`mypy`, `httpx-ws`) are installed by default from
|
||
the workspace's `dev` dependency group.
|
||
|
||
Frontend (from `web/`), with the backend running on :8080:
|
||
|
||
```sh
|
||
cd web
|
||
trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied
|
||
```
|
||
|
||
Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
|
||
`web/Trunk.toml`). For the login redirect to land back on the dev server,
|
||
run the backend with:
|
||
|
||
```sh
|
||
OIDC_0_PROVIDER=default \
|
||
OIDC_0_ISSUER=http://localhost:8180/tavolo \
|
||
OIDC_0_CLIENT_ID=tavolo \
|
||
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
|
||
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||
uv run granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||
```
|
||
|
||
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||
re-downloads them if needed. PWA icons are committed under
|
||
`web/assets/icons/`; `web/make-icons.sh` regenerates them from the SVG
|
||
sources.
|