Wire client chunk streaming + dedicated-server content load to the backend
Implements the client-side half of docs/architecture/world_persistence_impl.md
(the backend API in backend/app/routers/{world_content,internal}.py and
game_servers.py's --api-url/--server-token/_await_ready were already merged):
- scripts/http_chunk_source.gd: new HttpChunkSource adapter satisfying
ChunkStreamManager.set_server_rpc()'s seam. Fetches chunks over HTTP from
the public world_content API (JWT via ApiClient), with ETag/If-None-Match
(304) support backed by a user://chunk_cache/<checksum> cache, and a
bounds-query list_chunks() helper (§8.1's chunk index). A 404 (unwritten
coordinate) renders empty instead of erroring, per §8.1.
- scripts/server.gd: startup now parses --api-url/--server-token, and
before binding the ENet port fetches the world's manifest + the
spawn-neighbourhood chunks from /api/internal (§7.2/§7.3/§8.3), decodes
them, and builds real StaticBody3D collision from solid tiles + non-
walkable objects. Falls back to test_level.tscn if the backend is
unreachable, the world has no rooms, or the room has zero chunks -- never
hangs or crashes. Writes "ready"/"content" (plus diagnostic
chunks_loaded/collision_shapes counts) into the status file that
game_servers.py's _await_ready() already reads.
- scripts/api_client.gd: exposes a `base_url` getter so other client code
building its own requests (HttpChunkSource; server.gd doesn't use this
autoload at all, since its auth model is the per-spawn server token, not
a player JWT) can target the same backend.
Also fixes three real, previously-dormant bugs this work's first real
exercise of these code paths uncovered (nothing before this called
encode_binary() or fed real data through set_server_rpc()):
- chunk_data_decoder.gd: `_build_metadata_section` never wrapped its
output with the section id/length header every other section builder
uses, so `encode_binary()` produced corrupt payloads the decoder read as
a single garbage-length section ("truncated section body").
- chunk_stream_manager.gd: `_parse_coord_from_key` assigned a
PackedStringArray (String.split()'s real return type) to an
Array[String]-typed variable -- a hard crash.
- chunk_stream_manager.gd: `_instantiate_chunk_scene_with_data` read a
nonexistent `data.format_version` field; ChunkPayload's field is
`version`.
Adds scripts/smoke_test_http_chunk_source.gd, a real end-to-end smoke test
(register, create world, PUT a real chunk, stream it back through
ChunkStreamManager + HttpChunkSource, plus the unwritten-coordinate and
304-refetch paths).
Verified against a real backend + Postgres:
- Seeded real chunk content, joined through the real
GameServerSupervisor spawn path: status file reports
content=loaded, chunks_loaded=1, collision_shapes=33.
- A room with zero chunks and an unreachable backend (closed port) both
fall back to test_level.tscn cleanly (content=fallback, ready=true, no
hang/crash).
- Full existing Godot smoke suite (16 suites, including
smoke_test_enter_world.gd and smoke_test_network_client.gd) passes with
no regressions.
- Backend pytest (132/132) untouched and still green -- no backend/
changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
This commit is contained in:
+260
-7
@@ -9,9 +9,26 @@
|
||||
## 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
|
||||
## --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.
|
||||
## 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
|
||||
@@ -21,11 +38,40 @@ const PHYSICS_HZ := 30
|
||||
## 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
|
||||
@@ -35,7 +81,7 @@ var is_running: bool = false
|
||||
var _join_count: int = 0
|
||||
|
||||
var _player_scene: PackedScene = preload("res://scenes/server_player.tscn")
|
||||
var _level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn")
|
||||
var _fallback_level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn")
|
||||
var _spawn_point: Vector3 = Vector3(0, 1.5, 0)
|
||||
|
||||
|
||||
@@ -48,8 +94,16 @@ func _ready() -> void:
|
||||
_parse_args()
|
||||
Engine.physics_ticks_per_second = PHYSICS_HZ
|
||||
|
||||
# Load the world's level so players have real ground + collision.
|
||||
var level := _level_scene.instantiate()
|
||||
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"
|
||||
else:
|
||||
_content_status = "loaded"
|
||||
|
||||
level.name = "World"
|
||||
add_child(level)
|
||||
var marker := level.find_child("PlayerSpawn", true, false)
|
||||
@@ -65,7 +119,9 @@ func _ready() -> void:
|
||||
multiplayer.multiplayer_peer = network_peer
|
||||
is_running = true
|
||||
_write_status()
|
||||
print("[Server] world=%s listening on ENet %d (%d Hz)" % [world_id, port, PHYSICS_HZ])
|
||||
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-
|
||||
@@ -95,9 +151,198 @@ func _parse_args() -> void:
|
||||
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.new()
|
||||
world_root.name = "World"
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "PlayerSpawn"
|
||||
marker.position = spawn_pos
|
||||
world_root.add_child(marker)
|
||||
|
||||
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:
|
||||
return Vector3(float(p[0]), float(p[1]), float(p[2]))
|
||||
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
|
||||
@@ -176,7 +421,8 @@ func _on_peer_disconnected(peer_id: int) -> void:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status file for the supervisor's idle reaper
|
||||
# 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:
|
||||
@@ -190,5 +436,12 @@ func _write_status() -> void:
|
||||
"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()
|
||||
|
||||
Reference in New Issue
Block a user