PC3 Phase 2.2: game-server orchestration + POST /api/worlds/{id}/join
- backend/app/services/game_servers.py: an in-process supervisor. One
headless Godot dedicated-server process per world, keyed by world_id,
spawned on demand from the join endpoint, port-allocated from a
configured range, with a background reaper that kills a world's server
once its status file reports 0 players for the idle timeout. Off by
default (GAME_SERVERS_ENABLED); the checkpoint/playtest launcher enables it.
- POST /api/worlds/{id}/join -> {world_id, host, port}; 404 for
unknown/hidden worlds, 503 when orchestration is disabled. Auth required.
- main.py lifespan starts/stops the supervisor.
- config.py: GODOT_BIN, GAME_PROJECT_DIR, GAME_SERVER_HOST, port range,
idle timeout.
- tests/test_world_join.py (auth / 404 / visibility / 503) -- suite 72 passed.
- checkpoint runner also runs the 2p multiplayer shell test.
Verified end to end: enabled orchestration, POST /join spawned the Godot
server, a real client connected to the returned address and moved; backend
shutdown reaped the process.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"""Game-server orchestration.
|
||||
|
||||
On ``POST /api/worlds/{id}/join`` the backend ensures a headless Godot
|
||||
dedicated-server process is running for that world and returns its address.
|
||||
One process per world, keyed by ``world_id``. A background reaper kills a
|
||||
world's server once its status file reports zero players for
|
||||
``GAME_SERVER_IDLE_TIMEOUT_SECONDS``.
|
||||
|
||||
This lives in-process (a module-level singleton) rather than as a separate
|
||||
service, so the backend MUST run single-worker -- which it does
|
||||
(``uvicorn app.main:app`` with no ``--workers``). The Godot binary and
|
||||
project are on the host (see ``settings.GODOT_BIN`` / ``GAME_PROJECT_DIR``);
|
||||
the backend spawns them as subprocesses.
|
||||
|
||||
Design notes / limits (PC3):
|
||||
- All worlds currently load the same test level (per-world content is M2/M7).
|
||||
``world_id`` still scopes a distinct server process + session.
|
||||
- Port allocation is a linear scan of the configured range.
|
||||
- No crash-restart: if a server process dies, the next join respawns it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class _WorldServer:
|
||||
world_id: str
|
||||
port: int
|
||||
proc: subprocess.Popen
|
||||
status_path: Path
|
||||
started_at: float = field(default_factory=time.time)
|
||||
last_join: float = field(default_factory=time.time)
|
||||
|
||||
def status(self) -> dict:
|
||||
try:
|
||||
return json.loads(self.status_path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
def player_count(self) -> int:
|
||||
return int(self.status().get("players", 0))
|
||||
|
||||
def alive(self) -> bool:
|
||||
return self.proc.poll() is None
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.alive():
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.status_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class GameServerSupervisor:
|
||||
def __init__(self) -> None:
|
||||
self._servers: dict[str, _WorldServer] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._reaper: asyncio.Task | None = None
|
||||
self._runtime_dir = Path(tempfile.gettempdir()) / "agp-game-servers"
|
||||
self._runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
if not settings.GAME_SERVERS_ENABLED:
|
||||
return
|
||||
if self._reaper is None or self._reaper.done():
|
||||
self._reaper = asyncio.create_task(self._reap_loop())
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._reaper:
|
||||
self._reaper.cancel()
|
||||
async with self._lock:
|
||||
for srv in list(self._servers.values()):
|
||||
srv.stop()
|
||||
self._servers.clear()
|
||||
|
||||
# ── join ─────────────────────────────────────────────────────────────
|
||||
async def ensure_server(self, world_id: str) -> dict:
|
||||
"""Return ``{"host", "port", "world_id"}`` for the world's dedicated
|
||||
server, spawning it if needed. Raises RuntimeError if orchestration
|
||||
is disabled or no port/engine is available."""
|
||||
if not settings.GAME_SERVERS_ENABLED:
|
||||
raise RuntimeError("game servers are not enabled on this backend")
|
||||
|
||||
async with self._lock:
|
||||
srv = self._servers.get(world_id)
|
||||
if srv is not None and not srv.alive():
|
||||
srv.stop()
|
||||
srv = None
|
||||
del self._servers[world_id]
|
||||
|
||||
if srv is None:
|
||||
srv = self._spawn(world_id)
|
||||
self._servers[world_id] = srv
|
||||
# Give the engine a moment to bind the ENet port before we
|
||||
# hand the address to a client.
|
||||
await self._await_port(srv.port)
|
||||
|
||||
srv.last_join = time.time()
|
||||
return {"host": settings.GAME_SERVER_HOST, "port": srv.port, "world_id": world_id}
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────
|
||||
def _spawn(self, world_id: str) -> _WorldServer:
|
||||
port = self._free_port()
|
||||
status_path = self._runtime_dir / f"world_{world_id}_{port}.json"
|
||||
status_path.unlink(missing_ok=True)
|
||||
cmd = [
|
||||
settings.GODOT_BIN, "--headless",
|
||||
"--path", settings.GAME_PROJECT_DIR,
|
||||
"res://scenes/server.tscn", "--",
|
||||
"--port", str(port),
|
||||
"--world", str(world_id),
|
||||
"--status-file", str(status_path),
|
||||
]
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return _WorldServer(world_id=world_id, port=port, proc=proc, status_path=status_path)
|
||||
|
||||
def _free_port(self) -> int:
|
||||
used = {s.port for s in self._servers.values()}
|
||||
for p in range(settings.GAME_SERVER_PORT_MIN, settings.GAME_SERVER_PORT_MAX + 1):
|
||||
if p in used:
|
||||
continue
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
try:
|
||||
s.bind(("0.0.0.0", p))
|
||||
return p
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("no free game-server port in the configured range")
|
||||
|
||||
async def _await_port(self, port: int, timeout: float = 8.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
# ENet is UDP; a bind failure means something (our server) has it.
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
try:
|
||||
s.bind(("0.0.0.0", port))
|
||||
except OSError:
|
||||
return
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
async def _reap_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
for wid, srv in list(self._servers.items()):
|
||||
if not srv.alive():
|
||||
srv.stop()
|
||||
del self._servers[wid]
|
||||
continue
|
||||
idle_for = now - srv.last_join
|
||||
if idle_for > settings.GAME_SERVER_IDLE_TIMEOUT_SECONDS and srv.player_count() == 0:
|
||||
srv.stop()
|
||||
del self._servers[wid]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def debug_state(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"world_id": s.world_id, "port": s.port, "alive": s.alive(),
|
||||
"players": s.player_count(), "uptime_s": round(time.time() - s.started_at),
|
||||
}
|
||||
for s in self._servers.values()
|
||||
]
|
||||
|
||||
|
||||
supervisor = GameServerSupervisor()
|
||||
Reference in New Issue
Block a user