import asyncio import uuid from dataclasses import dataclass, field from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import async_session from app.models import GameServer, Match, MatchPlayer, Mode, ServerStatus @dataclass class Ticket: ticket_id: uuid.UUID player_id: uuid.UUID callsign: str mode: Mode mmr: int queued_at: datetime = field(default_factory=datetime.utcnow) status: str = "queued" # queued | matched | cancelled server_ip: str | None = None server_port: int | None = None team: int | None = None class QueueManager: """In-memory matchmaking queue. No Redis: a single API process holds all ticket state, which is fine at this scale and simpler to run — see memory/spacewar_networking_plan for why Redis was deferred rather than included from the start. """ def __init__(self) -> None: self._tickets: dict[uuid.UUID, Ticket] = {} self._lock = asyncio.Lock() async def join(self, player_id: uuid.UUID, callsign: str, mode: Mode, mmr: int) -> Ticket: ticket = Ticket(ticket_id=uuid.uuid4(), player_id=player_id, callsign=callsign, mode=mode, mmr=mmr) async with self._lock: self._tickets[ticket.ticket_id] = ticket return ticket async def leave(self, ticket_id: uuid.UUID) -> bool: async with self._lock: ticket = self._tickets.get(ticket_id) if ticket is None or ticket.status != "queued": return False ticket.status = "cancelled" return True def get(self, ticket_id: uuid.UUID) -> Ticket | None: return self._tickets.get(ticket_id) def _waiting(self, mode: Mode) -> list[Ticket]: tickets = [t for t in self._tickets.values() if t.mode == mode and t.status == "queued"] tickets.sort(key=lambda t: t.queued_at) return tickets async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None: async with self._lock: waiting = self._waiting(mode) if len(waiting) < required: return group = waiting[:cap] server = ( await db.execute( select(GameServer).where(GameServer.mode == mode, GameServer.status == ServerStatus.available) ) ).scalars().first() if server is None: return # no server free this cycle; leave everyone queued and retry next tick match = Match(mode=mode, server_id=server.id) db.add(match) await db.flush() for i, ticket in enumerate(group): team = i % 2 db.add(MatchPlayer(match_id=match.id, player_id=ticket.player_id, team=team)) ticket.status = "matched" ticket.server_ip = server.ip ticket.server_port = server.port ticket.team = team await db.commit() async def run_matching_loop(self) -> None: while True: await asyncio.sleep(2) async with async_session() as db: # Bot fill (spacewar/bots/bot_manager.gd) pads both teams up # to GameConfig.bot_min_team_size, so a single queued player # is enough to form a match. Still group in anyone else who # queues in the same tick, up to the real casual_team_size*2 # target, rather than capping at 1v1. await self._try_form_match(Mode.casual, required=1, cap=settings.casual_team_size * 2, db=db) queue_manager = QueueManager()