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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user