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
376 lines
16 KiB
Python
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
|