Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user