455 lines
17 KiB
GDScript
455 lines
17 KiB
GDScript
## ServerMain - headless dedicated server for one world.
|
|
##
|
|
## Accepts ENet connections, spawns a server-authoritative Player per peer in a
|
|
## real loaded level, and broadcasts a full snapshot (every peer's state + the
|
|
## last input seq processed for that peer) every physics frame. The project's
|
|
## physics rate is pinned to 30 Hz so the sim is deterministic and matches the
|
|
## client's prediction step.
|
|
##
|
|
## Launched by the backend's game-server supervisor
|
|
## (backend/app/services/game_servers.py):
|
|
## godot --headless --path . res://scenes/server.tscn -- \
|
|
## --port 27000 --world 3 --status-file /path/to/status.json \
|
|
## --api-url http://127.0.0.1:8000 --server-token <per-spawn token>
|
|
## All args are optional; the defaults let `godot res://scenes/server.tscn`
|
|
## run standalone on 7777 for smoke_test_network_client.gd (which passes
|
|
## neither --api-url nor --server-token, so startup goes straight to the
|
|
## test_level.tscn fallback below).
|
|
##
|
|
## Startup sequence (docs/architecture/world_persistence_impl.md §7.2):
|
|
## 1. Parse args.
|
|
## 2. If --api-url/--server-token/--world are all present, pull the world's
|
|
## manifest + the spawn-neighbourhood chunks from the backend's internal
|
|
## API (§8.3), decode them, and build real static collision from the
|
|
## tile layers + objects. This happens BEFORE the ENet port binds.
|
|
## 3. Fall back to test_level.tscn if the backend is unreachable, the world
|
|
## has no rooms, or the room has zero chunks -- a content outage must
|
|
## never make a world un-joinable (§7.2 step 5).
|
|
## 4. Bind the ENet port, then write "ready": true and "content" ("loaded"
|
|
## or "fallback") to the status file -- this is what
|
|
## GameServerSupervisor._await_ready() polls for (§7.4), replacing the
|
|
## old bare-port-bind readiness check.
|
|
extends Node
|
|
|
|
const MAX_PLAYERS := 16
|
|
const DEFAULT_PORT := 7777
|
|
const PHYSICS_HZ := 30
|
|
## Rewrite the status file (player count, for the supervisor's idle reaper)
|
|
## every this many physics frames.
|
|
const STATUS_WRITE_EVERY := 30
|
|
|
|
## world_persistence_impl.md §4.1's default room slug.
|
|
const MAIN_ROOM_SLUG := "main"
|
|
## §7.2 step 3: "the spawn neighbourhood (radius from the spawn point,
|
|
## default 3 chunks)".
|
|
const SPAWN_CHUNK_RADIUS := 3
|
|
## HTTP timeout for internal-API calls during startup. Generous but bounded
|
|
## -- a hung backend must not hang the server startup (§7.2 fallback rule).
|
|
const CONTENT_FETCH_TIMEOUT := 8.0
|
|
|
|
var network_peer: ENetMultiplayerPeer
|
|
var port: int = DEFAULT_PORT
|
|
var world_id: String = ""
|
|
var status_file_path: String = ""
|
|
|
|
## world_persistence_impl.md §7.3: internal-API base URL and this spawn's
|
|
## bearer token, both passed on the command line by
|
|
## GameServerSupervisor._spawn(). Empty means "no backend to talk to" --
|
|
## content loading is skipped and the fallback level loads immediately.
|
|
var api_url: String = ""
|
|
var server_token: String = ""
|
|
|
|
## What startup actually loaded, written into the status file's "content"
|
|
## field: "loaded" (real content from the backend) or "fallback"
|
|
## (test_level.tscn, either because there was nothing to load or nothing
|
|
## reachable to load it from).
|
|
var _content_status: String = "fallback"
|
|
## Diagnostic-only counters (not part of the doc's required status fields,
|
|
## but real: they're the actual number of chunks decoded and collision
|
|
## shapes added to the tree, computed in the same pass that builds them --
|
|
## useful for verifying a real load happened without needing to poke the
|
|
## running server's scene tree from outside).
|
|
var _chunks_loaded: int = 0
|
|
var _collision_shapes_built: int = 0
|
|
|
|
## peer_id -> Player node
|
|
var players: Dictionary = {}
|
|
var tick: int = 0
|
|
var is_running: bool = false
|
|
## Monotonic count of joins ever -- drives spawn spread so two players never
|
|
## land on the same spot (which used to shove one through the floor).
|
|
var _join_count: int = 0
|
|
|
|
var _player_scene: PackedScene = preload("res://scenes/server_player.tscn")
|
|
var _fallback_level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn")
|
|
var _spawn_point: Vector3 = Vector3(0, 1.5, 0)
|
|
|
|
|
|
func _enter_tree() -> void:
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
|
|
|
|
func _ready() -> void:
|
|
_parse_args()
|
|
Engine.physics_ticks_per_second = PHYSICS_HZ
|
|
|
|
var level: Node = null
|
|
if api_url != "" and server_token != "" and world_id != "":
|
|
level = await _try_load_real_content()
|
|
|
|
if level == null:
|
|
level = _fallback_level_scene.instantiate()
|
|
_content_status = "fallback"
|
|
elif _chunks_loaded == 0:
|
|
_content_status = "fallback"
|
|
else:
|
|
_content_status = "loaded"
|
|
|
|
level.name = "World"
|
|
add_child(level)
|
|
var marker := level.find_child("PlayerSpawn", true, false)
|
|
if marker is Node3D:
|
|
_spawn_point = (marker as Node3D).global_position
|
|
|
|
network_peer = ENetMultiplayerPeer.new()
|
|
var err := network_peer.create_server(port, MAX_PLAYERS)
|
|
if err != OK:
|
|
printerr("[Server] create_server(%d) failed: %d" % [port, err])
|
|
get_tree().quit(1)
|
|
return
|
|
multiplayer.multiplayer_peer = network_peer
|
|
is_running = true
|
|
_write_status()
|
|
print("[Server] world=%s listening on ENet %d (%d Hz) content=%s chunks=%d collision_shapes=%d" % [
|
|
world_id, port, PHYSICS_HZ, _content_status, _chunks_loaded, _collision_shapes_built
|
|
])
|
|
|
|
|
|
## A spawn point offset from the level's marker so joins don't stack. Golden-
|
|
## angle spiral: evenly spread, never exactly repeats, grows slowly outward.
|
|
func _spawn_for(index: int) -> Vector3:
|
|
if index == 0:
|
|
return _spawn_point
|
|
var angle := index * 2.399963 # golden angle (radians)
|
|
var radius := 0.9 + 0.25 * float(index)
|
|
return _spawn_point + Vector3(cos(angle) * radius, 0.0, sin(angle) * radius)
|
|
|
|
|
|
func _parse_args() -> void:
|
|
var args := OS.get_cmdline_user_args()
|
|
var i := 0
|
|
while i < args.size():
|
|
match args[i]:
|
|
"--port":
|
|
if i + 1 < args.size():
|
|
port = int(args[i + 1])
|
|
i += 1
|
|
"--world":
|
|
if i + 1 < args.size():
|
|
world_id = args[i + 1]
|
|
i += 1
|
|
"--status-file":
|
|
if i + 1 < args.size():
|
|
status_file_path = args[i + 1]
|
|
i += 1
|
|
"--api-url":
|
|
if i + 1 < args.size():
|
|
api_url = args[i + 1].rstrip("/")
|
|
i += 1
|
|
"--server-token":
|
|
if i + 1 < args.size():
|
|
server_token = args[i + 1]
|
|
i += 1
|
|
i += 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Startup content load (world_persistence_impl.md §7.2/§7.3/§8.3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Attempt to build a real level from the backend's internal content API.
|
|
## Returns null on ANY failure (unreachable backend, no rooms, zero chunks)
|
|
## so the caller falls back to test_level.tscn -- a content outage must
|
|
## never make a world un-joinable.
|
|
func _try_load_real_content() -> Node:
|
|
var manifest_result := await _internal_get_json("/api/internal/worlds/%s/manifest" % world_id)
|
|
if not manifest_result.ok:
|
|
print("[Server] manifest fetch failed (%s); falling back to test_level" % manifest_result.get("detail", "?"))
|
|
return null
|
|
|
|
var manifest: Dictionary = manifest_result.data
|
|
var rooms: Array = manifest.get("rooms", [])
|
|
var room: Variant = _pick_room(rooms)
|
|
if room == null:
|
|
print("[Server] world %s has no rooms; falling back to test_level" % world_id)
|
|
return null
|
|
if int(room.get("chunk_count", 0)) <= 0:
|
|
print("[Server] room '%s' has zero chunks; falling back to test_level" % room.get("slug", "?"))
|
|
return null
|
|
|
|
var slug: String = room.get("slug", MAIN_ROOM_SLUG)
|
|
var spawn_pos := _spawn_pos_from_room(room)
|
|
var spawn_coord := Chunk.coord_for_position(spawn_pos)
|
|
|
|
var index_path := "/api/internal/worlds/%s/rooms/%s/chunks?cx_min=%d&cx_max=%d&cy_min=%d&cy_max=%d&cz=%d" % [
|
|
world_id, slug,
|
|
spawn_coord.x - SPAWN_CHUNK_RADIUS, spawn_coord.x + SPAWN_CHUNK_RADIUS,
|
|
spawn_coord.y - SPAWN_CHUNK_RADIUS, spawn_coord.y + SPAWN_CHUNK_RADIUS,
|
|
spawn_coord.z,
|
|
]
|
|
var index_result := await _internal_get_json(index_path)
|
|
if not index_result.ok:
|
|
print("[Server] chunk index fetch failed (%s); falling back to test_level" % index_result.get("detail", "?"))
|
|
return null
|
|
|
|
var world_root: Node3D = _fallback_level_scene.instantiate() as Node3D
|
|
world_root.name = "World"
|
|
var marker: Marker3D = world_root.find_child("PlayerSpawn", true, false) as Marker3D
|
|
if marker == null:
|
|
marker = Marker3D.new()
|
|
marker.name = "PlayerSpawn"
|
|
world_root.add_child(marker)
|
|
marker.position = spawn_pos
|
|
|
|
var entries: Array = (index_result.data as Dictionary).get("chunks", [])
|
|
for entry in entries:
|
|
var cx: int = int(entry["cx"])
|
|
var cy: int = int(entry["cy"])
|
|
var cz: int = int(entry["cz"])
|
|
var chunk_path := "/api/internal/worlds/%s/rooms/%s/chunks/%d/%d/%d" % [world_id, slug, cx, cy, cz]
|
|
var payload_result := await _internal_get_binary(chunk_path)
|
|
if not payload_result.ok:
|
|
printerr("[Server] chunk fetch failed for %d/%d/%d (status %d); skipping" % [cx, cy, cz, payload_result.status])
|
|
continue
|
|
var decoded: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(payload_result.body)
|
|
if decoded == null:
|
|
printerr("[Server] chunk decode failed for %d/%d/%d; skipping" % [cx, cy, cz])
|
|
continue
|
|
_build_chunk_collision(world_root, Vector3i(cx, cy, cz), decoded)
|
|
_chunks_loaded += 1
|
|
|
|
return world_root
|
|
|
|
|
|
func _pick_room(rooms: Array) -> Variant:
|
|
for r in rooms:
|
|
if r.get("slug", "") == MAIN_ROOM_SLUG:
|
|
return r
|
|
if not rooms.is_empty():
|
|
return rooms[0]
|
|
return null
|
|
|
|
|
|
func _spawn_pos_from_room(room: Dictionary) -> Vector3:
|
|
var spawn: Variant = room.get("spawn")
|
|
if spawn is Dictionary and (spawn as Dictionary).has("pos"):
|
|
var p: Array = (spawn as Dictionary)["pos"]
|
|
if p.size() >= 3:
|
|
var pos := Vector3(float(p[0]), float(p[1]), float(p[2]))
|
|
if pos.y < 1.0:
|
|
pos.y = 1.5
|
|
return pos
|
|
return Vector3(0, 1.5, 0)
|
|
|
|
|
|
## Build a StaticBody3D of box collision shapes for one decoded chunk: one
|
|
## box per solid tile (TileDef.solid, world.md §3.2) plus one per static
|
|
## (non-dynamic) object that isn't walkable. This is real collision derived
|
|
## from the actual decoded payload -- there is no client-side precedent to
|
|
## reuse (ChunkScene/TileLayerNode only build visuals; the dedicated server
|
|
## has no renderer and needs physics bodies instead).
|
|
func _build_chunk_collision(parent: Node3D, coord: Vector3i, data: ChunkDataDecoder.ChunkPayload) -> void:
|
|
var body := StaticBody3D.new()
|
|
body.name = "chunk_%d_%d_%d" % [coord.x, coord.y, coord.z]
|
|
var chunk_origin := Vector3(
|
|
coord.x * Chunk.CHUNK_SIZE_X,
|
|
coord.y * Chunk.CHUNK_SIZE_Y,
|
|
coord.z * Chunk.CHUNK_SIZE_Z
|
|
)
|
|
|
|
for layer in data.tile_layers:
|
|
for z in range(layer.depth):
|
|
for y in range(layer.height):
|
|
for x in range(layer.width):
|
|
var tile_id := layer.get_tile(x, y, z)
|
|
if tile_id == 0:
|
|
continue
|
|
var tile_def := layer.get_tile_def(tile_id)
|
|
if tile_def == null or not tile_def.solid:
|
|
continue
|
|
var shape := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = Vector3.ONE
|
|
shape.shape = box
|
|
shape.position = chunk_origin + Vector3(x + 0.5, y + 0.5, z + 0.5)
|
|
body.add_child(shape)
|
|
_collision_shapes_built += 1
|
|
|
|
for obj in data.objects:
|
|
if obj.dynamic:
|
|
continue # dynamic templates are spawnable entities, not static geometry
|
|
if bool(obj.properties.get("walkable", true)):
|
|
continue
|
|
var shape := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = obj.scale
|
|
shape.shape = box
|
|
shape.position = obj.position
|
|
body.add_child(shape)
|
|
_collision_shapes_built += 1
|
|
|
|
if body.get_child_count() > 0:
|
|
parent.add_child(body)
|
|
|
|
|
|
## Minimal internal-API HTTP GET, bearer-authenticated with --server-token
|
|
## (world_persistence_impl.md §7.3). Deliberately separate from ApiClient:
|
|
## that autoload's `request()` always decodes the response body as
|
|
## UTF-8/JSON, which corrupts binary chunk payloads, and its auth model is
|
|
## the player's JWT, not this process's per-spawn server token.
|
|
func _internal_get_json(path: String) -> Dictionary:
|
|
var raw := await _internal_get(path, "application/json")
|
|
if not raw.ok:
|
|
return {"ok": false, "status": raw.status, "detail": raw.get("detail", ""), "data": null}
|
|
var parsed: Variant = JSON.parse_string(raw.body.get_string_from_utf8())
|
|
if not (parsed is Dictionary):
|
|
return {"ok": false, "status": raw.status, "detail": "invalid JSON response", "data": null}
|
|
return {"ok": true, "status": raw.status, "detail": "", "data": parsed}
|
|
|
|
|
|
func _internal_get_binary(path: String) -> Dictionary:
|
|
return await _internal_get(path, "application/octet-stream")
|
|
|
|
|
|
func _internal_get(path: String, accept: String) -> Dictionary:
|
|
var http := HTTPRequest.new()
|
|
http.timeout = CONTENT_FETCH_TIMEOUT
|
|
add_child(http)
|
|
|
|
var headers := PackedStringArray()
|
|
headers.append("Accept: " + accept)
|
|
headers.append("Authorization: Bearer " + server_token)
|
|
|
|
var url := api_url + path
|
|
var err := http.request(url, headers, HTTPClient.METHOD_GET)
|
|
if err != OK:
|
|
http.queue_free()
|
|
return {"ok": false, "status": 0, "detail": "request() failed to start (%d)" % err, "body": PackedByteArray()}
|
|
|
|
var args: Array = await http.request_completed
|
|
var result_code: int = args[0]
|
|
var response_code: int = args[1]
|
|
var body: PackedByteArray = args[3]
|
|
http.queue_free()
|
|
|
|
if result_code != HTTPRequest.RESULT_SUCCESS:
|
|
return {"ok": false, "status": 0, "detail": "transport error (%d)" % result_code, "body": PackedByteArray()}
|
|
if response_code < 200 or response_code >= 300:
|
|
return {"ok": false, "status": response_code, "detail": "HTTP %d" % response_code, "body": body}
|
|
return {"ok": true, "status": response_code, "detail": "", "body": body}
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
if not is_running:
|
|
return
|
|
tick += 1
|
|
if tick % PHYSICS_HZ == 0:
|
|
_sweep_stale_players()
|
|
_broadcast_snapshot()
|
|
if tick % STATUS_WRITE_EVERY == 0:
|
|
_write_status()
|
|
|
|
|
|
## Backstop for _on_peer_disconnected: once a second, drop any player whose
|
|
## peer is no longer connected (covers a missed disconnect signal, so a
|
|
## rejoining client never sees a frozen ghost of its old self).
|
|
func _sweep_stale_players() -> void:
|
|
var connected := multiplayer.get_peers()
|
|
for peer_id in players.keys():
|
|
if peer_id not in connected:
|
|
var p: Node = players[peer_id]
|
|
if is_instance_valid(p):
|
|
p.queue_free()
|
|
players.erase(peer_id)
|
|
print("[Server] swept stale player %d (%d left)" % [peer_id, players.size()])
|
|
|
|
|
|
func _broadcast_snapshot() -> void:
|
|
var snap := {"tick": tick, "players": {}}
|
|
for peer_id in players:
|
|
var player: Node = players[peer_id]
|
|
if is_instance_valid(player):
|
|
var s: Dictionary = player.get_server_state()
|
|
s["last_seq"] = player.last_applied_seq
|
|
snap["players"][peer_id] = s
|
|
for pid in multiplayer.get_peers():
|
|
if pid == 1:
|
|
continue
|
|
rpc_id(pid, "_receive_snapshot", snap)
|
|
|
|
|
|
# Client-side receiver lives on the NetworkClient's proxy at the same path.
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _receive_snapshot(_snapshot_data: Dictionary) -> void:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connection lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_peer_connected(peer_id: int) -> void:
|
|
if players.size() >= MAX_PLAYERS:
|
|
print("[Server] full; rejecting peer %d" % peer_id)
|
|
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
|
return
|
|
var player: Node3D = _player_scene.instantiate()
|
|
player.name = "Player_%d" % peer_id
|
|
player.peer_id = peer_id
|
|
add_child(player)
|
|
var spawn := _spawn_for(_join_count)
|
|
_join_count += 1
|
|
player.global_position = spawn
|
|
player.spawn_position = spawn
|
|
players[peer_id] = player
|
|
print("[Server] peer %d joined (%d players)" % [peer_id, players.size()])
|
|
_write_status()
|
|
|
|
|
|
func _on_peer_disconnected(peer_id: int) -> void:
|
|
if players.has(peer_id):
|
|
var player: Node = players[peer_id]
|
|
if is_instance_valid(player):
|
|
player.queue_free()
|
|
players.erase(peer_id)
|
|
print("[Server] peer %d left (%d players)" % [peer_id, players.size()])
|
|
_write_status()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status file for the supervisor's idle reaper + readiness wait
|
|
# (world_persistence_impl.md §7.4 / game_servers.py's _await_ready)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _write_status() -> void:
|
|
if status_file_path.is_empty():
|
|
return
|
|
var f := FileAccess.open(status_file_path, FileAccess.WRITE)
|
|
if f:
|
|
f.store_string(JSON.stringify({
|
|
"world_id": world_id,
|
|
"port": port,
|
|
"players": players.size(),
|
|
"tick": tick,
|
|
"updated": Time.get_unix_time_from_system(),
|
|
# game_servers.py's _await_ready() polls for this -- only ever
|
|
# written true, and only once content has been decided (loaded
|
|
# or fallen back) AND create_server() has already succeeded.
|
|
"ready": is_running,
|
|
"content": _content_status,
|
|
"chunks_loaded": _chunks_loaded,
|
|
"collision_shapes": _collision_shapes_built,
|
|
}))
|
|
f.close()
|