New tavolo-briscola distribution implementing briscola on the platform:
- pure rules (state.py, engine.py): 3-card deals, a face-up trump that
is the last card drawn, trump-first trick resolution (A > 3 > K > H >
J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a
configurable target score (default 61), deal-end acknowledgements and
turn auto-play;
- BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine
(briscola_a_quattro, 4 players in fixed partnerships) adapters;
- registered in tavolo-app, packaged in the Docker image, documented.
Platform fixes surfaced by a two-player lobby:
- the websocket state and snapshot payloads now carry seats_open (they
were only in the REST lobby payload), so lobbies with player counts
other than four render correctly; seats_open is clamped at zero;
- the web client logs unparseable server messages instead of silently
dropping them.
Web client: briscola table for both variants (stock, face-up trump,
trick, last-trick banner), deal summary modal and game-over overlay.
Tests: tavolo-briscola rules/adapter suites, app integration tests
(lobby, websocket play, turn timeout, deal-end acks, persistence), a
platform seats_open test and frontend model parsing tests.
Anonymous users and logged-in non-players can now open /game/<id> and
follow the match: they see the table, the score and each player's
captured cards and scope, but never a hand and never the join code.
Spectator actions are rejected with a forbidden error.
Backend (tavolo-platform / tavolo-scopone):
- /ws/games/{id} accepts anonymous and non-seated connections as
spectators; only unknown games are refused. The state payload carries
spectator: true and withholds join_code unless the viewer is seated.
- GET /api/games/{id} is public and returns the same personalized
(player) or read-only (spectator) view.
- GameEngine.view_for/game_over_view take Optional[str] (None = spectator)
and are documented to hide privileged information for non-seated
viewers.
Frontend (Sycamore):
- Parse the spectator flag and render a spectator table: card backs plus
captured/scope for every seat, no clickable hand, "Spectating" badge.
- A pre-start spectator sees a "game not started yet" waiting room with
the watch link and no join code.
- "Copy watch link" buttons in the waiting room and the game HUD.
- The hand-end summary carries no acknowledge button for spectators and
the game-over overlay hides "My matches".
Tests and docs updated.
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.
The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.
Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
kaya-oidc 0.0.6 namespaces its session state per provider as
oidc:{provider}:user (the default provider is "default", so the key is
oidc:default:user). The unprefixed oidc_user key is no longer written:
left unadapted, every websocket upgrade would be rejected with 401 and
traced requests would silently lose their user.id attribute. Adopt it
with a session_user helper on tavolo.platform.auth that finds any
provider's user, used by both get_ws_user and telemetry's
session_tag_hook. Websocket and route handlers keep their signatures.
kaya-otel 0.0.6 only enriches websocket spans with standard URL, network
and peer attributes (plus error.type), which needs no application change.
Regenerate the lock: the eight kaya pins move to 0.0.6 and, the wheels'
declared metadata being unchanged, nothing else moves. tavolo-platform
now floors kaya-core>=0.0.6 (it reads the new session layout) and
tavolo-app floors kaya-otel>=0.0.6.
As kaya documents, the session-key move signs existing logged-in users
out once; they simply log in again.
kaya 0.0.5 surfaces a vanished websocket peer as
kaya.core.WebSocketDisconnect instead of leaking granian's
RSGIProtocolClosed: receive() reports a close message so iteration ends
normally, sends raise the new exception, and KayaApp.handle_websocket
swallows it as normal termination. Adopt it: drop the granian-specific
workaround from the platform websocket handler, catch
WebSocketDisconnect in the send helper, and model the new semantics in
the abrupt-disconnect test (with coverage that a send on a vanished
peer is ignored).
Regenerate the lock: the eight kaya pins move to 0.0.5 (declared
metadata is unchanged) and pip-compile also bumps PyJWT to 2.15.1, a
security release with no breaking changes for kaya-oidc's jwt.decode
usage. tavolo-platform now floors kaya-core>=0.0.5 (it imports
WebSocketDisconnect) and tavolo-app floors kaya-otel>=0.0.5.
A close() before accept() carries the HTTP status of the refused
upgrade, not a WebSocket close code: RSGI (granian) uses the value
verbatim as the response status, so 4401 panicked on the invalid status
(InvalidStatusCode) and — granian being built with panic = "abort" —
aborted the worker and took the whole app down. ASGI servers answer 403
regardless, and browsers report a refused handshake as an abnormal close
(1006), so the 4401/4403/4404 codes never reached clients anyway.
Reject 401/404/403 to mirror GET /api/games/{id}, and classify a failed
first connection in the game page through the REST API so players still
see "session expired" / "game no longer available" instead of a retry
loop. Add a regression test asserting every pre-accept rejection uses a
status in the HTTP range.
The root logger runs at DEBUG, so urllib3.connectionpool emitted a
record per pooled HTTP request. Keep its INFO+ output while dropping
the per-connection chatter.
Cursors are JSON, so a datetime sort key reached the next request as a
string. Tortoise's DatetimeField.to_db_value does not parse strings, so
asyncpg rejected the string where a timestamptz parameter was expected;
sqlite tolerated it, hiding the bug in tests. Coerce decoded cursor
values through their model field's to_python_value before building the
keyset filter.
Builds the TracerProvider in tavolo.telemetry (kaya-otel's is private) and
injects it into kaya-otel, then instruments asyncpg and redis through
opentelemetry-instrumentation-asyncpg/redis. Redis command values are
redacted and asyncpg spans carry the parameterized SQL only.
Adds tavolo.active_users and tavolo.ongoing_games gauges, process/CPython
metrics via opentelemetry-instrumentation-system-metrics and OTLP log
export. The game store tracks ongoing matches (Redis sorted set, in-memory
set) and the session stores expose live counts for the gauges; a poller
refreshes them since metric callbacks cannot await the stores.
GET /api/games/preview returns the lobby behind a code plus the game's
name and engine-echoed options. The lobby debounces the lookup as a
complete code is typed and shows the game, seated players, open seats
and option badges rendered from the game's schema, disabling Join when
the lobby is full or the match has already started.
With the rule enabled, an ace played on an empty table goes straight to
the player's capture pile instead of staying on the table: it counts as
the last capture but never scores a scopa. An empty capture request now
matches only that self-capture option, so requesting nothing still can't
satisfy a real capture.
An ace played when no equal rank is on the table sweeps the whole
table; the sweep never scores a scopa, and an ace already on the table
still forces the mandatory equal-rank capture. The rule is off by
default, selectable per match via the schema-driven creation form, and
announced in the game view.
The deadline consumer runs in a long-lived task outside any request, so
a timeout that ended the match raised 'No TortoiseContext is currently
active' in save_match_result before the state was saved: the game stayed
stuck on the last turn and the entry retried forever. It only surfaced
on the match-deciding turn; ordinary timeouts and human plays were fine.
Bind the Tortoise context before persisting a finished match (optional
context_binder wired to TortoiseMixin.ensure_context), and back off to
the heartbeat when a due entry fails instead of hot-looping on it.
Relocate the composition root (app, config, static, logging_config,
aerich_config) from server/src/tavolo/ to
packages/tavolo-app/src/tavolo/, with its own pyproject.toml and README
like the sibling distributions. Module paths and the granian entrypoint
are unchanged.
server/pyproject.toml remains as a tooling shim ([tool.aerich] next to
./migrations, [tool.mypy] for runs from server/); the Dockerfile installs
all three distributions from ./packages. Verified: all suites green,
mypy clean per package, aerich resolves the moved config identically,
wheel builds.
Move the game-independent machinery (lobby, live-game store, websocket,
deadline scheduler, match history, leaderboards) into a new
tavolo-platform distribution behind a GameEngine contract, the scopone
scientifico rules plus a platform adapter into tavolo-scopone, and keep
only the composition root in tavolo-app. The three distributions share
the tavolo namespace (PEP 420, kaya-style monorepo).
Match history becomes fully generic: Match carries the engine's result
JSON and MatchPlayer points/details instead of scopone-shaped team
columns (migration 3 backfills existing rows). Lobby creation takes an
opaque per-game options object and websocket actions dispatch to the
session's engine.
Tests: platform suite runs against a DummyEngine toy game, scopone
keeps the rules tests plus new adapter tests, server/tests covers the
wired stack end to end (194 tests, was 143).