Files
modbison/scripts/http_chunk_source.gd
T
daddy fd88cef32f 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
2026-09-14 22:54:56 +00:00

204 lines
7.4 KiB
GDScript

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