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