Files
tavolo/server/src/tavolo/routes/games.py
T

258 lines
9.3 KiB
Python

"""Game lobby endpoints.
A game starts as a lobby: the creator is seated first and shares the
six-character ``join_code``. When the fourth player joins, the engine deals
the first hand and the match begins. Live play then happens over the
``/ws/games/{id}`` websocket (see :mod:`tavolo.ws`); these endpoints cover
creation, joining and snapshotting state.
"""
from __future__ import annotations
import secrets
import uuid
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import HttpContext
from kaya.openapi import operation
from .. import auth
from ..app import app, game_store, oidc_mixin
from ..auth import require_auth
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__)
# Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6
_MAX_CODE_ATTEMPTS = 20
def _now_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
async def _unique_code() -> str:
for _ in range(_MAX_CODE_ATTEMPTS):
code = _now_code()
if await game_store.find_by_code(code) is None:
return code
raise RuntimeError("could not allocate a unique join code")
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,
"napola": state.napola,
"phase": state.phase,
"players": [
{"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"}
for p in state.players
],
"seats_open": 4 - len(state.players),
}
@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. "
"Share the returned join_code with three other players.",
tags=["games"],
request_body={
"required": False,
"content": {
"application/json": {
"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},
"napola": {
"type": "boolean",
"default": True,
"description": "Score the napola rule; a full "
"denari sweep wins the match",
},
},
}
}
},
},
responses={
201: {"description": "The created lobby"},
400: {"description": "Invalid game_type, target_score or body"},
401: {"description": "Authentication required"},
})
@require_auth
async def create_game(ctx: HttpContext) -> None:
body: dict = {}
try:
body = await read_json_optional(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
target_score: Any = body.get("target_score", DEFAULT_TARGET_SCORE)
if isinstance(target_score, bool) or not isinstance(target_score, int):
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
napola: Any = body.get("napola", True)
if not isinstance(napola, bool):
await send_error(ctx, 400, "napola must be a boolean")
return
user = oidc_mixin.get_user(ctx)
assert user is not None # enforced by @require_auth
game_id = str(uuid.uuid4())
join_code = await _unique_code()
try:
state = engine.create_game(
game_id=game_id,
join_code=join_code,
creator_sub=user.sub,
creator_name=auth.display_name(user),
target_score=target_score,
hand_ack_timeout=settings.hand_ack_timeout_seconds,
turn_timeout=settings.turn_timeout_seconds,
game_type=game_type,
napola=napola,
)
except GameError as exc:
await send_error(ctx, 400, str(exc))
return
await game_store.save(state)
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))
@app.POST("/api/games/join")
@operation(summary="Join a game by code",
description="Seats the caller in the next free chair. Joining as the "
"fourth player starts the match.",
tags=["games"],
request_body={
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
},
},
responses={
200: {"description": "Seated; game state (may be playing)"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
409: {"description": "Already joined or lobby full"},
})
@require_auth
async def join_game(ctx: HttpContext) -> None:
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
code = body.get("code")
if not isinstance(code, str) or not code:
await send_error(ctx, 400, "code is required")
return
user = oidc_mixin.get_user(ctx)
assert user is not None
existing = await game_store.find_by_code(code)
if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code")
return
async with game_store.lock(existing.id):
state = await game_store.load(existing.id)
if state is None:
await send_error(ctx, 404, "unknown join code")
return
try:
engine.join_game(state, user.sub, auth.display_name(user))
except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, state.id, exc)
await send_error(ctx, 409, str(exc))
return
await game_store.save(state)
await game_store.publish(state.id)
seat = next(p.seat for p in state.players if p.sub == user.sub)
if state.phase == PHASE_LOBBY:
log.info("%s joined game %s (seat %d, %d/4 players)", user.sub, state.id, seat, len(state.players))
else:
log.info("%s joined game %s (seat %d); match started", user.sub, state.id, seat)
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
return
await send_json(ctx, 200, _lobby_payload(state))
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Only seated players may read a game; other players' "
"hands are hidden.",
tags=["games"],
responses={
200: {"description": "The personalized game state"},
401: {"description": "Authentication required"},
403: {"description": "Not a player in this game"},
404: {"description": "Game not found"},
})
@require_auth
async def get_game(ctx: HttpContext, game_id: str) -> None:
state = await game_store.load(game_id)
if state is None:
await send_error(ctx, 404, "game not found")
return
user = oidc_mixin.get_user(ctx)
assert user is not None
if not state.seated(user.sub):
await send_error(ctx, 403, "forbidden")
return
await send_json(ctx, 200, engine.state_for_player(state, user.sub))