Files
dotfiles/python/gems/routes/app.py
Richie add7a6a848
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
feat(gems): add multiplayer gem game with custom content packs
- 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
2026-07-22 20:47:03 -04:00

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"}