Files
dotfiles/python/gems/rooms.py
T
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

376 lines
16 KiB
Python

"""Application service for anonymous rooms and AI turns."""
from __future__ import annotations
import asyncio
import secrets
from collections import defaultdict
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from .ai import choose_ai_command
from .domain.engine import RuleError, apply_command, new_game
from .domain.models import GameCommand, GameSettings
from .persistence import Member, Repository, Room, utc_now
if TYPE_CHECKING:
from .content import ParsedPack
from .events import EventBroker
from .security import CredentialService
ROOM_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
MAX_PLAYERS = 4
OFFLINE_AFTER_SECONDS = 30
DISPLAY_NAME_MAX_LENGTH = 32
CONTROL_CHARACTER_LIMIT = 32
class RoomError(ValueError):
"""Safe user-facing room operation error."""
class RoomService:
"""Coordinates persistence, authorization, per-room locking, and bots."""
def __init__(self, repository: Repository, credentials: CredentialService, broker: EventBroker) -> None:
"""Initialize the service with persistence, security, and events."""
self.repository = repository
self.credentials = credentials
self.broker = broker
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._ai_tasks: dict[str, asyncio.Task[None]] = {}
def create(self, host_name: str, credential: str | None = None) -> tuple[Room, str]:
"""Create a lobby and assign its first human member as host."""
name = validate_name(host_name)
code = self._new_code()
credential = credential or self.credentials.issue()
now = utc_now()
room = Room(
code=code,
status="lobby",
settings=GameSettings(),
pack=None,
pack_digest=None,
state=None,
revision=0,
created_at=now,
updated_at=now,
)
host = Member(
room_code=code,
seat=0,
name=name,
controller="human",
difficulty=None,
credential_hash=self.credentials.digest(credential),
is_host=True,
ready=False,
last_seen=now,
)
self.repository.create_room(room, host)
return room, credential
def join(self, code: str, name: str, credential: str | None = None) -> tuple[Room, str]:
"""Join an available lobby or restore an existing membership."""
room = self.require_room(code)
credential = credential or self.credentials.issue()
existing = self.repository.member_for_credential(room.code, self.credentials.digest(credential))
if existing is not None:
return room, credential
if room.status != "lobby":
error = "This room is no longer accepting players"
raise RoomError(error)
members = self.repository.members(room.code)
if len(members) >= MAX_PLAYERS:
error = "This room is full"
raise RoomError(error)
clean_name = validate_name(name)
if any(member.name.casefold() == clean_name.casefold() for member in members):
error = "That display name is already in use"
raise RoomError(error)
seat = next(item for item in range(MAX_PLAYERS) if item not in {member.seat for member in members})
member = Member(
room_code=room.code,
seat=seat,
name=clean_name,
controller="human",
difficulty=None,
credential_hash=self.credentials.digest(credential),
is_host=False,
ready=False,
last_seen=utc_now(),
)
self.repository.add_member(member)
self._bump_lobby(room)
return room, credential
def authenticate(self, code: str, credential: str | None) -> tuple[Room, Member]:
"""Authenticate a room member and refresh their presence timestamp."""
room = self.require_room(code)
if not credential:
error = "Join the room to continue"
raise RoomError(error)
member = self.repository.member_for_credential(room.code, self.credentials.digest(credential))
if member is None:
error = "Join the room to continue"
raise RoomError(error)
member.last_seen = utc_now()
self.repository.update_member(member)
return room, member
def require_room(self, code: str) -> Room:
"""Load a room or raise a safe user-facing error."""
room = self.repository.get_room(code.upper())
if room is None:
error = "Room not found"
raise RoomError(error)
return room
def set_pack(self, room: Room, actor: Member, parsed: ParsedPack) -> Room:
"""Assign a validated content pack to a lobby."""
self._require_host_lobby(room, actor)
room.pack = parsed.pack
room.pack_digest = parsed.digest
self._bump_lobby(room)
return room
def set_settings(self, room: Room, actor: Member, settings: GameSettings) -> Room:
"""Replace the editable rules for a lobby."""
self._require_host_lobby(room, actor)
room.settings = settings
self._bump_lobby(room)
return room
def set_ready(self, room: Room, actor: Member, *, ready: bool) -> None:
"""Update a human lobby member's readiness."""
if room.status != "lobby" or actor.controller != "human":
error = "Readiness can only change in the lobby"
raise RoomError(error)
actor.ready = ready
actor.last_seen = utc_now()
self.repository.update_member(actor)
self._bump_lobby(room)
def add_ai(self, room: Room, actor: Member, difficulty: str) -> None:
"""Add an AI-controlled seat to a lobby."""
self._require_host_lobby(room, actor)
if difficulty not in {"easy", "medium", "hard"}:
error = "Choose easy, medium, or hard AI"
raise RoomError(error)
members = self.repository.members(room.code)
if len(members) >= MAX_PLAYERS:
error = "This room is full"
raise RoomError(error)
seat = next(item for item in range(MAX_PLAYERS) if item not in {member.seat for member in members})
number = 1 + sum(member.controller == "ai" for member in members)
self.repository.add_member(
Member(
room_code=room.code,
seat=seat,
name=f"Bot {number}",
controller="ai",
difficulty=difficulty,
credential_hash=None,
is_host=False,
ready=True,
last_seen=utc_now(),
)
)
self._bump_lobby(room)
def remove_seat(self, room: Room, actor: Member, seat: int) -> None:
"""Remove a non-host seat from a lobby."""
self._require_host_lobby(room, actor)
target = next((member for member in self.repository.members(room.code) if member.seat == seat), None)
if target is None or target.is_host:
error = "That seat cannot be removed"
raise RoomError(error)
self.repository.remove_member(room.code, seat)
self._bump_lobby(room)
def transfer_host(self, room: Room, actor: Member, seat: int) -> None:
"""Transfer lobby ownership to another human player."""
self._require_host_lobby(room, actor)
target = next((member for member in self.repository.members(room.code) if member.seat == seat), None)
if target is None or target.controller != "human" or target.seat == actor.seat:
error = "Choose another human player"
raise RoomError(error)
actor.is_host = False
target.is_host = True
self.repository.update_member(actor)
self.repository.update_member(target)
self._bump_lobby(room)
async def start(self, room: Room, actor: Member) -> Room:
"""Validate the lobby and start its first game."""
self._require_host_lobby(room, actor)
if room.pack is None:
error = "Upload a valid content pack first"
raise RoomError(error)
members = self.repository.members(room.code)
humans = [member for member in members if member.controller == "human"]
if not humans or any(not member.ready for member in humans):
error = "Every human player must be ready"
raise RoomError(error)
ordered = sorted(members, key=lambda member: member.seat)
# Compress lobby seat gaps so engine seats always index the players list.
for new_seat, member in enumerate(ordered):
if member.seat != new_seat:
self.repository.remove_member(room.code, member.seat)
member.seat = new_seat
self.repository.add_member(member)
try:
state = new_game(
room.code, [member.name for member in ordered], room.pack, room.settings, seed=secrets.randbits(63)
)
except RuleError as exc:
raise RoomError(str(exc)) from exc
room.state = state
room.status = "playing"
room.revision = state.revision
self.repository.save_state(room, f"start-{secrets.token_hex(8)}", actor.seat, {"type": "start"})
self.broker.publish(room.code, room.revision)
self.schedule_ai(room.code)
return room
async def play_again(self, room: Room, actor: Member) -> Room:
"""Start a fresh game in a finished room while preserving its table setup."""
if not actor.is_host or room.status != "finished":
error = "Only the host can replay a finished game"
raise RoomError(error)
async with self._locks[room.code]:
room = self.require_room(room.code)
if room.status != "finished" or room.pack is None:
error = "This game is not ready for a replay"
raise RoomError(error)
members = sorted(self.repository.members(room.code), key=lambda member: member.seat)
try:
room.state = new_game(
room.code,
[member.name for member in members],
room.pack,
room.settings,
seed=secrets.randbits(63),
)
except RuleError as exc:
raise RoomError(str(exc)) from exc
room.status = "playing"
room.revision = room.state.revision
self.repository.save_state(
room,
f"replay-{secrets.token_hex(8)}",
actor.seat,
{"type": "play_again"},
)
self.broker.publish(room.code, room.revision)
self.schedule_ai(room.code)
return room
async def submit(self, room: Room, actor: Member, command: GameCommand) -> Room:
"""Apply and persist one idempotent human game command."""
if room.status != "playing" or room.state is None or room.pack is None:
error = "This game is not active"
raise RoomError(error)
async with self._locks[room.code]:
room = self.require_room(room.code)
if self.repository.has_command(room.code, command.command_id):
return room
if room.state is None or room.pack is None:
error = "This game is not active"
raise RoomError(error)
try:
room.state = apply_command(room.state, command, room.pack, room.settings, actor_seat=actor.seat)
except RuleError as exc:
raise RoomError(str(exc)) from exc
room.revision = room.state.revision
room.status = "finished" if room.state.finished else "playing"
room.state.log = room.state.log[-200:]
self.repository.save_state(room, command.command_id, actor.seat, command.model_dump(mode="json"))
self.broker.publish(room.code, room.revision)
self.schedule_ai(room.code)
return room
def replace_with_ai(self, room: Room, actor: Member, seat: int, difficulty: str) -> None:
"""Replace a disconnected human player with an AI controller."""
if not actor.is_host or room.status != "playing":
error = "Only the host can replace a player during a game"
raise RoomError(error)
target = next((item for item in self.repository.members(room.code) if item.seat == seat), None)
if target is None or target.controller != "human" or target.is_host:
error = "That player cannot be replaced"
raise RoomError(error)
last_seen = datetime.fromisoformat(target.last_seen)
if (datetime.now(UTC) - last_seen).total_seconds() < OFFLINE_AFTER_SECONDS:
error = "That player is still connected"
raise RoomError(error)
target.controller = "ai"
target.difficulty = difficulty
target.credential_hash = None
target.ready = True
self.repository.update_member(target)
self.broker.publish(room.code, room.revision)
self.schedule_ai(room.code)
def schedule_ai(self, code: str) -> None:
"""Schedule an AI runner when a room does not already have one."""
task = self._ai_tasks.get(code)
if task is None or task.done():
self._ai_tasks[code] = asyncio.create_task(self._run_ai(code))
async def _run_ai(self, code: str) -> None:
while True:
room = self.require_room(code)
if room.status != "playing" or room.state is None or room.pack is None:
return
member = next(
(item for item in self.repository.members(code) if item.seat == room.state.current_seat), None
)
if member is None or member.controller != "ai":
return
await asyncio.sleep(0.35)
async with self._locks[code]:
room = self.require_room(code)
if room.state is None or room.pack is None or room.state.current_seat != member.seat:
continue
chosen = choose_ai_command(
room.state, room.pack, room.settings, member.seat, member.difficulty or "medium"
)
try:
room.state = apply_command(room.state, chosen, room.pack, room.settings, actor_seat=member.seat)
except RuleError:
return
room.revision = room.state.revision
room.status = "finished" if room.state.finished else "playing"
room.state.log = room.state.log[-200:]
self.repository.save_state(room, chosen.command_id, member.seat, chosen.model_dump(mode="json"))
self.broker.publish(code, room.revision)
def _new_code(self) -> str:
while True:
code = "".join(secrets.choice(ROOM_ALPHABET) for _ in range(8))
if not self.repository.room_exists(code):
return code
def _require_host_lobby(self, room: Room, actor: Member) -> None:
if not actor.is_host or room.status != "lobby":
error = "Only the host can change this lobby"
raise RoomError(error)
def _bump_lobby(self, room: Room) -> None:
room.revision += 1
room.updated_at = utc_now()
self.repository.save_lobby(room)
self.broker.publish(room.code, room.revision)
def validate_name(name: str) -> str:
"""Normalize and validate a player display name."""
clean = " ".join(name.split())
if not 1 <= len(clean) <= DISPLAY_NAME_MAX_LENGTH or any(
ord(character) < CONTROL_CHARACTER_LIMIT for character in clean
):
error = "Display names must be 1-32 plain-text characters"
raise RoomError(error)
return clean