Files
client/matchmaking-api/app/queue_manager.py
T
anekdotin edcda92812 Add ENet multiplayer (movement/combat sync) and matchmaking API
Networked play, end to end: connect, get matched, spawn, fight, respawn.

Godot side:
- Replace the single hardcoded Player with per-peer networked ships
  (MultiplayerSpawner + custom spawn_function), driven by client-side
  prediction with server reconciliation for movement.
- Server-authoritative combat: bullets, health, death/respawn all decided
  by the server and broadcast to every peer over RPC.
- Fix a Camera2D bug where Godot auto-promotes the first camera to ever
  enter the scene tree as the active one regardless of its own `current`
  value — with MultiplayerSpawner catch-up replication that was almost
  never the local player's own ship. `make_current()` on the owner's
  camera fixes it; property assignment doesn't reliably override the
  auto-claim.
- Fix two "late joiner never learns already-established state" gaps
  (health, visibility) by folding both into the existing per-tick
  position broadcast instead of relying on one-shot RPCs that only reach
  peers already connected when they fire.
- Make the 2 offered factions in team select match for every player in
  a match: the server decides once and clients either read it directly
  (server's own instance) or request it over RPC, rather than each
  client rolling its own random pair.
- New MatchmakingClient autoload wires the main menu's CASUAL/RANKED
  buttons to the matchmaking API instead of connecting directly.

New matchmaking-api/ (FastAPI + Postgres, Docker Compose):
- Queue/match/stats/ranks endpoints. In-memory matchmaking queue in a
  single API process — no Redis until there's an actual reason to shard
  the queue across instances.
- Background loop forms a match once enough players are queued for a
  mode, assigns the first available registered game server.
- No real server pool yet: one dev server is auto-seeded from
  DEV_SEED_SERVER_IP/PORT; POST /servers/register exists for real
  servers to self-register later but nothing calls it yet.

Docs: CLAUDE.md and overview/tech.md updated to match — networking
checklist mostly checked off, matchmaking backend section rewritten to
describe what was actually built vs. the original Go/Redis plan, and
Current Tasks reordered around what's actually left (bot fill, real
server pool, ranked/MMR refinement, lag compensation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:37:59 -04:00

107 lines
3.8 KiB
Python

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"]
if mode == Mode.ranked:
# Simple MMR-sorted grouping. No widening-window-by-wait-time yet
# (real ranked matchmaking will want that) — nearest-neighbor by
# mmr is a reasonable placeholder until ranked queue volume
# exists to tune against.
tickets.sort(key=lambda t: t.mmr)
else:
tickets.sort(key=lambda t: t.queued_at)
return tickets
async def _try_form_match(self, mode: Mode, team_size: int, db: AsyncSession) -> None:
required = team_size * 2
async with self._lock:
waiting = self._waiting(mode)
if len(waiting) < required:
return
group = waiting[:required]
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:
await self._try_form_match(Mode.casual, settings.casual_team_size, db)
await self._try_form_match(Mode.ranked, settings.ranked_team_size, db)
queue_manager = QueueManager()