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,53 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Mode, Player
|
||||
from app.queue_manager import queue_manager
|
||||
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
|
||||
|
||||
router = APIRouter(prefix="/matchmaking", tags=["matchmaking"])
|
||||
|
||||
|
||||
async def _get_or_create_player(db: AsyncSession, callsign: str) -> Player:
|
||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||
if player is None:
|
||||
player = Player(callsign=callsign)
|
||||
db.add(player)
|
||||
await db.commit()
|
||||
await db.refresh(player)
|
||||
return player
|
||||
|
||||
|
||||
@router.post("/queue/join", response_model=QueueJoinResponse)
|
||||
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
|
||||
player = await _get_or_create_player(db, req.callsign)
|
||||
mmr = req.mmr if req.mode == Mode.ranked and req.mmr is not None else player.mmr
|
||||
|
||||
ticket = await queue_manager.join(player.id, player.callsign, req.mode, mmr)
|
||||
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
|
||||
|
||||
|
||||
@router.post("/queue/leave")
|
||||
async def leave_queue(ticket_id: uuid.UUID) -> dict:
|
||||
ok = await queue_manager.leave(ticket_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found or already resolved")
|
||||
return {"status": "cancelled"}
|
||||
|
||||
|
||||
@router.get("/queue/status/{ticket_id}", response_model=QueueStatusResponse)
|
||||
async def queue_status(ticket_id: uuid.UUID) -> QueueStatusResponse:
|
||||
ticket = queue_manager.get(ticket_id)
|
||||
if ticket is None:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
return QueueStatusResponse(
|
||||
ticket_id=ticket.ticket_id,
|
||||
status=ticket.status,
|
||||
server_ip=ticket.server_ip,
|
||||
server_port=ticket.server_port,
|
||||
team=ticket.team,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Player
|
||||
|
||||
router = APIRouter(prefix="/ranks", tags=["ranks"])
|
||||
|
||||
# Mirrors RANK_DATA in spacewar/menu/main_menu.gd — keep the tier names (and
|
||||
# ideally thresholds) in sync if either side changes.
|
||||
_TIERS: list[tuple[int, str]] = [
|
||||
(0, "CADET"),
|
||||
(1000, "PILOT"),
|
||||
(1400, "ACE"),
|
||||
(1800, "COMMANDER"),
|
||||
(2200, "ADMIRAL"),
|
||||
(2600, "LEGEND"),
|
||||
]
|
||||
|
||||
|
||||
def _tier_for(mmr: int) -> str:
|
||||
tier = _TIERS[0][1]
|
||||
for threshold, name in _TIERS:
|
||||
if mmr >= threshold:
|
||||
tier = name
|
||||
return tier
|
||||
|
||||
|
||||
@router.get("/{callsign}")
|
||||
async def get_rank(callsign: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||
if player is None:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return {"callsign": player.callsign, "mmr": player.mmr, "rank": _tier_for(player.mmr)}
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import GameServer, ServerStatus
|
||||
from app.schemas import ServerRegisterRequest
|
||||
|
||||
router = APIRouter(prefix="/servers", tags=["servers"])
|
||||
|
||||
|
||||
# Real game servers should call this on boot and periodically (heartbeat) once
|
||||
# NetworkManager grows that integration. Until then, a dev server is seeded
|
||||
# on API startup (see main.py) from DEV_SEED_SERVER_IP/PORT so matchmaking is
|
||||
# testable end-to-end without it.
|
||||
@router.post("/register")
|
||||
async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
existing = (
|
||||
await db.execute(select(GameServer).where(GameServer.ip == req.ip, GameServer.port == req.port, GameServer.mode == req.mode))
|
||||
).scalars().first()
|
||||
if existing:
|
||||
existing.status = ServerStatus.available
|
||||
existing.last_heartbeat = datetime.utcnow()
|
||||
else:
|
||||
db.add(GameServer(ip=req.ip, port=req.port, mode=req.mode, status=ServerStatus.available))
|
||||
await db.commit()
|
||||
return {"status": "registered"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
|
||||
servers = (await db.execute(select(GameServer))).scalars().all()
|
||||
return [
|
||||
{
|
||||
"ip": s.ip,
|
||||
"port": s.port,
|
||||
"mode": s.mode,
|
||||
"status": s.status,
|
||||
"last_heartbeat": s.last_heartbeat,
|
||||
}
|
||||
for s in servers
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Player
|
||||
from app.schemas import PlayerStatsResponse
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["stats"])
|
||||
|
||||
|
||||
@router.get("/{callsign}", response_model=PlayerStatsResponse)
|
||||
async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> PlayerStatsResponse:
|
||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||
if player is None:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return PlayerStatsResponse(callsign=player.callsign, mmr=player.mmr, wins=player.wins, losses=player.losses)
|
||||
Reference in New Issue
Block a user