Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s

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.
This commit is contained in:
2026-10-06 14:08:25 +08:00
parent b5219062f2
commit 7bb6b74fdc
34 changed files with 3610 additions and 90 deletions
+2 -2
View File
@@ -48,8 +48,8 @@ itself ships no game.
Implement `GameEngine` (see `engine.py` for the full contract), register
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
`KayaApp` — see the composition root in `tavolo.app`. The canonical
example is
[`tavolo-scopone`](../tavolo-scopone/README.md).
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
[`tavolo-briscola`](../tavolo-briscola/README.md).
## Development (from `server/`)
@@ -71,7 +71,7 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
}
for index, seat in enumerate(session.players)
],
"seats_open": engine.max_players - len(session.players),
"seats_open": max(0, engine.max_players - len(session.players)),
**engine.lobby_view(session),
}
@@ -85,6 +85,9 @@ def _view_payload(
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
@@ -36,9 +36,10 @@ Client -> server messages are JSON objects::
For scopone scientifico the game actions are ``play`` (with ``card``
and optionally ``capture``) and ``ack`` — see
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
``action``) is handed to the engine as the payload, games define their
own fields freely.
:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card``
plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole
message (minus ``action``) is handed to the engine as the payload, games
define their own fields freely.
Mutations run under the per-game lock; after a successful action the new
session is saved and a change signal is published. Every connected
@@ -101,6 +102,9 @@ def _state_message(
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
# Free chairs; the lobby view needs it for games with other player
# counts than four.
"seats_open": max(0, engine.max_players - len(session.players)),
}
if seated:
# The join code is a credential: only players get it.
@@ -132,6 +132,33 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("state", update["type"])
self.assertEqual(1, update["game"]["plays"])
@async_test
async def test_state_reports_open_seats(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
game_id = created.json()["id"]
code = created.json()["join_code"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(1, first["game"]["seats_open"])
# Once the second player joins the match starts and no seat is
# left open.
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(0, first["game"]["seats_open"])
@async_test
async def test_unknown_action_returns_error(self) -> None:
app, platform, _ = make_platform()