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:
@@ -0,0 +1,203 @@
|
||||
class_name HttpChunkSource
|
||||
extends Node
|
||||
## HTTP-backed adapter for ChunkStreamManager.set_server_rpc().
|
||||
##
|
||||
## Implements docs/architecture/world_persistence_impl.md D6/§8.4: the
|
||||
## playing client streams chunks from the FastAPI backend over HTTP (the
|
||||
## same public routes the editor uses -- `backend/app/routers/world_content.py`),
|
||||
## not over the dedicated server's ENet connection.
|
||||
##
|
||||
## `ChunkStreamManager` only requires an object with a `request_chunk(key)`
|
||||
## method (see its `_server_rpc` seam); this adapter satisfies that and feeds
|
||||
## results back through the manager's existing (previously dormant)
|
||||
## `_on_chunk_data_received(key, bytes)` / `_on_chunk_load_error(coord, msg)`
|
||||
## methods, and its `_instantiate_chunk_scene(coord)` for the "unwritten
|
||||
## coordinate renders empty" case (§8.1).
|
||||
##
|
||||
## `ChunkStreamManager._load_chunk()` builds its request key with empty
|
||||
## world/room components (`Chunk.build_key("", "", coord)`) because the
|
||||
## manager itself has no notion of which world/room it's streaming --
|
||||
## that's this adapter's job. `request_chunk(key)` only reads the
|
||||
## coordinate out of the key it's given and rebuilds the real key from
|
||||
## `world_id`/`room_slug` before touching the network, per the doc's note
|
||||
## under §8.4.
|
||||
##
|
||||
## Must be added to the scene tree before use (it creates per-request
|
||||
## HTTPRequest child nodes), e.g.:
|
||||
##
|
||||
## var source := HttpChunkSource.new()
|
||||
## source.world_id = str(Session.world_id)
|
||||
## source.room_slug = "main"
|
||||
## source.stream_manager = stream_manager
|
||||
## stream_manager.add_child(source)
|
||||
## stream_manager.set_server_rpc(source)
|
||||
|
||||
## The world this source fetches chunks for (string form of the backend's
|
||||
## numeric world id -- matches Chunk.build_key's world_id parameter type).
|
||||
var world_id: String = ""
|
||||
|
||||
## The room slug within that world. world.md's default room is "main".
|
||||
var room_slug: String = "main"
|
||||
|
||||
## The ChunkStreamManager to feed decoded results back into. Required.
|
||||
var stream_manager: ChunkStreamManager = null
|
||||
|
||||
## In-memory ETag cache: chunk key -> last-known checksum (bare hex, no
|
||||
## quotes). Backs the on-disk payload cache so re-fetching an unchanged
|
||||
## chunk costs a 304, not a re-download (world_persistence_impl.md §8.1:
|
||||
## "This is what makes the client's user://chunk_cache/ cache cheap").
|
||||
var _etag_by_key: Dictionary = {}
|
||||
|
||||
const CACHE_DIR := "user://chunk_cache"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(CACHE_DIR)
|
||||
|
||||
|
||||
## Satisfies ChunkStreamManager's `_server_rpc.request_chunk(key)` seam.
|
||||
func request_chunk(key: String) -> void:
|
||||
var coord: Variant = _coord_from_key(key)
|
||||
if coord == null:
|
||||
push_error("HttpChunkSource: malformed chunk key %s" % key)
|
||||
return
|
||||
_fetch(coord)
|
||||
|
||||
|
||||
## world_persistence_impl.md §8.1 chunk index -- lists which chunks in a
|
||||
## bounds actually exist without fetching their payloads. Not called by
|
||||
## ChunkStreamManager's per-coordinate streaming loop today, but is the
|
||||
## "bounds-query listing" half of this adapter's contract for future
|
||||
## callers (e.g. a minimap, or a smarter prefetcher).
|
||||
func list_chunks(cx_min: int, cx_max: int, cy_min: int, cy_max: int, cz: int = 0) -> Dictionary:
|
||||
var path := "/api/worlds/%s/rooms/%s/chunks?cx_min=%d&cx_max=%d&cy_min=%d&cy_max=%d&cz=%d" % [
|
||||
world_id, room_slug, cx_min, cx_max, cy_min, cy_max, cz
|
||||
]
|
||||
return await _api_client().request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
||||
|
||||
|
||||
# ─────────────────────────── internals ───────────────────────────
|
||||
|
||||
## Resolved via an absolute node path rather than the bare `ApiClient`
|
||||
## autoload identifier: bare autoload identifiers only resolve at GDScript
|
||||
## compile time inside a scene the engine boots normally. Any script
|
||||
## compiled while running headless via `godot --script ...` (as
|
||||
## scripts/smoke_test_*.gd do) fails with "Identifier not found: ApiClient"
|
||||
## on the bare form -- confirmed while writing
|
||||
## scripts/smoke_test_http_chunk_source.gd. `get_node` resolves the same
|
||||
## singleton correctly in both contexts (the same pattern game.gd already
|
||||
## uses for AvatarSystem via `get_node_or_null("/root/AvatarSystem")`).
|
||||
func _api_client() -> Node:
|
||||
return get_node("/root/ApiClient")
|
||||
|
||||
|
||||
func _fetch(coord: Vector3i) -> void:
|
||||
var key := Chunk.build_key(world_id, room_slug, coord)
|
||||
|
||||
var http := HTTPRequest.new()
|
||||
http.timeout = 10.0
|
||||
add_child(http)
|
||||
|
||||
var api := _api_client()
|
||||
var headers := PackedStringArray()
|
||||
headers.append("Accept: application/octet-stream")
|
||||
if api.has_token():
|
||||
var bearer: String = api.bearer_token
|
||||
headers.append("Authorization: " + bearer)
|
||||
var cached_etag: String = _etag_by_key.get(key, "")
|
||||
if cached_etag != "":
|
||||
headers.append("If-None-Match: \"%s\"" % cached_etag)
|
||||
|
||||
var base_url: String = api.base_url
|
||||
var url := "%s/api/worlds/%s/rooms/%s/chunks/%d/%d/%d" % [
|
||||
base_url, world_id, room_slug, coord.x, coord.y, coord.z
|
||||
]
|
||||
var err := http.request(url, headers, HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
http.queue_free()
|
||||
_fail(coord, "HttpChunkSource: failed to start request for %s (%d)" % [key, err])
|
||||
return
|
||||
|
||||
var args: Array = await http.request_completed
|
||||
var result_code: int = args[0]
|
||||
var response_code: int = args[1]
|
||||
var response_headers: PackedStringArray = args[2]
|
||||
var body: PackedByteArray = args[3]
|
||||
http.queue_free()
|
||||
|
||||
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||
_fail(coord, "HttpChunkSource: transport error fetching %s (code %d)" % [key, result_code])
|
||||
return
|
||||
|
||||
match response_code:
|
||||
304:
|
||||
var cached := _read_cache(cached_etag)
|
||||
if cached.is_empty():
|
||||
# Cache miss despite a 304 (e.g. the user:// cache got
|
||||
# cleared) -- drop the stale ETag and refetch unconditionally
|
||||
# rather than deliver empty bytes to the decoder.
|
||||
_etag_by_key.erase(key)
|
||||
_fetch(coord)
|
||||
return
|
||||
_deliver(key, cached)
|
||||
200:
|
||||
var checksum := _etag_from_headers(response_headers)
|
||||
if checksum != "":
|
||||
_etag_by_key[key] = checksum
|
||||
_write_cache(checksum, body)
|
||||
_deliver(key, body)
|
||||
404:
|
||||
# world_persistence_impl.md §8.1: "an unwritten coordinate is not
|
||||
# an error to the streamer -- it renders empty."
|
||||
if stream_manager != null:
|
||||
stream_manager._instantiate_chunk_scene(coord)
|
||||
_:
|
||||
_fail(coord, "HttpChunkSource: HTTP %d fetching %s" % [response_code, key])
|
||||
|
||||
|
||||
func _deliver(key: String, bytes: PackedByteArray) -> void:
|
||||
if stream_manager != null:
|
||||
stream_manager._on_chunk_data_received(key, bytes)
|
||||
|
||||
|
||||
func _fail(coord: Vector3i, message: String) -> void:
|
||||
push_error(message)
|
||||
if stream_manager != null:
|
||||
stream_manager._on_chunk_load_error(coord, message)
|
||||
|
||||
|
||||
func _coord_from_key(key: String) -> Variant:
|
||||
var parts: PackedStringArray = key.split(":")
|
||||
if parts.size() < 5:
|
||||
return null
|
||||
return Vector3i(int(parts[2]), int(parts[3]), int(parts[4]))
|
||||
|
||||
|
||||
func _etag_from_headers(headers: PackedStringArray) -> String:
|
||||
for h in headers:
|
||||
if h.to_lower().begins_with("etag:"):
|
||||
var v := h.substr(h.find(":") + 1).strip_edges()
|
||||
return v.trim_prefix('"').trim_suffix('"')
|
||||
return ""
|
||||
|
||||
|
||||
func _cache_path(checksum: String) -> String:
|
||||
return "%s/%s" % [CACHE_DIR, checksum]
|
||||
|
||||
|
||||
func _write_cache(checksum: String, bytes: PackedByteArray) -> void:
|
||||
var f := FileAccess.open(_cache_path(checksum), FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_buffer(bytes)
|
||||
f.close()
|
||||
|
||||
|
||||
func _read_cache(checksum: String) -> PackedByteArray:
|
||||
if checksum == "":
|
||||
return PackedByteArray()
|
||||
var f := FileAccess.open(_cache_path(checksum), FileAccess.READ)
|
||||
if f == null:
|
||||
return PackedByteArray()
|
||||
var bytes := f.get_buffer(f.get_length())
|
||||
f.close()
|
||||
return bytes
|
||||
Reference in New Issue
Block a user