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,14 @@
|
||||
DATABASE_URL=postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking
|
||||
|
||||
# Real-value targets per overview/bots.md (casual min 7v7) and overview/racesclasses.md (ranked 5v5).
|
||||
CASUAL_TEAM_SIZE=7
|
||||
RANKED_TEAM_SIZE=5
|
||||
|
||||
# Registered on API startup so local matchmaking has somewhere to assign
|
||||
# players to before real game servers self-register via POST /servers/register.
|
||||
# This IP is handed straight to game CLIENTS to connect their ENet peer to —
|
||||
# clients run on the host, not in Docker, so it must be host-reachable
|
||||
# (127.0.0.1 for local dev), not a docker-internal alias like
|
||||
# host.docker.internal (that only resolves from inside the api container).
|
||||
DEV_SEED_SERVER_IP=127.0.0.1
|
||||
DEV_SEED_SERVER_PORT=7777
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,81 @@
|
||||
# Spacewar Matchmaking API
|
||||
|
||||
FastAPI service for matchmaking, stats, and ranks (chat planned — see below).
|
||||
One process, one Postgres database, no Redis: matchmaking tickets live
|
||||
in-memory in the API process, which is enough at this scale — add Redis
|
||||
later only if the API needs to run as more than one instance.
|
||||
|
||||
## Run it
|
||||
|
||||
```
|
||||
cp .env.example .env # already done for local dev; edit if needed
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
API is at `http://localhost:8100`, interactive docs at `/docs`. Postgres is
|
||||
exposed on host port `5433` (mapped from its usual `5432`, to avoid clashing
|
||||
with any other local Postgres) for local inspection.
|
||||
|
||||
```
|
||||
docker compose down # stop
|
||||
docker compose down -v # stop + wipe the postgres volume
|
||||
```
|
||||
|
||||
Code in `app/` is bind-mounted with `--reload`, so edits take effect without
|
||||
rebuilding the image. Rebuild (`docker compose up -d --build`) only when
|
||||
`requirements.txt` changes.
|
||||
|
||||
## How matchmaking works right now
|
||||
|
||||
- `POST /matchmaking/queue/join` `{callsign, mode, mmr?}` — creates the
|
||||
player row if it doesn't exist yet, returns a `ticket_id`.
|
||||
- `GET /matchmaking/queue/status/{ticket_id}` — poll this; once matched it
|
||||
returns the assigned server's `server_ip`/`server_port`/`team`.
|
||||
- `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket.
|
||||
|
||||
A background loop (`app/queue_manager.py`) runs every 2s, and once enough
|
||||
players are queued for a mode (`CASUAL_TEAM_SIZE`/`RANKED_TEAM_SIZE` × 2) it
|
||||
forms a match, splits them into two teams, and assigns the first available
|
||||
`GameServer` for that mode.
|
||||
|
||||
There's no real game-server pool yet — one dev server is auto-seeded on API
|
||||
startup from `DEV_SEED_SERVER_IP`/`DEV_SEED_SERVER_PORT` (defaults to
|
||||
`127.0.0.1:7777`, i.e. a Godot server run on the host via
|
||||
`godot4 --headless --path spacewar -- --server`). That IP is handed straight
|
||||
to game clients to connect to, so it must be reachable from wherever the
|
||||
*client* runs, not the API container — `host.docker.internal` would resolve
|
||||
inside the api container but not on the client's machine, which is why this
|
||||
isn't a docker-internal address. Real servers should eventually call
|
||||
`POST /servers/register` on boot and periodically as a heartbeat — that
|
||||
endpoint exists but nothing calls it yet.
|
||||
|
||||
## Client integration
|
||||
|
||||
The Godot client is wired up (`spacewar/autoload/matchmaking_client.gd`):
|
||||
CASUAL/RANKED on the main menu calls `POST /matchmaking/queue/join`, polls
|
||||
`GET /matchmaking/queue/status/{ticket_id}` every 1.5s, and once `matched`
|
||||
connects `NetworkManager` directly to the returned `server_ip`/`server_port`.
|
||||
Point it at a non-default API with `godot4 ... -- --matchmaking-api=http://host:port`.
|
||||
|
||||
## Adding a new domain (stats/ranks did this; chat will too)
|
||||
|
||||
1. Add/extend a model in `app/models.py` if it needs its own table.
|
||||
2. Add request/response shapes to `app/schemas.py`.
|
||||
3. Add a router file under `app/routers/`, `include_router()` it in
|
||||
`app/main.py`.
|
||||
|
||||
Chat isn't scaffolded yet (no data model or requirements decided), but it
|
||||
fits the same shape — likely a WebSocket router using FastAPI's native
|
||||
support, in this same service.
|
||||
|
||||
## Not set up yet, on purpose
|
||||
|
||||
- **Migrations**: tables are created via `Base.metadata.create_all()` on
|
||||
startup. Fine while the schema is still moving; switch to Alembic before
|
||||
this holds real data.
|
||||
- **Auth**: `callsign` is the only player identity, matching the game
|
||||
client's current state (no accounts yet). Real identity arrives with the
|
||||
GodotSteam auth checklist item in `overview/tech.md`.
|
||||
- **Ranked MMR-window widening / bot-fill timeouts**: current matching is a
|
||||
simple threshold (enough players queued → form a match). Refine once
|
||||
there's real queue volume to tune against.
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
database_url: str
|
||||
casual_team_size: int = 7
|
||||
ranked_team_size: int = 5
|
||||
dev_seed_server_ip: str = "127.0.0.1"
|
||||
dev_seed_server_port: int = 7777
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,19 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,58 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, async_session, engine
|
||||
from app.models import GameServer, Mode, ServerStatus
|
||||
from app.queue_manager import queue_manager
|
||||
from app.routers import matchmaking, ranks, servers, stats
|
||||
|
||||
|
||||
async def _seed_dev_server() -> None:
|
||||
async with async_session() as db:
|
||||
for mode in (Mode.casual, Mode.ranked):
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(GameServer).where(
|
||||
GameServer.ip == settings.dev_seed_server_ip,
|
||||
GameServer.port == settings.dev_seed_server_port,
|
||||
GameServer.mode == mode,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is None:
|
||||
db.add(
|
||||
GameServer(
|
||||
ip=settings.dev_seed_server_ip,
|
||||
port=settings.dev_seed_server_port,
|
||||
mode=mode,
|
||||
status=ServerStatus.available,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await _seed_dev_server()
|
||||
matching_task = asyncio.create_task(queue_manager.run_matching_loop())
|
||||
yield
|
||||
matching_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
|
||||
|
||||
app.include_router(matchmaking.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(ranks.router)
|
||||
app.include_router(servers.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,67 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Mode(str, enum.Enum):
|
||||
casual = "casual"
|
||||
ranked = "ranked"
|
||||
|
||||
|
||||
class ServerStatus(str, enum.Enum):
|
||||
available = "available"
|
||||
full = "full"
|
||||
offline = "offline"
|
||||
|
||||
|
||||
# No account/auth system yet (GodotSteam auth is still on the networking
|
||||
# checklist), so callsign is the identity key for now — same as the game
|
||||
# client itself, which only has a free-text name field today.
|
||||
class Player(Base):
|
||||
__tablename__ = "players"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
callsign: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
mmr: Mapped[int] = mapped_column(Integer, default=1000)
|
||||
wins: Mapped[int] = mapped_column(Integer, default=0)
|
||||
losses: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class GameServer(Base):
|
||||
__tablename__ = "game_servers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
ip: Mapped[str] = mapped_column(String(255))
|
||||
port: Mapped[int] = mapped_column(Integer)
|
||||
mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
|
||||
status: Mapped[ServerStatus] = mapped_column(SAEnum(ServerStatus), default=ServerStatus.available)
|
||||
last_heartbeat: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Match(Base):
|
||||
__tablename__ = "matches"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
|
||||
server_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("game_servers.id"))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
players: Mapped[list["MatchPlayer"]] = relationship(back_populates="match")
|
||||
|
||||
|
||||
class MatchPlayer(Base):
|
||||
__tablename__ = "match_players"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
match_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("matches.id"))
|
||||
player_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("players.id"))
|
||||
team: Mapped[int] = mapped_column(Integer) # 0 or 1
|
||||
|
||||
match: Mapped["Match"] = relationship(back_populates="players")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models import Mode
|
||||
|
||||
|
||||
class QueueJoinRequest(BaseModel):
|
||||
callsign: str
|
||||
mode: Mode
|
||||
mmr: int | None = None # ignored for casual; defaults to the player's stored mmr for ranked
|
||||
|
||||
|
||||
class QueueJoinResponse(BaseModel):
|
||||
ticket_id: uuid.UUID
|
||||
status: str
|
||||
|
||||
|
||||
class QueueStatusResponse(BaseModel):
|
||||
ticket_id: uuid.UUID
|
||||
status: str
|
||||
server_ip: str | None = None
|
||||
server_port: int | None = None
|
||||
team: int | None = None
|
||||
|
||||
|
||||
class ServerRegisterRequest(BaseModel):
|
||||
ip: str
|
||||
port: int
|
||||
mode: Mode
|
||||
|
||||
|
||||
class PlayerStatsResponse(BaseModel):
|
||||
callsign: str
|
||||
mmr: int
|
||||
wins: int
|
||||
losses: int
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: matchmaking
|
||||
POSTGRES_PASSWORD: matchmaking
|
||||
POSTGRES_DB: matchmaking
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U matchmaking"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build: .
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8100:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
# Lets the container reach a Godot dev server running on the host
|
||||
# (Linux Docker Engine doesn't resolve host.docker.internal by default).
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.6.1
|
||||
Reference in New Issue
Block a user