treefmt / nix fmt (pull_request) Successful in 6s
test ebook search / test-ebook-search (pull_request) Successful in 39s
pytest / pytest (pull_request) Successful in 40s
build_systems / build-brain (pull_request) Successful in 1m0s
build_systems / build-bob (pull_request) Successful in 1m2s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m33s
build_systems / build-jeeves (pull_request) Successful in 2m25s
test ebook search / test-ebook-search (push) Successful in 35s
build_systems / build-brain (push) Successful in 37s
build_systems / build-bob (push) Successful in 37s
build_systems / build-jeeves (push) Successful in 2m17s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
build_systems / build-rhapsody-in-green (push) Successful in 49s
- implement FastAPI and HTMX lobby and game interfaces - support up to four human and AI-controlled players - add configurable rules, victory conditions, and expansion modules - support validated JSON cards, patrons, objectives, and outposts
627 lines
27 KiB
Python
627 lines
27 KiB
Python
"""FastAPI pages, HTMX mutations, and SSE stream."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import html
|
|
from collections.abc import AsyncIterator, Callable, Mapping
|
|
from datetime import UTC, datetime
|
|
from typing import Annotated, Literal
|
|
|
|
from fastapi import APIRouter, Form, Request, Response, UploadFile
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
|
|
from pydantic import ValidationError
|
|
|
|
from python.gems.content import ContentPackError, content_pack_schema, parse_content_pack
|
|
from python.gems.domain.engine import bonuses, public_state, score
|
|
from python.gems.domain.legal_actions import command as build_command
|
|
from python.gems.domain.legal_actions import legal_commands
|
|
from python.gems.domain.models import GameCommand, GameSettings, Interactions, Modules
|
|
from python.gems.persistence import Member, Room
|
|
from python.gems.rooms import RoomError, RoomService
|
|
from python.gems.web import templates
|
|
|
|
router = APIRouter()
|
|
COOKIE_NAME = "gems_credential"
|
|
DISPLAY_NAME_COOKIE = "gems_display_name"
|
|
DISPLAY_NAME_MAX_AGE = 365 * 24 * 60 * 60
|
|
DISPLAY_NAME_MAX_LENGTH = 32
|
|
OFFLINE_AFTER_SECONDS = 30
|
|
RoomOperation = Callable[[Room, Member], None]
|
|
|
|
|
|
def service(request: Request) -> RoomService:
|
|
"""Return the application-scoped room service."""
|
|
return request.app.state.rooms
|
|
|
|
|
|
def _request_origin(request: Request) -> str:
|
|
return f"{request.url.scheme}://{request.url.netloc}".rstrip("/")
|
|
|
|
|
|
def _public_origin(request: Request) -> str:
|
|
"""Prefer the current address when the untouched local default is configured."""
|
|
configured = request.app.state.config.public_origin.rstrip("/")
|
|
if configured == "http://127.0.0.1:8082":
|
|
return _request_origin(request)
|
|
return configured
|
|
|
|
|
|
def _context(request: Request, room: Room, member: Member, *, error: str | None = None) -> dict[str, object]:
|
|
members = service(request).repository.members(room.code)
|
|
humans = [item for item in members if item.controller == "human"]
|
|
all_humans_ready = bool(humans) and all(item.ready for item in humans)
|
|
csrf = service(request).credentials.csrf(request.cookies.get(COOKIE_NAME, ""), room.code)
|
|
context: dict[str, object] = {
|
|
"request": request,
|
|
"room": room,
|
|
"member": member,
|
|
"members": members,
|
|
"csrf": csrf,
|
|
"error": error,
|
|
"public_origin": _public_origin(request),
|
|
"all_humans_ready": all_humans_ready,
|
|
"can_start": room.pack is not None and all_humans_ready,
|
|
"offline_seats": {
|
|
item.seat
|
|
for item in members
|
|
if item.controller == "human"
|
|
and (datetime.now(UTC) - datetime.fromisoformat(item.last_seen)).total_seconds() >= OFFLINE_AFTER_SECONDS
|
|
},
|
|
}
|
|
if room.state and room.pack:
|
|
context["state"] = public_state(room.state, member.seat)
|
|
context["pack"] = room.pack
|
|
context["cards"] = {card.id: card for card in room.pack.cards}
|
|
context["resources"] = {resource.id: resource for resource in [*room.pack.resources, room.pack.wild_resource]}
|
|
context["normal_resource_ids"] = room.pack.resource_ids
|
|
context["all_resource_ids"] = (*room.pack.resource_ids, room.pack.wild_resource.id)
|
|
context["member_by_seat"] = {item.seat: item for item in members}
|
|
context["bonus_counts"] = {player.seat: bonuses(player, room.pack) for player in room.state.players}
|
|
context["patrons"] = {item.id: item for item in room.pack.patrons}
|
|
context["objectives"] = {item.id: item for item in room.pack.objectives}
|
|
context["outposts"] = {item.id: item for item in room.pack.outposts}
|
|
commands = (
|
|
legal_commands(room.state, room.pack, room.settings, member.seat) if member.controller == "human" else []
|
|
)
|
|
context["actions"] = [(action_label(item, room), item.model_dump_json()) for item in commands]
|
|
context["double_resource_ids"] = {
|
|
str(item.payload["resource"])
|
|
for item in commands
|
|
if item.type == "take_double" and item.payload.get("resource")
|
|
}
|
|
card_actions: dict[str, dict[str, str]] = {}
|
|
deck_reserve_actions: dict[str, str] = {}
|
|
for item in commands:
|
|
card_id = item.payload.get("card_id")
|
|
if item.type in {"purchase", "reserve"} and card_id:
|
|
card_actions.setdefault(str(card_id), {})[item.type] = item.model_dump_json()
|
|
elif item.type == "reserve" and item.payload.get("deck"):
|
|
deck_reserve_actions[str(item.payload["deck"])] = item.model_dump_json()
|
|
context["card_actions"] = card_actions
|
|
context["deck_reserve_actions"] = deck_reserve_actions
|
|
context["scores"] = {player.seat: score(player, room.pack) for player in room.state.players}
|
|
return context
|
|
|
|
|
|
def action_label(command: GameCommand, room: Room) -> str:
|
|
"""Build a human-readable label for a legal game command."""
|
|
payload = command.payload
|
|
if command.type == "take_distinct":
|
|
label = "Take " + ", ".join(payload["resources"])
|
|
elif command.type == "take_double":
|
|
label = f"Take two {payload['resource']}"
|
|
elif command.type == "reserve":
|
|
if payload.get("card_id") and room.pack:
|
|
label = f"Reserve {room.pack.card(payload['card_id']).label}"
|
|
else:
|
|
label = f"Reserve from {payload.get('deck', 'deck')}"
|
|
elif command.type == "purchase" and room.pack:
|
|
label = f"Purchase {room.pack.card(payload['card_id']).label}"
|
|
elif command.type == "choose" and isinstance(payload.get("tokens"), dict) and room.pack:
|
|
resources = {item.id: item for item in [*room.pack.resources, room.pack.wild_resource]}
|
|
discarded = payload["tokens"]
|
|
label = "Discard " + ", ".join(
|
|
f"{amount} {resources[str(resource)].label}" for resource, amount in discarded.items()
|
|
)
|
|
elif command.type == "decline":
|
|
label = "Decline"
|
|
else:
|
|
choice = payload.get("choice") or payload.get("card_id") or "selection"
|
|
label = f"Choose {choice}"
|
|
return label
|
|
|
|
|
|
def _selected_gem_command(room: Room, seat: int, selected: list[str]) -> GameCommand:
|
|
if room.state is None or room.pack is None:
|
|
error = "The game has not started"
|
|
raise RoomError(error)
|
|
if not selected:
|
|
error = "Select gem piles before taking gems"
|
|
raise RoomError(error)
|
|
if len(selected) != len(set(selected)) or not set(selected) <= set(room.pack.resource_ids):
|
|
error = "Choose each normal gem pile at most once"
|
|
raise RoomError(error)
|
|
commands = legal_commands(room.state, room.pack, room.settings, seat)
|
|
chosen = next(
|
|
(
|
|
item
|
|
for item in commands
|
|
if item.type == "take_double" and len(selected) == 1 and item.payload.get("resource") == selected[0]
|
|
),
|
|
None,
|
|
)
|
|
if chosen is None:
|
|
chosen = next(
|
|
(
|
|
item
|
|
for item in commands
|
|
if item.type == "take_distinct"
|
|
and set(item.payload.get("resources", [])) == set(selected)
|
|
and len(item.payload.get("resources", [])) == len(selected)
|
|
),
|
|
None,
|
|
)
|
|
if chosen is None:
|
|
error = "Choose three different available gems, or one pile with at least four gems to take a pair"
|
|
raise RoomError(error)
|
|
return chosen
|
|
|
|
|
|
def _double_gem_command(room: Room, seat: int, resource: str) -> GameCommand:
|
|
if room.state is None or room.pack is None:
|
|
error = "The game has not started"
|
|
raise RoomError(error)
|
|
chosen = next(
|
|
(
|
|
item
|
|
for item in legal_commands(room.state, room.pack, room.settings, seat)
|
|
if item.type == "take_double" and item.payload.get("resource") == resource
|
|
),
|
|
None,
|
|
)
|
|
if chosen is None:
|
|
error = "That gem pair is unavailable; a pile needs at least four gems"
|
|
raise RoomError(error)
|
|
return chosen
|
|
|
|
|
|
def _discard_token_command(room: Room, seat: int, submitted: Mapping[str, object]) -> GameCommand:
|
|
if room.state is None or room.pack is None:
|
|
error = "The game has not started"
|
|
raise RoomError(error)
|
|
pending = room.state.pending
|
|
if pending is None or pending.kind != "discard_tokens" or pending.seat != seat:
|
|
error = "There are no excess tokens to discard"
|
|
raise RoomError(error)
|
|
discard: dict[str, int] = {}
|
|
for resource in (*room.pack.resource_ids, room.pack.wild_resource.id):
|
|
value = submitted.get(resource, "0")
|
|
try:
|
|
amount = int(value) if isinstance(value, str) else 0
|
|
except ValueError as exc:
|
|
error = "Token discard amounts must be whole numbers"
|
|
raise RoomError(error) from exc
|
|
if amount < 0:
|
|
error = "Token discard amounts cannot be negative"
|
|
raise RoomError(error)
|
|
if amount:
|
|
discard[resource] = amount
|
|
return build_command(room.state, "choose", {"tokens": discard})
|
|
|
|
|
|
def _authenticate(request: Request, code: str) -> tuple[Room, Member]:
|
|
return service(request).authenticate(code, request.cookies.get(COOKIE_NAME))
|
|
|
|
|
|
def _saved_display_name(request: Request) -> str:
|
|
name = request.cookies.get(DISPLAY_NAME_COOKIE, "").strip()
|
|
return name if len(name) <= DISPLAY_NAME_MAX_LENGTH else ""
|
|
|
|
|
|
def _set_identity_cookies(response: Response, request: Request, credential: str, name: str) -> None:
|
|
cookie_options = {"httponly": True, "secure": request.app.state.config.secure_cookies, "samesite": "lax"}
|
|
response.set_cookie(COOKIE_NAME, credential, **cookie_options)
|
|
response.set_cookie(DISPLAY_NAME_COOKIE, name.strip(), max_age=DISPLAY_NAME_MAX_AGE, **cookie_options)
|
|
|
|
|
|
def _check_csrf(request: Request, room_code: str, csrf: str) -> None:
|
|
credential = request.cookies.get(COOKIE_NAME, "")
|
|
if not service(request).credentials.valid_csrf(credential, room_code, csrf):
|
|
error = "Your form expired; reload and try again"
|
|
raise RoomError(error)
|
|
origin = request.headers.get("origin")
|
|
allowed_origins = {
|
|
request.app.state.config.public_origin.rstrip("/"),
|
|
_request_origin(request),
|
|
}
|
|
if origin and origin.rstrip("/") not in allowed_origins:
|
|
error = "Request origin was rejected"
|
|
raise RoomError(error)
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def home(request: Request) -> Response:
|
|
"""Render the room creation and join page."""
|
|
return templates.TemplateResponse(request, "home.html", {"display_name": _saved_display_name(request)})
|
|
|
|
|
|
@router.post("/rooms")
|
|
def create_room(request: Request, name: Annotated[str, Form()]) -> Response:
|
|
"""Create a room and redirect its host to the lobby."""
|
|
try:
|
|
room, credential = service(request).create(name, request.cookies.get(COOKIE_NAME))
|
|
except RoomError as exc:
|
|
return templates.TemplateResponse(
|
|
request, "home.html", {"error": str(exc), "display_name": name}, status_code=422
|
|
)
|
|
response = RedirectResponse(f"/rooms/{room.code}", status_code=303)
|
|
_set_identity_cookies(response, request, credential, name)
|
|
return response
|
|
|
|
|
|
@router.post("/join")
|
|
def join_code(code: Annotated[str, Form()]) -> Response:
|
|
"""Normalize a room code and redirect to its join page."""
|
|
return RedirectResponse(f"/join/{code.strip().upper()}", status_code=303)
|
|
|
|
|
|
@router.get("/join/{code}", response_class=HTMLResponse)
|
|
def join_page(request: Request, code: str) -> Response:
|
|
"""Render the display-name form for an existing room."""
|
|
try:
|
|
room = service(request).require_room(code)
|
|
except RoomError:
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"join.html",
|
|
{"code": code, "error": "Room not found", "display_name": _saved_display_name(request)},
|
|
status_code=404,
|
|
)
|
|
return templates.TemplateResponse(
|
|
request, "join.html", {"code": room.code, "display_name": _saved_display_name(request)}
|
|
)
|
|
|
|
|
|
@router.post("/join/{code}")
|
|
def join_room(request: Request, code: str, name: Annotated[str, Form()]) -> Response:
|
|
"""Join a room and persist the member's browser identity."""
|
|
try:
|
|
room, credential = service(request).join(code, name, request.cookies.get(COOKIE_NAME))
|
|
except RoomError as exc:
|
|
return templates.TemplateResponse(
|
|
request, "join.html", {"code": code, "error": str(exc), "display_name": name}, status_code=422
|
|
)
|
|
response = RedirectResponse(f"/rooms/{room.code}", status_code=303)
|
|
_set_identity_cookies(response, request, credential, name)
|
|
return response
|
|
|
|
|
|
@router.get("/rooms/{code}", response_class=HTMLResponse)
|
|
def room_page(request: Request, code: str) -> Response:
|
|
"""Render the authenticated lobby or game table."""
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
except RoomError:
|
|
return RedirectResponse(f"/join/{code}", status_code=303)
|
|
return templates.TemplateResponse(request, "room.html", _context(request, room, member))
|
|
|
|
|
|
def _partial(request: Request, code: str, operation: RoomOperation, *, error_status: int = 422) -> Response:
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
operation(room, member)
|
|
room = service(request).require_room(code)
|
|
return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member))
|
|
except (RoomError, ValidationError, ContentPackError) as exc:
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"partials/room_state.html",
|
|
_context(request, room, member, error=str(exc)),
|
|
status_code=error_status,
|
|
)
|
|
except RoomError:
|
|
return HTMLResponse(html.escape(str(exc)), status_code=error_status)
|
|
|
|
|
|
def _csrf_partial(request: Request, code: str, csrf: str, operation: RoomOperation) -> Response:
|
|
def authorized(room: Room, member: Member) -> None:
|
|
_check_csrf(request, room.code, csrf)
|
|
operation(room, member)
|
|
|
|
return _partial(request, code, authorized)
|
|
|
|
|
|
@router.post("/rooms/{code}/pack", response_class=HTMLResponse)
|
|
async def upload_pack(request: Request, code: str, csrf: Annotated[str, Form()], pack_file: UploadFile) -> Response:
|
|
"""Validate and activate an uploaded JSON content pack."""
|
|
raw = await pack_file.read(512 * 1024 + 1)
|
|
|
|
def operation(room: Room, member: Member) -> None:
|
|
_check_csrf(request, room.code, csrf)
|
|
service(request).set_pack(room, member, parse_content_pack(raw))
|
|
|
|
return _partial(request, code, operation)
|
|
|
|
|
|
@router.post("/rooms/{code}/settings/preset", response_class=HTMLResponse)
|
|
def apply_preset(
|
|
request: Request,
|
|
code: str,
|
|
csrf: Annotated[str, Form()],
|
|
preset: Annotated[str, Form()],
|
|
) -> Response:
|
|
"""Apply a built-in rules preset to a lobby."""
|
|
presets: dict[str, tuple[Modules, Literal["score", "objective", "either", "both"]]] = {
|
|
"classic": (Modules(), "score"),
|
|
"objectives": (Modules(objectives=True), "objective"),
|
|
"objectives_outposts": (Modules(objectives=True, outposts=True), "objective"),
|
|
"eastern_fortifications": (Modules(eastern_decks=True, fortifications=True), "score"),
|
|
"all": (Modules(objectives=True, outposts=True, eastern_decks=True, fortifications=True), "objective"),
|
|
}
|
|
|
|
def operation(room: Room, member: Member) -> None:
|
|
_check_csrf(request, room.code, csrf)
|
|
if preset not in presets:
|
|
error = "Unknown rules preset"
|
|
raise RoomError(error)
|
|
modules, victory = presets[preset]
|
|
settings = room.settings.model_copy(deep=True)
|
|
settings.modules = modules
|
|
settings.victory_condition = victory
|
|
settings.interactions = Interactions()
|
|
service(request).set_settings(room, member, settings)
|
|
|
|
return _partial(request, code, operation)
|
|
|
|
|
|
@router.get("/rooms/{code}/pack")
|
|
def download_pack(request: Request, code: str) -> Response:
|
|
"""Download the room's active content pack as JSON."""
|
|
room, _ = _authenticate(request, code)
|
|
if room.pack is None:
|
|
return JSONResponse({"detail": "No pack uploaded"}, status_code=404)
|
|
return JSONResponse(
|
|
room.pack.model_dump(mode="json"),
|
|
headers={"Content-Disposition": f'attachment; filename="{room.pack.metadata.id}.json"'},
|
|
)
|
|
|
|
|
|
@router.post("/rooms/{code}/settings", response_class=HTMLResponse)
|
|
async def update_settings(request: Request, code: str) -> Response:
|
|
"""Validate and save custom lobby rules."""
|
|
form = await request.form()
|
|
|
|
def checked(name: str) -> bool:
|
|
return name in form
|
|
|
|
def value(name: str, default: str) -> str:
|
|
submitted = form.get(name)
|
|
return submitted if isinstance(submitted, str) else default
|
|
|
|
def operation(room: Room, member: Member) -> None:
|
|
_check_csrf(request, room.code, value("csrf", ""))
|
|
settings = GameSettings.model_validate(
|
|
{
|
|
"target_score": value("target_score", "15"),
|
|
"victory_condition": value("victory_condition", "score"),
|
|
"first_player_mode": value("first_player_mode", "random"),
|
|
"first_player_seat": value("first_player_seat", "0"),
|
|
"token_limit": value("token_limit", "10"),
|
|
"reserve_limit": value("reserve_limit", "3"),
|
|
"base_market_size": value("base_market_size", "4"),
|
|
"eastern_market_size": value("eastern_market_size", "2"),
|
|
"objective_count": value("objective_count", "3"),
|
|
"fortifications_per_player": value("fortifications_per_player", "3"),
|
|
"modules": Modules(
|
|
objectives=checked("objectives"),
|
|
outposts=checked("outposts"),
|
|
eastern_decks=checked("eastern_decks"),
|
|
fortifications=checked("fortifications"),
|
|
),
|
|
"interactions": Interactions(**{field: checked(field) for field in Interactions.model_fields}),
|
|
}
|
|
)
|
|
service(request).set_settings(room, member, settings)
|
|
|
|
return _partial(request, code, operation)
|
|
|
|
|
|
@router.post("/rooms/{code}/ready", response_class=HTMLResponse)
|
|
def ready(
|
|
request: Request,
|
|
code: str,
|
|
csrf: Annotated[str, Form()],
|
|
ready_value: Annotated[bool, Form(alias="ready")],
|
|
) -> Response:
|
|
"""Update the authenticated human player's ready state."""
|
|
return _csrf_partial(
|
|
request, code, csrf, lambda room, member: service(request).set_ready(room, member, ready=ready_value)
|
|
)
|
|
|
|
|
|
@router.post("/rooms/{code}/seats/ai", response_class=HTMLResponse)
|
|
def add_ai(request: Request, code: str, csrf: Annotated[str, Form()], difficulty: Annotated[str, Form()]) -> Response:
|
|
"""Add an AI-controlled player to a lobby."""
|
|
return _csrf_partial(request, code, csrf, lambda room, member: service(request).add_ai(room, member, difficulty))
|
|
|
|
|
|
@router.post("/rooms/{code}/seats/{seat}/remove", response_class=HTMLResponse)
|
|
def remove_seat(request: Request, code: str, seat: int, csrf: Annotated[str, Form()]) -> Response:
|
|
"""Remove a non-host player from a lobby."""
|
|
return _csrf_partial(request, code, csrf, lambda room, member: service(request).remove_seat(room, member, seat))
|
|
|
|
|
|
@router.post("/rooms/{code}/seats/{seat}/make-host", response_class=HTMLResponse)
|
|
def transfer_host(request: Request, code: str, seat: int, csrf: Annotated[str, Form()]) -> Response:
|
|
"""Transfer lobby ownership to another human player."""
|
|
return _csrf_partial(request, code, csrf, lambda room, member: service(request).transfer_host(room, member, seat))
|
|
|
|
|
|
@router.post("/rooms/{code}/seats/{seat}/replace-with-ai", response_class=HTMLResponse)
|
|
def replace_with_ai(
|
|
request: Request,
|
|
code: str,
|
|
seat: int,
|
|
csrf: Annotated[str, Form()],
|
|
difficulty: Annotated[str, Form()] = "medium",
|
|
) -> Response:
|
|
"""Replace a disconnected player with an AI controller."""
|
|
return _csrf_partial(
|
|
request,
|
|
code,
|
|
csrf,
|
|
lambda room, member: service(request).replace_with_ai(room, member, seat, difficulty),
|
|
)
|
|
|
|
|
|
@router.post("/rooms/{code}/start", response_class=HTMLResponse)
|
|
async def start_game(request: Request, code: str, csrf: Annotated[str, Form()]) -> Response:
|
|
"""Start a game after validating the lobby's readiness."""
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
_check_csrf(request, room.code, csrf)
|
|
await service(request).start(room, member)
|
|
room = service(request).require_room(code)
|
|
return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member))
|
|
except RoomError as exc:
|
|
room, member = _authenticate(request, code)
|
|
return templates.TemplateResponse(
|
|
request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422
|
|
)
|
|
|
|
|
|
@router.post("/rooms/{code}/play-again", response_class=HTMLResponse)
|
|
async def play_again(request: Request, code: str, csrf: Annotated[str, Form()]) -> Response:
|
|
"""Start a fresh game with the finished room's current setup."""
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
_check_csrf(request, room.code, csrf)
|
|
room = await service(request).play_again(room, member)
|
|
return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member))
|
|
except RoomError as exc:
|
|
room, member = _authenticate(request, code)
|
|
return templates.TemplateResponse(
|
|
request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422
|
|
)
|
|
|
|
|
|
@router.post("/rooms/{code}/commands", response_class=HTMLResponse)
|
|
async def game_command(
|
|
request: Request, code: str, csrf: Annotated[str, Form()], command_json: Annotated[str, Form()]
|
|
) -> Response:
|
|
"""Validate and submit one serialized game command."""
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
_check_csrf(request, room.code, csrf)
|
|
command = GameCommand.model_validate_json(command_json)
|
|
room = await service(request).submit(room, member, command)
|
|
return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member))
|
|
except (RoomError, ValidationError) as exc:
|
|
room, member = _authenticate(request, code)
|
|
return templates.TemplateResponse(
|
|
request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422
|
|
)
|
|
|
|
|
|
@router.post("/rooms/{code}/take-gems", response_class=HTMLResponse)
|
|
async def take_gems(request: Request, code: str) -> Response:
|
|
"""Translate the visual gem selection into a legal command."""
|
|
form = await request.form()
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
_check_csrf(request, room.code, str(form.get("csrf", "")))
|
|
pair_resource = form.get("pair_resource")
|
|
if pair_resource is not None:
|
|
chosen = _double_gem_command(room, member.seat, str(pair_resource))
|
|
else:
|
|
selected = [str(item) for item in form.getlist("resources")]
|
|
chosen = _selected_gem_command(room, member.seat, selected)
|
|
room = await service(request).submit(room, member, chosen)
|
|
return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member))
|
|
except RoomError as exc:
|
|
room, member = _authenticate(request, code)
|
|
return templates.TemplateResponse(
|
|
request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422
|
|
)
|
|
|
|
|
|
@router.post("/rooms/{code}/discard-tokens", response_class=HTMLResponse)
|
|
async def discard_tokens(request: Request, code: str) -> Response:
|
|
"""Return a player's selected excess tokens to the supply."""
|
|
form = await request.form()
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
_check_csrf(request, room.code, str(form.get("csrf", "")))
|
|
submitted = {
|
|
resource: form.get(f"token_{resource}", "0")
|
|
for resource in ((*room.pack.resource_ids, room.pack.wild_resource.id) if room.pack else ())
|
|
}
|
|
chosen = _discard_token_command(room, member.seat, submitted)
|
|
room = await service(request).submit(room, member, chosen)
|
|
return templates.TemplateResponse(request, "partials/room_state.html", _context(request, room, member))
|
|
except RoomError as exc:
|
|
room, member = _authenticate(request, code)
|
|
return templates.TemplateResponse(
|
|
request, "partials/room_state.html", _context(request, room, member, error=str(exc)), status_code=422
|
|
)
|
|
|
|
|
|
@router.get("/rooms/{code}/events")
|
|
async def room_events(request: Request, code: str) -> Response:
|
|
"""Stream room revisions to an authenticated browser over SSE."""
|
|
try:
|
|
room, member = _authenticate(request, code)
|
|
except RoomError:
|
|
return HTMLResponse("Unauthorized", status_code=401)
|
|
queue = service(request).broker.subscribe(room.code)
|
|
stream_member = member
|
|
|
|
async def stream() -> AsyncIterator[str]:
|
|
current_member = stream_member
|
|
try:
|
|
# Comments keep the SSE connection alive without replacing in-progress form input.
|
|
yield ": connected\n\n"
|
|
while True:
|
|
try:
|
|
await asyncio.wait_for(queue.get(), timeout=15)
|
|
latest = service(request).require_room(code)
|
|
yield _sse_fragment(request, latest, current_member)
|
|
except TimeoutError:
|
|
_, current_member = _authenticate(request, code)
|
|
yield ": keepalive\n\n"
|
|
if await request.is_disconnected():
|
|
break
|
|
finally:
|
|
service(request).broker.unsubscribe(room.code, queue)
|
|
|
|
return StreamingResponse(
|
|
stream(), media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}
|
|
)
|
|
|
|
|
|
def _sse_fragment(request: Request, room: Room, member: Member) -> str:
|
|
rendered = templates.get_template("partials/room_state.html").render(_context(request, room, member))
|
|
return "event: room\n" + "\n".join(f"data: {line}" for line in rendered.splitlines()) + "\n\n"
|
|
|
|
|
|
@router.get("/schemas/content-pack-v1.json")
|
|
def schema() -> Response:
|
|
"""Return the JSON Schema for content-pack version one."""
|
|
return JSONResponse(content_pack_schema())
|
|
|
|
|
|
@router.get("/healthz")
|
|
def health() -> dict[str, str]:
|
|
"""Report that the HTTP process is running."""
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/readyz")
|
|
def readyz(request: Request) -> dict[str, str]:
|
|
"""Report readiness after checking the database connection."""
|
|
request.app.state.repository._connection.execute("SELECT 1").fetchone() # noqa: SLF001
|
|
return {"status": "ready"}
|