fd88cef32f
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
204 lines
7.4 KiB
GDScript
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
|