Add preliminary support for multiple card games
CI / Build and push docker image (push) Successful in 3m5s

A game-type registry (server/src/tavolo/games.py) is now the single
source of truth for the games the platform can host; only scopone
scientifico is registered so far. GET /api/game-types exposes it for the
lobby's new game dropdown, and POST /api/games accepts a validated
game_type (default scopone_scientifico) which is carried on the live
GameState and onto each finished Match row (new indexed column,
migration 1_20260916235833_update), so statistics can be scoped per
game: /api/me/matches and /api/leaderboard take an optional game_type
filter and every serialized match includes its game_type.

Game states serialized before this change still load with the default
game type.
This commit is contained in:
2026-09-17 08:26:46 +08:00
parent 876b4abd8b
commit ab4130a4ca
15 changed files with 376 additions and 28 deletions
+10 -7
View File
@@ -107,8 +107,10 @@ loggers:
### Postgres (statistics, via Tortoise ORM + aerich migrations) ### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `match` — one row per finished match: both teams' final scores, winner, - `match` — one row per finished match: the game played (`game_type`, one
target score, hands played, start/finish timestamps. of the ids from `GET /api/game-types`, indexed so statistics can be
scoped per game), both teams' final scores, winner, target score, hands
played, start/finish timestamps.
- `match_player` — one row per participant: the OIDC `sub`, display name, - `match_player` — one row per participant: the OIDC `sub`, display name,
seat, team and whether they won. Unique per `(match, user_sub)`. seat, team and whether they won. Unique per `(match, user_sub)`.
@@ -118,16 +120,17 @@ Redis until its TTL expires so clients can still fetch the final board.
## REST API ## REST API
All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json` All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
require authentication. `/api/game-types` and `/api/leaderboard` require authentication.
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"target_score": 11}`. Returns `{id, join_code}` | | `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11}`. Returns `{id, join_code}` |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=`) | | `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player | | `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
## WebSocket protocol ## WebSocket protocol
@@ -0,0 +1,42 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "game_type" VARCHAR(32) NOT NULL DEFAULT 'scopone_scientifico';
CREATE INDEX IF NOT EXISTS "idx_match_game_ty_7d519c" ON "match" ("game_type");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_match_game_ty_7d519c";
ALTER TABLE "match" DROP COLUMN "game_type";"""
MODELS_STATE = (
"eJztmW1P4zgQgP9KlE+ctIdoYGF1Op2UlqLtLW0RLXenXa0sN3FTi8TO2s51K47/frbTNI"
"3zQgsUAeoXaGY8jv2MX2Ymd3ZEfRTywz4U3sz+zbqzCYyQ/FFUfLBsGMe5WAkEnIRpy1WT"
"CRcMekIKpzDkSIp8xD2GY4EpUU1dy6NRHCKBfEubWXRqUYLUPzFDFkMB5gIxqQ7kOCyxiB"
"E/VH371JOdYxI8rZuE4B8JAoIGSDZksrNv36UYEx/9RDx7jG/BFKPQLwDBvupAy4HqUMlu"
"bnrnF7qlGuIEeDRMIpK3jhdiRsmqeZJg/1DZKF2ACGJQTmENF0nCcIk1E6UjlgLBErQaqp"
"8LfDSFSaig279PE+Ip1pZ+k/pz8sdyaGvNABgMx2DUHQNgl3ykhmDwXoo8SpR/MREK1N19"
"2m8OREtt9YLOZ/f64Pj0F42AchEwrdS47HttCAVMTTX0nLLyV8qrBLszg6wadsHIYC6H/B"
"jamaAJN/doLFcd4B5GROAp9uiuYMtN9hOEiARC7dJjpwH+X+51yt/R/Knckek+HSw1jlYp"
"N+TYBYIRgHImlFWQH0UwDHtEVNM3bQ0HyCk8jwPyMyWDm+HbBfBADeLXY+fs9JPU6jGqh7"
"MG8qO+e3nZG4yr2E6ewHayZ1vHdo6JnDpQmLY5MAyzXR0ZL0u1cES0NjghWrUHRKt0PkAm"
"b8xHrmHDdr+Gi2xnkPgcxCFcoIoQo5mtabtnW2TL5eKT8wZQlMmeSyICR6iabNHS4OovTQ"
"+zH2+QcgPMca/fHY3d/pXqPuL8R6h5ueOu0jhaujCkB6fGabLqxPq7N/5sqUfr63DQNYPC"
"VbvxV1uNCSaCAkLnAPrrTDJxJiq4eYoJ5rNH+dkw3Tv61TlaZWTT27VsQQkm0LudQ+aDgi"
"ZfEfpIZLy8GtpLw4sv1yiEmmXZ7evJ75Xu6e36PZfaaRKjgVKH1hEtqyInMiWQwEBPSb1b"
"vakCWV05ISf6QFEhvdjYZrWFK3leYw/H2qdZQSDhiFmY6N+6y3IxYQu7iurBt7z8oWwATy"
"b2931J4TWVFFZ+2SJBWLd5sYLCiyUHzsePG6QHslVtgqB1xVDLx1xtV6Cft0Bt2r3DZMw5"
"OtqE99FRPW+lM0JbVBXsNKcLmc0+TSiXaLZZs/vCQc06LRUO5ml8ZcRglIYIkpraTGVENp"
"Emb5BtA8z2cHhZCLvbvbGB9abf7ma0ZSMstLi8fNNgabtoYt3mOWOK1wv8gRCilGYYfMtw"
"LyhDOCBf0EIj7smBQOJVXWLmx7S3CbWUSUgxg/NVYFtYU3L2cs4oXbIdd9Rxz7v2fX3qts"
"ukxEUMV3/eXGo+NKUiMG/zUBJST/SZPzzW3vCVG7rifl+672nZwe4vd6d1cnby6fj0ZHXD"
"ryRN13x2RNYnBf8ixnHV3VR/6a+ZvMN7fyc5gdpUWxBeNn+HdFsbZQCthgygVc4A5BsFIh"
"VJwJ+j4aCa8JqJWenEnrD+s0LM32Iu0ABXwSjEWRnTg777j4m7czlsm/GB6qBdFSC85GV2"
"/z8H5XI/"
)
+3
View File
@@ -134,6 +134,7 @@ def create_game(
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, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
game_type: str = "scopone_scientifico",
) -> 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,6 +143,7 @@ def create_game(
id=game_id, id=game_id,
join_code=join_code, join_code=join_code,
creator_sub=creator_sub, creator_sub=creator_sub,
game_type=game_type,
target_score=target_score, target_score=target_score,
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)],
@@ -488,6 +490,7 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
payload: Dict[str, object] = { payload: Dict[str, object] = {
"id": state.id, "id": state.id,
"join_code": state.join_code, "join_code": state.join_code,
"game_type": state.game_type,
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"hand_number": state.hand_number, "hand_number": state.hand_number,
+5
View File
@@ -148,6 +148,9 @@ class GameState:
id: str id: str
join_code: str join_code: str
creator_sub: str creator_sub: str
# Which card game this state belongs to (see tavolo.games.GAME_TYPES).
# Defaults so states serialized before game types existed still load.
game_type: str = "scopone_scientifico"
target_score: int = DEFAULT_TARGET_SCORE target_score: int = DEFAULT_TARGET_SCORE
phase: str = PHASE_LOBBY phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list) players: List[PlayerState] = field(default_factory=list)
@@ -184,6 +187,7 @@ class GameState:
"id": self.id, "id": self.id,
"join_code": self.join_code, "join_code": self.join_code,
"creator_sub": self.creator_sub, "creator_sub": self.creator_sub,
"game_type": self.game_type,
"target_score": self.target_score, "target_score": self.target_score,
"phase": self.phase, "phase": self.phase,
"players": [p.to_json() for p in self.players], "players": [p.to_json() for p in self.players],
@@ -212,6 +216,7 @@ class GameState:
id=str(data["id"]), id=str(data["id"]),
join_code=str(data["join_code"]), join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")), creator_sub=str(data.get("creator_sub", "")),
game_type=str(data.get("game_type", "scopone_scientifico")),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
phase=str(data.get("phase", PHASE_LOBBY)), phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])], players=[PlayerState.from_json(p) for p in data.get("players", [])],
+42
View File
@@ -0,0 +1,42 @@
"""Registry of the card games the platform can host.
Only *scopone scientifico* is implemented for now; adding a game means a
new entry here plus its engine. The registry is the single source of truth
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
and persisted on each finished match (:mod:`tavolo.models`), which is what
makes match statistics game-scoped.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass(frozen=True)
class GameType:
"""Metadata describing one playable card game."""
id: str
name: str
description: str
SCOPONE_SCIENTIFICO = "scopone_scientifico"
GAME_TYPES: Dict[str, GameType] = {
SCOPONE_SCIENTIFICO: GameType(
id=SCOPONE_SCIENTIFICO,
name="Scopone scientifico",
description=(
"Four players in fixed partnerships, ten cards each and an empty "
"table. First team to the target score wins."
),
),
}
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
def get_game_type(game_type_id: str) -> Optional[GameType]:
"""Return the registered game type with id ``game_type_id``, if any."""
return GAME_TYPES.get(game_type_id)
+4 -1
View File
@@ -15,9 +15,12 @@ from tortoise.models import Model
class Match(Model): class Match(Model):
"""A completed scopone scientifico match.""" """A completed match of one of the registered game types."""
id = fields.UUIDField(pk=True) id = fields.UUIDField(pk=True)
# Which card game was played (tavolo.games.GAME_TYPES); the default
# backfills matches recorded before game types existed.
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
team_a_score = fields.SmallIntField() team_a_score = fields.SmallIntField()
team_b_score = fields.SmallIntField() team_b_score = fields.SmallIntField()
# "A" or "B". # "A" or "B".
+42 -8
View File
@@ -23,6 +23,7 @@ from ..config import settings
from ..game import engine from ..game import engine
from ..game.errors import GameError from ..game.errors import GameError
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
from ..games import GAME_TYPES, get_game_type
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
log = getLogger(__name__) log = getLogger(__name__)
@@ -49,6 +50,7 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
return { return {
"id": state.id, "id": state.id,
"join_code": state.join_code, "join_code": state.join_code,
"game_type": state.game_type,
"target_score": state.target_score, "target_score": state.target_score,
"phase": state.phase, "phase": state.phase,
"players": [ "players": [
@@ -59,6 +61,21 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
} }
@app.GET("/api/game-types")
@operation(summary="List available games",
description="Every card game the platform can host, for the "
"match-creation dropdown.",
tags=["games"],
responses={200: {"description": "The available game types"}})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
{"id": g.id, "name": g.name, "description": g.description}
for g in GAME_TYPES.values()
]
})
@app.POST("/api/games") @app.POST("/api/games")
@operation(summary="Create a game", @operation(summary="Create a game",
description="Creates a lobby game and seats the caller in seat 0. " description="Creates a lobby game and seats the caller in seat 0. "
@@ -68,18 +85,23 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
"required": False, "required": False,
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"target_score": {"type": "integer", "minimum": 1, "maximum": 100}, "game_type": {
}, "type": "string",
} "default": "scopone_scientifico",
"description": "One of the ids from GET /api/game-types",
},
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
},
}
} }
}, },
}, },
responses={ responses={
201: {"description": "The created lobby"}, 201: {"description": "The created lobby"},
400: {"description": "Invalid target_score or body"}, 400: {"description": "Invalid game_type, target_score or body"},
401: {"description": "Authentication required"}, 401: {"description": "Authentication required"},
}) })
@require_auth @require_auth
@@ -96,6 +118,11 @@ async def create_game(ctx: HttpContext) -> None:
await send_error(ctx, 400, "target_score must be an integer") await send_error(ctx, 400, "target_score must be an integer")
return return
game_type: Any = body.get("game_type", "scopone_scientifico")
if not isinstance(game_type, str) or get_game_type(game_type) is None:
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
return
user = oidc_mixin.get_user(ctx) user = oidc_mixin.get_user(ctx)
assert user is not None # enforced by @require_auth assert user is not None # enforced by @require_auth
game_id = str(uuid.uuid4()) game_id = str(uuid.uuid4())
@@ -109,12 +136,19 @@ async def create_game(ctx: HttpContext) -> None:
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, turn_timeout=settings.turn_timeout_seconds,
game_type=game_type,
) )
except GameError as exc: except GameError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
return return
await game_store.save(state) await game_store.save(state)
log.info("game %s created by %s (target score %d)", game_id, user.sub, target_score) log.info(
"game %s created by %s (%s, target score %d)",
game_id,
user.sub,
game_type,
target_score,
)
await send_json(ctx, 201, _lobby_payload(state)) await send_json(ctx, 201, _lobby_payload(state))
+47 -6
View File
@@ -6,23 +6,47 @@ leaderboard aggregated from the same two tables.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List, Optional, Tuple
from kaya.core import HttpContext from kaya.core import HttpContext
from kaya.openapi import operation from kaya.openapi import operation
from ..app import app, oidc_mixin from ..app import app, oidc_mixin
from ..auth import require_auth from ..auth import require_auth
from ..http import send_error, send_json from ..games import get_game_type
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer from ..models import Match, MatchPlayer
from ..openapi import PAGINATION_PARAMETERS from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params from ..pagination import CursorDecodeError, paginate, parse_cursor_params
GAME_TYPE_PARAMETER: Dict[str, Any] = {
"name": "game_type",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Only count matches of this game (id from GET /api/game-types).",
}
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
"""Parse the ``game_type`` query parameter.
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
when valid, ``(None, message)`` when it names no registered game."""
values = extract_query_params(query_string).get("game_type")
if not values:
return None, None
game_type = values[0]
if get_game_type(game_type) is None:
return None, f"unknown game_type: {game_type!r}"
return game_type, None
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]: async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat") participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
return { return {
"id": str(match.id), "id": str(match.id),
"game_type": match.game_type,
"team_a_score": match.team_a_score, "team_a_score": match.team_a_score,
"team_b_score": match.team_b_score, "team_b_score": match.team_b_score,
"winner_team": match.winner_team, "winner_team": match.winner_team,
@@ -49,10 +73,10 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
description="Cursor-paginated history of finished matches the caller " description="Cursor-paginated history of finished matches the caller "
"played, newest first, with the final score.", "played, newest first, with the final score.",
tags=["stats"], tags=["stats"],
parameters=PAGINATION_PARAMETERS, parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={ responses={
200: {"description": "A page of matches"}, 200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor"}, 400: {"description": "Invalid pagination cursor or game_type"},
401: {"description": "Authentication required"}, 401: {"description": "Authentication required"},
}) })
@require_auth @require_auth
@@ -62,9 +86,15 @@ async def my_matches(ctx: HttpContext) -> None:
except CursorDecodeError as exc: except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
return return
game_type, error = _parse_game_type(ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
user = oidc_mixin.get_user(ctx) user = oidc_mixin.get_user(ctx)
assert user is not None assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct() queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate( matches, next_cursor = await paginate(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
) )
@@ -77,9 +107,20 @@ async def my_matches(ctx: HttpContext) -> None:
description="Aggregated wins, matches played and team points for every " description="Aggregated wins, matches played and team points for every "
"player with at least one finished match. Sorted by wins.", "player with at least one finished match. Sorted by wins.",
tags=["stats"], tags=["stats"],
responses={200: {"description": "The leaderboard"}}) parameters=[GAME_TYPE_PARAMETER],
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
})
async def leaderboard(ctx: HttpContext) -> None: async def leaderboard(ctx: HttpContext) -> None:
rows = await MatchPlayer.all().prefetch_related("match") game_type, error = _parse_game_type(ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
queryset = MatchPlayer.all()
if game_type is not None:
queryset = queryset.filter(match__game_type=game_type)
rows = await queryset.prefetch_related("match")
aggregate: Dict[str, Dict[str, Any]] = {} aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows: for row in rows:
entry = aggregate.setdefault( entry = aggregate.setdefault(
+3 -1
View File
@@ -39,6 +39,7 @@ async def save_match_result(state: GameState) -> None:
async with in_transaction(): async with in_transaction():
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
game_type=state.game_type,
team_a_score=state.scores[0], team_a_score=state.scores[0],
team_b_score=state.scores[1], team_b_score=state.scores[1],
winner_team=TEAM_NAMES[state.winner], winner_team=TEAM_NAMES[state.winner],
@@ -59,8 +60,9 @@ async def save_match_result(state: GameState) -> None:
) )
state.stats_saved = True state.stats_saved = True
log.info( log.info(
"match result persisted: game %s, team %s won %d-%d over %d hands", "match result persisted: game %s (%s), team %s won %d-%d over %d hands",
state.id, state.id,
state.game_type,
TEAM_NAMES[state.winner], TEAM_NAMES[state.winner],
state.scores[0], state.scores[0],
state.scores[1], state.scores[1],
+48
View File
@@ -119,5 +119,53 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
class GameTypesRouteTest(unittest.TestCase):
@async_test
async def test_lists_available_game_types(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/game-types")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
self.assertEqual("Scopone scientifico", results[0]["name"])
self.assertTrue(results[0]["description"])
@async_test
async def test_create_defaults_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post("/api/games", json={})
self.assertEqual(201, created.status_code)
self.assertEqual("scopone_scientifico", created.json()["game_type"])
@async_test
async def test_create_with_explicit_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games", json={"game_type": "scopone_scientifico"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopone_scientifico", body["game_type"])
with oidc_user("alice"):
snapshot = await client.get(f"/api/games/{body['id']}")
self.assertEqual("scopone_scientifico", snapshot.json()["game_type"])
@async_test
async def test_create_rejects_unknown_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
unknown = await client.post("/api/games", json={"game_type": "briscola"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+44 -1
View File
@@ -66,11 +66,13 @@ class SaveMatchResultTest(unittest.TestCase):
assert match is not None assert match is not None
self.assertEqual(state.scores[0], match.team_a_score) self.assertEqual(state.scores[0], match.team_a_score)
self.assertEqual("A", match.winner_team) self.assertEqual("A", match.winner_team)
# The game type travels from the live state onto the row.
self.assertEqual("scopone_scientifico", match.game_type)
winners = await MatchPlayer.filter(won=True) winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
async def _seed_two_matches() -> None: async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None:
ctx = await _use_app_db() ctx = await _use_app_db()
with ctx: with ctx:
for index, (a_score, b_score, winner, finished) in enumerate( for index, (a_score, b_score, winner, finished) in enumerate(
@@ -81,6 +83,7 @@ async def _seed_two_matches() -> None:
): ):
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
game_type=game_types[index],
team_a_score=a_score, team_a_score=a_score,
team_b_score=b_score, team_b_score=b_score,
winner_team=winner, winner_team=winner,
@@ -165,5 +168,45 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual("alice", response.json()["results"][0]["user_sub"]) self.assertEqual("alice", response.json()["results"][0]["user_sub"])
class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=scopone_scientifico")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual(
{"scopone_scientifico", "other_game"},
{m["game_type"] for m in all_matches.json()["results"]},
)
scoped_results = scoped.json()["results"]
self.assertEqual(1, len(scoped_results))
self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"])
self.assertEqual(400, unknown.status_code)
@async_test
async def test_leaderboard_filter_by_game_type(self) -> None:
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico")
unknown = await client.get("/api/leaderboard?game_type=briscola")
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, team A won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(400, unknown.status_code)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+18
View File
@@ -25,6 +25,24 @@ class InMemoryGameStoreTest(unittest.TestCase):
self.assertEqual(16, loaded.target_score) self.assertEqual(16, loaded.target_score)
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players]) self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
@async_test
async def test_game_type_roundtrip_and_default(self) -> None:
store = InMemoryGameStore()
state = engine.create_game(
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico"
)
await store.save(state)
loaded = await store.load("g1b")
assert loaded is not None
self.assertEqual("scopone_scientifico", loaded.game_type)
# States serialized before game types existed load with the default.
legacy = state.to_json()
del legacy["game_type"]
from tavolo.game.state import GameState
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
@async_test @async_test
async def test_load_missing_returns_none(self) -> None: async def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore()
+15 -2
View File
@@ -22,9 +22,22 @@ pub async fn me() -> Result<Option<User>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string()) resp.json().await.map(Some).map_err(|e| e.to_string())
} }
pub async fn create_game(target_score: i32) -> Result<GameView, String> { /// Fetch the card games the platform can host (for the creation dropdown).
pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
let resp = Request::get("/api/game-types")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
pub async fn create_game(game_type: &str, target_score: i32) -> Result<GameView, String> {
let resp = Request::post("/api/games") let resp = Request::post("/api/games")
.json(&serde_json::json!({ "target_score": target_score })) .json(&serde_json::json!({ "game_type": game_type, "target_score": target_score }))
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.send() .send()
.await .await
+21
View File
@@ -101,6 +101,9 @@ pub struct GameView {
pub id: String, pub id: String,
#[serde(default)] #[serde(default)]
pub join_code: String, pub join_code: String,
/// Which card game this match is (id from /api/game-types).
#[serde(default)]
pub game_type: String,
pub phase: String, pub phase: String,
#[serde(default)] #[serde(default)]
pub target_score: i32, pub target_score: i32,
@@ -174,6 +177,8 @@ pub struct MatchPlayer {
#[allow(dead_code)] #[allow(dead_code)]
pub struct MatchSummary { pub struct MatchSummary {
pub id: String, pub id: String,
#[serde(default)]
pub game_type: String,
pub team_a_score: i32, pub team_a_score: i32,
pub team_b_score: i32, pub team_b_score: i32,
pub winner_team: String, pub winner_team: String,
@@ -211,6 +216,22 @@ pub struct LeaderboardPage {
pub results: Vec<LeaderboardEntry>, pub results: Vec<LeaderboardEntry>,
} }
/// A card game the platform can host (GET /api/game-types).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[allow(dead_code)]
pub struct GameTypeInfo {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GameTypesPage {
#[serde(default)]
pub results: Vec<GameTypeInfo>,
}
/// Map a card code (e.g. `07D`) to its asset path. /// Map a card code (e.g. `07D`) to its asset path.
pub fn card_asset(code: &str) -> String { pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg") format!("/assets/cards/{code}.svg")
+32 -2
View File
@@ -4,7 +4,16 @@ use sycamore::prelude::*;
use sycamore_router::navigate; use sycamore_router::navigate;
use crate::api; use crate::api;
use crate::model::User; use crate::model::{GameTypeInfo, User};
/// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> {
vec![GameTypeInfo {
id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(),
description: String::new(),
}]
}
#[component] #[component]
pub fn LobbyPage() -> View { pub fn LobbyPage() -> View {
@@ -12,6 +21,8 @@ pub fn LobbyPage() -> View {
let user = create_signal(Option::<Option<User>>::None); let user = create_signal(Option::<Option<User>>::None);
let error = create_signal(Option::<String>::None); let error = create_signal(Option::<String>::None);
let code = create_signal(String::new()); let code = create_signal(String::new());
let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string());
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
@@ -23,9 +34,20 @@ pub fn LobbyPage() -> View {
} }
}); });
spawn_local(async move {
match api::game_types().await {
Ok(types) if !types.is_empty() => {
selected_game.set(types[0].id.clone());
game_types.set(types);
}
_ => {} // keep the scopone fallback
}
});
let on_create = move |target: i32| { let on_create = move |target: i32| {
let game_type = selected_game.get_clone();
spawn_local(async move { spawn_local(async move {
match api::create_game(target).await { match api::create_game(&game_type, target).await {
Ok(game) => navigate(&format!("/game/{}", game.id)), Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)), Err(e) => error.set(Some(e)),
} }
@@ -70,6 +92,14 @@ pub fn LobbyPage() -> View {
} }
div(class="panel") { div(class="panel") {
h2 { "New match" } h2 { "New match" }
label(r#for="game-type") { "Game" }
select(id="game-type", bind:value=selected_game) {
Keyed(
list=game_types,
view=|g| view! { option(value=g.id.clone()) { (g.name) } },
key=|g| g.id.clone(),
)
}
p { "First team to reach the target score wins." } p { "First team to reach the target score wins." }
div(class="target-buttons") { div(class="target-buttons") {
button(class="button", on:click=move |_| on_create(11)) { "Target 11" } button(class="button", on:click=move |_| on_create(11)) { "Target 11" }