Add preliminary support for multiple card games
CI / Build and push docker image (push) Successful in 3m5s
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:
+10
-7
@@ -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/"
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", [])],
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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".
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user