From ab4130a4ca95673781f438dcaa123a6e42aa3ad3 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Thu, 17 Sep 2026 08:26:46 +0800 Subject: [PATCH] Add preliminary support for multiple card games 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. --- server/README.md | 17 +++--- .../models/1_20260916235833_update.py | 42 +++++++++++++++ server/src/tavolo/game/engine.py | 3 ++ server/src/tavolo/game/state.py | 5 ++ server/src/tavolo/games.py | 42 +++++++++++++++ server/src/tavolo/models.py | 5 +- server/src/tavolo/routes/games.py | 50 ++++++++++++++--- server/src/tavolo/routes/stats.py | 53 ++++++++++++++++--- server/src/tavolo/stats.py | 4 +- server/tests/test_routes_games.py | 48 +++++++++++++++++ server/tests/test_stats.py | 45 +++++++++++++++- server/tests/test_store.py | 18 +++++++ web/src/api.rs | 17 +++++- web/src/model.rs | 21 ++++++++ web/src/pages/lobby.rs | 34 +++++++++++- 15 files changed, 376 insertions(+), 28 deletions(-) create mode 100644 server/migrations/models/1_20260916235833_update.py create mode 100644 server/src/tavolo/games.py diff --git a/server/README.md b/server/README.md index 6bb8bd1..2bcae4a 100644 --- a/server/README.md +++ b/server/README.md @@ -107,8 +107,10 @@ loggers: ### Postgres (statistics, via Tortoise ORM + aerich migrations) -- `match` — one row per finished match: both teams' final scores, winner, - target score, hands played, start/finish timestamps. +- `match` — one row per finished match: the game played (`game_type`, one + 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, 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 -All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json` -require authentication. +All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, +`/api/game-types` and `/api/leaderboard` require authentication. | 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 | | `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/leaderboard` | Aggregated wins / matches / team points per player | +| `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 (`?game_type=`) | ## WebSocket protocol diff --git a/server/migrations/models/1_20260916235833_update.py b/server/migrations/models/1_20260916235833_update.py new file mode 100644 index 0000000..b58943f --- /dev/null +++ b/server/migrations/models/1_20260916235833_update.py @@ -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/" +) diff --git a/server/src/tavolo/game/engine.py b/server/src/tavolo/game/engine.py index c06a328..16de8a6 100644 --- a/server/src/tavolo/game/engine.py +++ b/server/src/tavolo/game/engine.py @@ -134,6 +134,7 @@ def create_game( target_score: int = DEFAULT_TARGET_SCORE, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, + game_type: str = "scopone_scientifico", ) -> GameState: """Create a lobby game with the creator seated first.""" if target_score < 1 or target_score > 100: @@ -142,6 +143,7 @@ def create_game( id=game_id, join_code=join_code, creator_sub=creator_sub, + game_type=game_type, target_score=target_score, phase=PHASE_LOBBY, 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] = { "id": state.id, "join_code": state.join_code, + "game_type": state.game_type, "phase": state.phase, "target_score": state.target_score, "hand_number": state.hand_number, diff --git a/server/src/tavolo/game/state.py b/server/src/tavolo/game/state.py index 50d96cd..cd38fc9 100644 --- a/server/src/tavolo/game/state.py +++ b/server/src/tavolo/game/state.py @@ -148,6 +148,9 @@ class GameState: id: str join_code: 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 phase: str = PHASE_LOBBY players: List[PlayerState] = field(default_factory=list) @@ -184,6 +187,7 @@ class GameState: "id": self.id, "join_code": self.join_code, "creator_sub": self.creator_sub, + "game_type": self.game_type, "target_score": self.target_score, "phase": self.phase, "players": [p.to_json() for p in self.players], @@ -212,6 +216,7 @@ class GameState: id=str(data["id"]), join_code=str(data["join_code"]), 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)), phase=str(data.get("phase", PHASE_LOBBY)), players=[PlayerState.from_json(p) for p in data.get("players", [])], diff --git a/server/src/tavolo/games.py b/server/src/tavolo/games.py new file mode 100644 index 0000000..c1962ab --- /dev/null +++ b/server/src/tavolo/games.py @@ -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) diff --git a/server/src/tavolo/models.py b/server/src/tavolo/models.py index d628a8b..e6e2007 100644 --- a/server/src/tavolo/models.py +++ b/server/src/tavolo/models.py @@ -15,9 +15,12 @@ from tortoise.models import Model class Match(Model): - """A completed scopone scientifico match.""" + """A completed match of one of the registered game types.""" 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_b_score = fields.SmallIntField() # "A" or "B". diff --git a/server/src/tavolo/routes/games.py b/server/src/tavolo/routes/games.py index 87efc3a..a9a41fa 100644 --- a/server/src/tavolo/routes/games.py +++ b/server/src/tavolo/routes/games.py @@ -23,6 +23,7 @@ from ..config import settings from ..game import engine from ..game.errors import GameError 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 log = getLogger(__name__) @@ -49,6 +50,7 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]: return { "id": state.id, "join_code": state.join_code, + "game_type": state.game_type, "target_score": state.target_score, "phase": state.phase, "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") @operation(summary="Create a game", 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, "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "target_score": {"type": "integer", "minimum": 1, "maximum": 100}, - }, - } + "schema": { + "type": "object", + "properties": { + "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={ 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"}, }) @require_auth @@ -96,6 +118,11 @@ async def create_game(ctx: HttpContext) -> None: await send_error(ctx, 400, "target_score must be an integer") 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) assert user is not None # enforced by @require_auth game_id = str(uuid.uuid4()) @@ -109,12 +136,19 @@ async def create_game(ctx: HttpContext) -> None: target_score=target_score, hand_ack_timeout=settings.hand_ack_timeout_seconds, turn_timeout=settings.turn_timeout_seconds, + game_type=game_type, ) except GameError as exc: await send_error(ctx, 400, str(exc)) return 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)) diff --git a/server/src/tavolo/routes/stats.py b/server/src/tavolo/routes/stats.py index 842d55d..736394e 100644 --- a/server/src/tavolo/routes/stats.py +++ b/server/src/tavolo/routes/stats.py @@ -6,23 +6,47 @@ leaderboard aggregated from the same two tables. """ 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.openapi import operation from ..app import app, oidc_mixin 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 ..openapi import PAGINATION_PARAMETERS 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]: participants = await MatchPlayer.filter(match_id=match.id).order_by("seat") return { "id": str(match.id), + "game_type": match.game_type, "team_a_score": match.team_a_score, "team_b_score": match.team_b_score, "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 " "played, newest first, with the final score.", tags=["stats"], - parameters=PAGINATION_PARAMETERS, + parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER], responses={ 200: {"description": "A page of matches"}, - 400: {"description": "Invalid pagination cursor"}, + 400: {"description": "Invalid pagination cursor or game_type"}, 401: {"description": "Authentication required"}, }) @require_auth @@ -62,9 +86,15 @@ async def my_matches(ctx: HttpContext) -> None: except CursorDecodeError as exc: await send_error(ctx, 400, str(exc)) 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) assert user is not None 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( 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 " "player with at least one finished match. Sorted by wins.", 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: - 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]] = {} for row in rows: entry = aggregate.setdefault( diff --git a/server/src/tavolo/stats.py b/server/src/tavolo/stats.py index 1966a54..2518d12 100644 --- a/server/src/tavolo/stats.py +++ b/server/src/tavolo/stats.py @@ -39,6 +39,7 @@ async def save_match_result(state: GameState) -> None: async with in_transaction(): match = await Match.create( id=uuid.uuid4(), + game_type=state.game_type, team_a_score=state.scores[0], team_b_score=state.scores[1], winner_team=TEAM_NAMES[state.winner], @@ -59,8 +60,9 @@ async def save_match_result(state: GameState) -> None: ) state.stats_saved = True 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.game_type, TEAM_NAMES[state.winner], state.scores[0], state.scores[1], diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index 08c380a..c0d91d3 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -119,5 +119,53 @@ class GamesRouteTest(unittest.TestCase): 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__": unittest.main() diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py index af4b41f..b9298b7 100644 --- a/server/tests/test_stats.py +++ b/server/tests/test_stats.py @@ -66,11 +66,13 @@ class SaveMatchResultTest(unittest.TestCase): assert match is not None self.assertEqual(state.scores[0], match.team_a_score) 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) 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() with ctx: for index, (a_score, b_score, winner, finished) in enumerate( @@ -81,6 +83,7 @@ async def _seed_two_matches() -> None: ): match = await Match.create( id=uuid.uuid4(), + game_type=game_types[index], team_a_score=a_score, team_b_score=b_score, winner_team=winner, @@ -165,5 +168,45 @@ class StatsRouteTest(unittest.TestCase): 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__": unittest.main() diff --git a/server/tests/test_store.py b/server/tests/test_store.py index e586edf..6511e37 100644 --- a/server/tests/test_store.py +++ b/server/tests/test_store.py @@ -25,6 +25,24 @@ class InMemoryGameStoreTest(unittest.TestCase): self.assertEqual(16, loaded.target_score) 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 def test_load_missing_returns_none(self) -> None: store = InMemoryGameStore() diff --git a/web/src/api.rs b/web/src/api.rs index 51bc80e..b675243 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -22,9 +22,22 @@ pub async fn me() -> Result, String> { resp.json().await.map(Some).map_err(|e| e.to_string()) } -pub async fn create_game(target_score: i32) -> Result { +/// Fetch the card games the platform can host (for the creation dropdown). +pub async fn game_types() -> Result, 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 { 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())? .send() .await diff --git a/web/src/model.rs b/web/src/model.rs index 4f6ebbe..105d9af 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -101,6 +101,9 @@ pub struct GameView { pub id: String, #[serde(default)] pub join_code: String, + /// Which card game this match is (id from /api/game-types). + #[serde(default)] + pub game_type: String, pub phase: String, #[serde(default)] pub target_score: i32, @@ -174,6 +177,8 @@ pub struct MatchPlayer { #[allow(dead_code)] pub struct MatchSummary { pub id: String, + #[serde(default)] + pub game_type: String, pub team_a_score: i32, pub team_b_score: i32, pub winner_team: String, @@ -211,6 +216,22 @@ pub struct LeaderboardPage { pub results: Vec, } +/// 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, +} + /// Map a card code (e.g. `07D`) to its asset path. pub fn card_asset(code: &str) -> String { format!("/assets/cards/{code}.svg") diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index f633e0d..9ec380f 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -4,7 +4,16 @@ use sycamore::prelude::*; use sycamore_router::navigate; 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 { + vec![GameTypeInfo { + id: "scopone_scientifico".to_string(), + name: "Scopone scientifico".to_string(), + description: String::new(), + }] +} #[component] pub fn LobbyPage() -> View { @@ -12,6 +21,8 @@ pub fn LobbyPage() -> View { let user = create_signal(Option::>::None); let error = create_signal(Option::::None); 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 { 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 game_type = selected_game.get_clone(); 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)), Err(e) => error.set(Some(e)), } @@ -70,6 +92,14 @@ pub fn LobbyPage() -> View { } div(class="panel") { 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." } div(class="target-buttons") { button(class="button", on:click=move |_| on_create(11)) { "Target 11" }