from datetime import datetime, timedelta from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.models import GameServer, ServerStatus from app.schemas import ServerRegisterRequest router = APIRouter(prefix="/servers", tags=["servers"]) def _status_for(player_count: int, max_players: int) -> ServerStatus: return ServerStatus.full if player_count >= max_players else ServerStatus.available # spacewar/autoload/network_manager.gd calls this once on server boot and # then repeatedly as a heartbeat (every HEARTBEAT_INTERVAL, currently 8s), # via MatchmakingClient.register_server(). Same endpoint for both — an # unrecognized (ip, port, mode) creates a new row, a recognized one just # refreshes it. sweep_stale_servers() (below) is what flips a server back to # offline if those heartbeats stop, e.g. the process crashed or was killed # without a clean shutdown. @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() status = _status_for(req.player_count, req.max_players) if existing: existing.status = status existing.player_count = req.player_count existing.max_players = req.max_players existing.last_heartbeat = datetime.utcnow() existing.game_mode = req.game_mode existing.map_name = req.map_name else: db.add( GameServer( ip=req.ip, port=req.port, mode=req.mode, status=status, player_count=req.player_count, max_players=req.max_players, game_mode=req.game_mode, map_name=req.map_name, ) ) await db.commit() return {"status": "registered"} # Run periodically from main.py's lifespan (same shape as # queue_manager.run_matching_loop()). Without this, a server that's killed # instead of cleanly shut down (crash, host reboot, `kill -9`) would stay # listed as available/full forever — server_select.gd's own UDP ping probe # catches that live per-row, but this keeps the DB's status column honest # too, e.g. for any future consumer that just reads GET /servers. async def sweep_stale_servers(db: AsyncSession) -> None: cutoff = datetime.utcnow() - timedelta(seconds=settings.server_stale_seconds) stale = ( await db.execute( select(GameServer).where(GameServer.last_heartbeat < cutoff, GameServer.status != ServerStatus.offline) ) ).scalars().all() for server in stale: server.status = ServerStatus.offline if stale: await db.commit() @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, "player_count": s.player_count, "max_players": s.max_players, "last_heartbeat": s.last_heartbeat, "game_mode": s.game_mode, "map_name": s.map_name, } for s in servers ]