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