Phase 7: netfox + godot-jolt stack upgrade

Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+598
View File
@@ -0,0 +1,598 @@
"""
tactical-shooter — Master Server API
======================================
Standalone REST service that receives heartbeat pings from game servers
and serves the server list to client browsers.
Endpoints:
POST /api/v1/heartbeat — Register/refresh a game server
GET /api/v1/servers — List all active servers
GET /api/v1/servers/:id — Get a single server's details
GET /api/v1/status — Health check + stats
Data: SQLite with TTL-based expiry (servers older than heartbeat_ttl are pruned).
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
import aiosqlite
import uvicorn
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
CONFIG_PATH = Path(__file__).resolve().parent / "master_config.json"
with open(CONFIG_PATH) as f:
_cfg = json.load(f)
HOST: str = _cfg.get("host", "0.0.0.0")
PORT: int = _cfg.get("port", 28961)
DB_PATH: str = _cfg.get("database", "master_server.db")
HEARTBEAT_TTL: int = _cfg.get("heartbeat_ttl_seconds", 180)
MAX_SERVERS: int = _cfg.get("max_servers", 1000)
RATE_LIMIT: int = _cfg.get("rate_limit_per_ip", 30)
RATE_LIMIT_WINDOW: int = _cfg.get("rate_limit_window_seconds", 60)
DB_PATH = os.environ.get("MASTER_DB_PATH", DB_PATH)
log = logging.getLogger("master_server")
log.setLevel(_cfg.get("log_level", "info").upper())
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class HeartbeatPayload(BaseModel):
"""Payload sent by game servers via POST /heartbeat."""
server_id: str | None = None # auto-generated if missing
name: str = "Unnamed Server"
map: str = "unknown"
players: int = 0
max_players: int = 16
game_mode: str = "deathmatch"
version: str = "1.0.0"
tags: str = ""
password: bool = False
host: str = ""
port: int = 0
steam_id: str = ""
class ServerInfo(BaseModel):
"""Public server info returned to clients."""
server_id: str
name: str
map: str
players: int
max_players: int
game_mode: str
version: str
tags: list[str]
password: bool
host: str
port: int
steam_id: str
last_seen: str # ISO-8601
uptime_seconds: int
class ServerListResponse(BaseModel):
count: int
servers: list[ServerInfo]
class StatusResponse(BaseModel):
status: str = "ok"
uptime_seconds: int
active_servers: int
total_heartbeats: int
version: str = "1.0.0"
# ---------------------------------------------------------------------------
# Map Registry Models
# ---------------------------------------------------------------------------
# Directory where uploaded .pck files are stored
MAPS_DIR: str = _cfg.get("maps_dir", "workshop_maps")
os.makedirs(MAPS_DIR, exist_ok=True)
class MapInfo(BaseModel):
"""Public map metadata returned to clients."""
name: str
size: int = 0
version: int = 1
description: str = ""
scene: str = ""
author: str = ""
checksum_sha256: str = ""
uploaded_at: str = ""
download_url: str = ""
class MapListResponse(BaseModel):
count: int
maps: list[MapInfo]
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS servers (
server_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
map TEXT NOT NULL DEFAULT 'unknown',
players INTEGER NOT NULL DEFAULT 0,
max_players INTEGER NOT NULL DEFAULT 16,
game_mode TEXT NOT NULL DEFAULT 'deathmatch',
version TEXT NOT NULL DEFAULT '1.0.0',
tags TEXT NOT NULL DEFAULT '',
password INTEGER NOT NULL DEFAULT 0,
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 0,
steam_id TEXT NOT NULL DEFAULT '',
first_seen REAL NOT NULL,
last_seen REAL NOT NULL,
heartbeat_count INTEGER NOT NULL DEFAULT 1
);
"""
CREATE_INDEX_SQL = "CREATE INDEX IF NOT EXISTS idx_servers_last_seen ON servers(last_seen);"
CREATE_MAPS_SQL = """
CREATE TABLE IF NOT EXISTS workshop_maps (
name TEXT PRIMARY KEY,
size INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 1,
description TEXT NOT NULL DEFAULT '',
scene TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
checksum_sha256 TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL,
uploaded_at REAL NOT NULL
);
"""
async def get_db() -> AsyncIterator[aiosqlite.Connection]:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA synchronous=NORMAL")
await db.execute(CREATE_TABLE_SQL)
await db.execute(CREATE_INDEX_SQL)
await db.commit()
yield db
async def _prune_expired(db: aiosqlite.Connection) -> int:
"""Remove servers whose heartbeat has expired. Returns count pruned."""
cutoff = time.time() - HEARTBEAT_TTL
db.row_factory = aiosqlite.Row
cursor = await db.execute("DELETE FROM servers WHERE last_seen < ?", (cutoff,))
await db.commit()
return cursor.rowcount
def _row_to_server(row: aiosqlite.Row) -> ServerInfo:
now = time.time()
tags_list = [t.strip() for t in row["tags"].split(",") if t.strip()] if row["tags"] else []
return ServerInfo(
server_id=row["server_id"],
name=row["name"],
map=row["map"],
players=row["players"],
max_players=row["max_players"],
game_mode=row["game_mode"],
version=row["version"],
tags=tags_list,
password=bool(row["password"]),
host=row["host"],
port=row["port"],
steam_id=row["steam_id"],
last_seen=datetime.fromtimestamp(row["last_seen"], tz=timezone.utc).isoformat(),
uptime_seconds=int(max(0, now - row["first_seen"])),
)
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
_start_time = time.time()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Startup / shutdown — ensure DB schema exists."""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA synchronous=NORMAL")
await db.execute(CREATE_TABLE_SQL)
await db.execute(CREATE_INDEX_SQL)
await db.execute(CREATE_MAPS_SQL)
await db.commit()
# Ensure maps directory exists
os.makedirs(MAPS_DIR, exist_ok=True)
log.info("Master server ready on %s:%s", HOST, PORT)
log.info("Maps directory: %s (absolute: %s)", MAPS_DIR, os.path.abspath(MAPS_DIR))
yield
app = FastAPI(
title="Tactical Shooter Master Server",
version="1.0.0",
lifespan=lifespan,
)
# Allow cross-origin from any game client / browser
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# In-memory rate limiter: {ip: [timestamps]}
_rate_buckets: dict[str, list[float]] = {}
def _check_rate_limit(ip: str) -> None:
now = time.time()
window_start = now - RATE_LIMIT_WINDOW
timestamps = _rate_buckets.get(ip, [])
# Drop old entries
timestamps = [t for t in timestamps if t > window_start]
if len(timestamps) >= RATE_LIMIT:
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded: max {RATE_LIMIT} requests per {RATE_LIMIT_WINDOW}s",
)
timestamps.append(now)
_rate_buckets[ip] = timestamps
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/api/v1/status", response_model=StatusResponse)
async def get_status():
"""Health check and server stats."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT COUNT(*) AS cnt FROM servers")
row = await cursor.fetchone()
active = row["cnt"] if row else 0
cursor = await db.execute("SELECT COALESCE(SUM(heartbeat_count), 0) AS total FROM servers")
row = await cursor.fetchone()
total_heartbeats = row["total"] if row else 0
return StatusResponse(
uptime_seconds=int(time.time() - _start_time),
active_servers=active,
total_heartbeats=total_heartbeats,
)
@app.post("/api/v1/heartbeat")
async def heartbeat(payload: HeartbeatPayload, request: Request):
"""Register or refresh a game server.
If server_id is not provided (or empty), one is auto-generated in the
response. The server should store the returned id and send it on
subsequent heartbeats.
"""
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip)
# Generate id for new servers
server_id = payload.server_id or str(uuid.uuid4())
now = time.time()
async with aiosqlite.connect(DB_PATH) as db:
# Check if we're at capacity
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT COUNT(*) AS cnt FROM servers")
row = await cursor.fetchone()
count = row["cnt"] if row else 0
if count >= MAX_SERVERS:
# Try to prune expired first
await _prune_expired(db)
cursor = await db.execute("SELECT COUNT(*) AS cnt FROM servers")
row = await cursor.fetchone()
count = row["cnt"] if row else 0
if count >= MAX_SERVERS:
raise HTTPException(status_code=503, detail="Server registry full")
# Upsert
await db.execute(
"""
INSERT INTO servers (server_id, name, map, players, max_players,
game_mode, version, tags, password, host, port,
steam_id, first_seen, last_seen, heartbeat_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(server_id) DO UPDATE SET
name = excluded.name,
map = excluded.map,
players = excluded.players,
max_players = excluded.max_players,
game_mode = excluded.game_mode,
version = excluded.version,
tags = excluded.tags,
password = excluded.password,
host = excluded.host,
port = excluded.port,
steam_id = excluded.steam_id,
last_seen = excluded.last_seen,
heartbeat_count = servers.heartbeat_count + 1
""",
(
server_id,
payload.name,
payload.map,
payload.players,
payload.max_players,
payload.game_mode,
payload.version,
payload.tags,
1 if payload.password else 0,
payload.host or ip,
payload.port,
payload.steam_id,
now,
now,
),
)
await db.commit()
log.info("Heartbeat from %s (%s) — %s/%s players on %s",
payload.name, server_id[:8], payload.players, payload.max_players, payload.map)
return {
"status": "ok",
"server_id": server_id,
"registered": True,
}
@app.get("/api/v1/servers", response_model=ServerListResponse)
async def list_servers(request: Request, tags: str | None = None):
"""List all active servers, optionally filtered by comma-separated tags."""
_check_rate_limit(request.client.host if request.client else "unknown")
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
await _prune_expired(db)
if tags:
# Filter servers whose tags field contains any of the requested tags
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
placeholders = ",".join("?" for _ in tag_list)
query = f"""
SELECT * FROM servers
WHERE last_seen > ?
AND ({' OR '.join(f"tags LIKE ?" for _ in tag_list)})
ORDER BY players DESC
"""
params = [time.time() - HEARTBEAT_TTL]
for tg in tag_list:
params.append(f"%{tg}%")
cursor = await db.execute(query, params)
else:
cursor = await db.execute(
"SELECT * FROM servers WHERE last_seen > ? ORDER BY players DESC",
(time.time() - HEARTBEAT_TTL,),
)
rows = await cursor.fetchall()
servers = [_row_to_server(r) for r in rows]
return ServerListResponse(count=len(servers), servers=servers)
@app.get("/api/v1/servers/{server_id}", response_model=ServerInfo)
async def get_server(server_id: str):
"""Get details for a single server."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM servers WHERE server_id = ?", (server_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Server not found")
now = time.time()
if row["last_seen"] < now - HEARTBEAT_TTL:
raise HTTPException(status_code=410, detail="Server has expired (no recent heartbeat)")
return _row_to_server(row)
# ---------------------------------------------------------------------------
# Map Registry — Workshop API
# ---------------------------------------------------------------------------
def _compute_sha256(filepath: str) -> str:
"""Compute SHA-256 checksum of a file."""
try:
h = hashlib.sha256()
with open(filepath, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
except OSError:
return ""
def _row_to_map_info(row: aiosqlite.Row) -> MapInfo:
"""Convert a DB row to a MapInfo response model."""
return MapInfo(
name=row["name"],
size=row["size"],
version=row["version"],
description=row["description"],
scene=row["scene"],
author=row["author"],
checksum_sha256=row["checksum_sha256"],
uploaded_at=datetime.fromtimestamp(row["uploaded_at"], tz=timezone.utc).isoformat(),
download_url=f"/api/v1/maps/{row['name']}.pck",
)
@app.get("/api/v1/maps", response_model=MapListResponse)
async def list_workshop_maps():
"""List all community maps available for download."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM workshop_maps ORDER BY name ASC"
)
rows = await cursor.fetchall()
maps = [_row_to_map_info(r) for r in rows]
return MapListResponse(count=len(maps), maps=maps)
@app.get("/api/v1/maps/{map_name}")
async def get_workshop_map(map_name: str):
"""Get metadata for a specific workshop map."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM workshop_maps WHERE name = ?", (map_name,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Map '{map_name}' not found")
return _row_to_map_info(row)
@app.get("/api/v1/maps/{map_name}.pck")
async def download_workshop_map(map_name: str):
"""Download a workshop map .pck file."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM workshop_maps WHERE name = ?", (map_name,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Map '{map_name}' not found")
filepath = os.path.join(MAPS_DIR, row["filename"])
if not os.path.exists(filepath):
raise HTTPException(status_code=410, detail="Map file has been removed from storage")
return FileResponse(
path=filepath,
filename=row["filename"],
media_type="application/octet-stream",
headers={
"Access-Control-Allow-Origin": "*",
"X-Checksum-SHA256": row["checksum_sha256"],
},
)
@app.post("/api/v1/maps/upload")
async def upload_workshop_map(
request: Request,
file: UploadFile = File(...),
name: str = Form(...),
description: str = Form(""),
scene: str = Form(""),
author: str = Form(""),
version: int = Form(1),
):
"""Upload a new workshop map .pck file."""
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip)
# Validate file extension
if not file.filename or not file.filename.endswith(".pck"):
raise HTTPException(status_code=400, detail="Only .pck files are accepted")
# Save file to disk
filename = f"{name}.pck"
filepath = os.path.join(MAPS_DIR, filename)
content = await file.read()
with open(filepath, "wb") as f:
f.write(content)
file_size = os.path.getsize(filepath)
checksum = _compute_sha256(filepath)
now = time.time()
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA synchronous=NORMAL")
await db.execute(
"""INSERT OR REPLACE INTO workshop_maps
(name, size, version, description, scene, author, checksum_sha256, filename, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(name, file_size, version, description, scene, author, checksum, filename, now),
)
await db.commit()
log.info("Map uploaded: %s (%s, %d bytes, v%d)", name, filename, file_size, version)
return {
"status": "ok",
"map": name,
"size": file_size,
"version": version,
"checksum_sha256": checksum,
}
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
if __name__ == "__main__":
logging.basicConfig(
level=_cfg.get("log_level", "info").upper(),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
uvicorn.run(
"main:app",
host=HOST,
port=PORT,
log_level=_cfg.get("log_level", "info"),
reload=False,
)