e9dc05983c
Includes code from task workspaces that was never pushed: - client/characters/ — fps_character_controller.gd (400 lines), fps_camera.gd, input_handler.gd - gdextension/simulation/ — C++ GDExtension scaffold: entity, movement, hit detection, simulation server, state serializer, bitstream (14 files) - scripts/network/ — ENet-based network_manager.gd, server/client_main.gd, player.gd - scripts/map_packaging/ — PCK pipeline: pack_map.gd, map_downloader.gd, map_registry_server.py, README - scripts/config/ — server_config.gd (480 lines) - scenes/ — client_main, server_main, player, test_range - project.godot, export_presets.cfg
394 lines
13 KiB
Python
394 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
map_registry_server.py — Map Registry HTTP Server
|
|
|
|
Serves packaged .pck map files and a JSON map listing for the Godot
|
|
client's MapDownloader to consume.
|
|
|
|
Endpoints
|
|
---------
|
|
|
|
GET /maps
|
|
Returns a JSON list of available maps with metadata.
|
|
|
|
Response:
|
|
{
|
|
"maps": [
|
|
{
|
|
"name": "de_dust2",
|
|
"size": 4194304,
|
|
"version": 1,
|
|
"description": "Classic bomb-defusal map",
|
|
"scene": "res://scenes/maps/de_dust2.tscn",
|
|
"checksum_sha256": "a1b2c3..."
|
|
}
|
|
],
|
|
"server_name": "Tactical Shooter Map Registry",
|
|
"map_count": 1
|
|
}
|
|
|
|
GET /maps/<name>.pck
|
|
Download a packaged map file. Content-Type: application/octet-stream.
|
|
|
|
GET /maps/<name>.json
|
|
Download metadata for a specific map.
|
|
|
|
Response:
|
|
{
|
|
"name": "de_dust2",
|
|
"size": 4194304,
|
|
"version": 1,
|
|
"description": "Classic bomb-defusal map",
|
|
"scene": "res://scenes/maps/de_dust2.tscn",
|
|
"checksum_sha256": "a1b2c3...",
|
|
"packed_at": "2026-06-30 12:00:00"
|
|
}
|
|
|
|
Usage
|
|
-----
|
|
|
|
# Start the server on the default port (8090):
|
|
python3 map_registry_server.py
|
|
|
|
# Custom port and map directory:
|
|
python3 map_registry_server.py --port 8080 --maps-dir /data/maps
|
|
|
|
# Docker / container friendly:
|
|
MAP_REGISTRY_PORT=8080 MAP_REGISTRY_MAPS=/data/maps python3 map_registry_server.py
|
|
|
|
The server scans `maps_dir` (default: ./packed_maps/) for *.pck files and
|
|
their matching *.json metadata files on startup and on SIGHUP.
|
|
|
|
Integration
|
|
-----------
|
|
|
|
In your server's ServerConfig or config, add a registry section:
|
|
|
|
[map_registry]
|
|
enabled=true
|
|
url="http://maps.example.com:8090"
|
|
|
|
Or set env: MAP_REGISTRY_URL on the game server.
|
|
|
|
The game client's MapDownloader singleton will connect to this server
|
|
to fetch the map list and download .pck files.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
from pathlib import Path
|
|
from typing import Dict, Optional
|
|
|
|
logger = logging.getLogger("MapRegistry")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DEFAULT_PORT = 8090
|
|
DEFAULT_MAPS_DIR = "packed_maps"
|
|
SERVER_NAME = "Tactical Shooter Map Registry"
|
|
VERSION = "1.0.0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Map Registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MapRegistry:
|
|
"""Scans a directory for .pck files and their metadata."""
|
|
|
|
def __init__(self, maps_dir: str):
|
|
self.maps_dir = Path(maps_dir)
|
|
self.maps_dir.mkdir(parents=True, exist_ok=True)
|
|
self._cache: Dict[str, dict] = {}
|
|
self._last_scan: float = 0
|
|
self._scan_interval: float = 5.0 # seconds between rescans
|
|
self._scan()
|
|
|
|
def _scan(self) -> None:
|
|
"""Scan the maps directory for .pck and .json files."""
|
|
now = time.time()
|
|
if now - self._last_scan < self._scan_interval:
|
|
return
|
|
self._last_scan = now
|
|
|
|
self._cache.clear()
|
|
if not self.maps_dir.exists():
|
|
logger.warning("Maps directory does not exist: %s", self.maps_dir)
|
|
return
|
|
|
|
for pck_file in sorted(self.maps_dir.glob("*.pck")):
|
|
map_name = pck_file.stem
|
|
json_file = pck_file.with_suffix(".json")
|
|
|
|
entry = {
|
|
"name": map_name,
|
|
"size": pck_file.stat().st_size,
|
|
"path": str(pck_file.relative_to(self.maps_dir)),
|
|
"version": 1,
|
|
"description": "",
|
|
"scene": f"res://scenes/maps/{map_name}.tscn",
|
|
"checksum_sha256": "",
|
|
"packed_at": "",
|
|
}
|
|
|
|
# Load metadata from sidecar JSON if it exists
|
|
if json_file.exists():
|
|
try:
|
|
with open(json_file, "r") as f:
|
|
meta = json.load(f)
|
|
entry["version"] = meta.get("version", 1)
|
|
entry["description"] = meta.get("description", "")
|
|
entry["packed_at"] = meta.get("packed_at", "")
|
|
entry["source_scene"] = meta.get("source_scene", "")
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning("Failed to parse metadata for %s: %s", map_name, e)
|
|
|
|
# Compute SHA-256 checksum (on first scan, cached in memory)
|
|
ck = self._compute_checksum(pck_file)
|
|
if ck:
|
|
entry["checksum_sha256"] = ck
|
|
|
|
self._cache[map_name] = entry
|
|
logger.debug("Registered map: %s (%d bytes)", map_name, entry["size"])
|
|
|
|
logger.info("Scanned %s: %d maps registered", self.maps_dir, len(self._cache))
|
|
|
|
def _compute_checksum(self, path: Path) -> str:
|
|
"""Compute SHA-256 checksum of a file."""
|
|
try:
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(65536)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
except OSError as e:
|
|
logger.warning("Checksum failed for %s: %s", path.name, e)
|
|
return ""
|
|
|
|
def get_map_list(self) -> dict:
|
|
"""Return the full map listing as a dict."""
|
|
self._scan()
|
|
return {
|
|
"maps": list(self._cache.values()),
|
|
"server_name": SERVER_NAME,
|
|
"server_version": VERSION,
|
|
"map_count": len(self._cache),
|
|
}
|
|
|
|
def get_map_info(self, name: str) -> Optional[dict]:
|
|
"""Return metadata for a single map."""
|
|
self._scan()
|
|
return self._cache.get(name)
|
|
|
|
def get_map_file(self, name: str) -> Optional[Path]:
|
|
"""Return the filesystem path to a .pck file, or None."""
|
|
self._scan()
|
|
entry = self._cache.get(name)
|
|
if entry is None:
|
|
return None
|
|
pck_path = self.maps_dir / entry["path"]
|
|
return pck_path if pck_path.exists() else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP Handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MapRegistryHandler(BaseHTTPRequestHandler):
|
|
"""HTTP request handler for the map registry API."""
|
|
|
|
# Shared across all instances (set by the server)
|
|
registry: MapRegistry = None # type: ignore
|
|
|
|
def log_message(self, format: str, *args) -> None:
|
|
logger.info("%s - %s", self.client_address[0], format % args)
|
|
|
|
def _send_json(self, data: dict, status: int = 200) -> None:
|
|
body = json.dumps(data, indent=2).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_error(self, status: int, message: str) -> None:
|
|
self._send_json({"error": message}, status)
|
|
|
|
def _send_file(self, path: Path, content_type: str = "application/octet-stream") -> None:
|
|
try:
|
|
file_size = path.stat().st_size
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(file_size))
|
|
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.end_headers()
|
|
|
|
with open(path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(65536)
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
except OSError as e:
|
|
logger.error("File send failed for %s: %s", path.name, e)
|
|
self._send_error(500, "Internal server error")
|
|
|
|
def do_GET(self) -> None:
|
|
path = self.path.rstrip("/")
|
|
|
|
# GET /maps — list all available maps
|
|
if path == "/maps":
|
|
self._send_json(self.registry.get_map_list())
|
|
return
|
|
|
|
# GET /maps/<name>.pck — download a map file
|
|
if path.startswith("/maps/") and path.endswith(".pck"):
|
|
map_name = path[len("/maps/"):-len(".pck")]
|
|
map_file = self.registry.get_map_file(map_name)
|
|
if map_file:
|
|
self._send_file(map_file)
|
|
else:
|
|
self._send_error(404, f"Map '{map_name}' not found")
|
|
return
|
|
|
|
# GET /maps/<name>.json — get metadata for a single map
|
|
if path.startswith("/maps/") and path.endswith(".json"):
|
|
map_name = path[len("/maps/"):-len(".json")]
|
|
info = self.registry.get_map_info(map_name)
|
|
if info:
|
|
self._send_json(info)
|
|
else:
|
|
self._send_error(404, f"Map '{map_name}' not found")
|
|
return
|
|
|
|
# GET / — server info
|
|
if path == "/" or path == "":
|
|
self._send_json({
|
|
"service": SERVER_NAME,
|
|
"version": VERSION,
|
|
"endpoints": {
|
|
"list_maps": "/maps",
|
|
"download_map": "/maps/<name>.pck",
|
|
"map_metadata": "/maps/<name>.json",
|
|
},
|
|
})
|
|
return
|
|
|
|
self._send_error(404, f"Not found: {path}")
|
|
|
|
def do_OPTIONS(self) -> None:
|
|
"""CORS preflight."""
|
|
self.send_response(204)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
self.end_headers()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Server
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create_server(port: int, maps_dir: str) -> HTTPServer:
|
|
"""Create and configure the HTTP server."""
|
|
registry = MapRegistry(maps_dir)
|
|
MapRegistryHandler.registry = registry
|
|
|
|
server = HTTPServer(("0.0.0.0", port), MapRegistryHandler)
|
|
server.timeout = 0.5 # allow signal handling
|
|
|
|
logger.info("Map Registry Server v%s", VERSION)
|
|
logger.info(" Listen: http://0.0.0.0:%d", port)
|
|
logger.info(" Maps dir: %s", registry.maps_dir.resolve())
|
|
logger.info(" Maps found: %d", len(registry._cache))
|
|
logger.info(" Endpoints:")
|
|
logger.info(" List: GET /maps")
|
|
logger.info(" Download: GET /maps/<name>.pck")
|
|
logger.info(" Metadata: GET /maps/<name>.json")
|
|
|
|
return server
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Tactical Shooter Map Registry Server",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
parser.add_argument(
|
|
"--port", "-p",
|
|
type=int,
|
|
default=int(os.environ.get("MAP_REGISTRY_PORT", DEFAULT_PORT)),
|
|
help="HTTP port (default: %d)" % DEFAULT_PORT,
|
|
)
|
|
parser.add_argument(
|
|
"--maps-dir", "-d",
|
|
type=str,
|
|
default=os.environ.get("MAP_REGISTRY_MAPS", DEFAULT_MAPS_DIR),
|
|
help="Directory containing .pck files (default: %s)" % DEFAULT_MAPS_DIR,
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", "-v",
|
|
action="store_true",
|
|
help="Enable debug logging",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
server = create_server(args.port, args.maps_dir)
|
|
|
|
# Handle SIGTERM/SIGINT for graceful shutdown
|
|
shutdown_requested = False
|
|
|
|
def handle_signal(sig, frame):
|
|
nonlocal shutdown_requested
|
|
if not shutdown_requested:
|
|
logger.info("Shutdown requested (signal %d)", sig)
|
|
shutdown_requested = True
|
|
server.shutdown()
|
|
|
|
signal.signal(signal.SIGTERM, handle_signal)
|
|
signal.signal(signal.SIGINT, handle_signal)
|
|
|
|
# SIGHUP rescans the map directory
|
|
if hasattr(signal, "SIGHUP"):
|
|
def handle_hup(sig, frame):
|
|
logger.info("SIGHUP received — rescanning maps")
|
|
server.RequestHandlerClass.registry._scan()
|
|
signal.signal(signal.SIGHUP, handle_hup)
|
|
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|
|
logger.info("Server stopped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|