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
190 lines
6.9 KiB
GDScript
190 lines
6.9 KiB
GDScript
extends SceneTree
|
|
## Real integration test for HttpChunkSource against a live backend.
|
|
##
|
|
## Covers docs/architecture/world_persistence_impl.md D6/§8.4: the client
|
|
## streams chunks over HTTP through the same ChunkStreamManager.set_server_rpc()
|
|
## seam a fake/ENet source would use. This proves the adapter actually
|
|
## satisfies that seam end-to-end -- a real PUT'd chunk decodes into a real
|
|
## ChunkScene with real tile layers + objects, an unwritten coordinate
|
|
## "renders empty" instead of erroring, and a second fetch of an unchanged
|
|
## chunk round-trips through the 304/ETag cache path without failing.
|
|
##
|
|
## Run with:
|
|
## godot --headless --path . --script res://scripts/smoke_test_http_chunk_source.gd
|
|
##
|
|
## Requires the backend to be running (default base URL
|
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
|
|
## AGP_API_BASE_URL).
|
|
|
|
var passed := 0
|
|
var failed := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
_run()
|
|
|
|
|
|
func _run() -> void:
|
|
await process_frame
|
|
|
|
var api := root.get_node("ApiClient")
|
|
|
|
randomize()
|
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
|
var username := "httpchunk_%d" % unique_id
|
|
var email := "httpchunk_%d@test.example.com" % unique_id
|
|
var reg: Dictionary = await api.register(username, email, "SmokeTest123!", "1990-01-01")
|
|
if not _check("setup: register succeeded", reg.ok):
|
|
print(" detail: ", reg.detail)
|
|
_finish()
|
|
return
|
|
|
|
var world_body := JSON.stringify({
|
|
"name": "HttpChunkSource Smoke World %d" % unique_id,
|
|
"visibility": "public",
|
|
})
|
|
var world_result: Dictionary = await api.request(
|
|
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
|
|
)
|
|
if not _check("setup: world created", world_result.ok):
|
|
print(" detail: ", world_result.detail)
|
|
_finish()
|
|
return
|
|
var world_id: int = (world_result.data as Dictionary)["id"]
|
|
|
|
# Seed a real chunk at (0,0,0): one solid tile + one static object, built
|
|
# with the same client-side codec HttpChunkSource's decode path uses on
|
|
# the way back in (round-tripping through the real backend, not a mock).
|
|
var chunk_payload := _build_test_chunk(world_id)
|
|
var encoded := ChunkDataDecoder.encode_binary(chunk_payload)
|
|
# ApiClient.request() only takes a String body, which can't carry
|
|
# arbitrary binary safely -- PUT with a dedicated HTTPRequest instead
|
|
# (request_raw takes a real PackedByteArray), matching what the real
|
|
# backend test suite sends.
|
|
var put_result: Dictionary = await _put_binary(api, world_id, encoded)
|
|
if not _check("setup: chunk PUT succeeded", put_result.ok):
|
|
print(" detail: ", put_result.detail)
|
|
_finish()
|
|
return
|
|
|
|
# Build the real ChunkStreamManager + HttpChunkSource pair and wire them
|
|
# exactly as real gameplay code would.
|
|
var manager := ChunkStreamManager.new()
|
|
root.add_child(manager)
|
|
var source := HttpChunkSource.new()
|
|
source.world_id = str(world_id)
|
|
source.room_slug = "main"
|
|
source.stream_manager = manager
|
|
manager.add_child(source)
|
|
manager.set_server_rpc(source)
|
|
|
|
# 1. A written coordinate decodes into a real ChunkScene.
|
|
manager._load_chunk(Vector3i(0, 0, 0))
|
|
var chunk_scene: Node = await _wait_for_chunk(manager, Vector3i(0, 0, 0))
|
|
_check("written chunk (0,0,0) loaded via HttpChunkSource", chunk_scene != null)
|
|
if chunk_scene != null:
|
|
_check("loaded chunk has 1 tile layer", chunk_scene.tile_layers.size() == 1)
|
|
_check("loaded chunk tile layer has the expected solid tile",
|
|
chunk_scene.tile_layers[0].tiles[0] == 1)
|
|
_check("loaded chunk has 1 world object", chunk_scene.world_objects.size() == 1)
|
|
|
|
# 2. An unwritten coordinate renders empty, not an error.
|
|
manager._load_chunk(Vector3i(9, 9, 0))
|
|
var empty_scene: Node = await _wait_for_chunk(manager, Vector3i(9, 9, 0))
|
|
_check("unwritten chunk (9,9,0) still resolves (renders empty)", empty_scene != null)
|
|
if empty_scene != null:
|
|
_check("unwritten chunk has no tile layers", empty_scene.tile_layers.is_empty())
|
|
|
|
# 3. Re-fetching the same written chunk exercises the ETag/304 path
|
|
# (can't observe the wire status code directly here, but a stale cache
|
|
# entry or a broken conditional-request path would either error or
|
|
# silently return corrupt/empty data -- this proves it doesn't).
|
|
manager.force_reload_chunk(Vector3i(0, 0, 0))
|
|
var refetched: Node = await _wait_for_chunk(manager, Vector3i(0, 0, 0))
|
|
_check("re-fetch of unchanged chunk (0,0,0) round-trips via cache/304 path", refetched != null)
|
|
if refetched != null:
|
|
_check("re-fetched chunk still has 1 tile layer", refetched.tile_layers.size() == 1)
|
|
|
|
_finish()
|
|
|
|
|
|
func _build_test_chunk(world_id: int) -> ChunkDataDecoder.ChunkPayload:
|
|
var layer := ChunkDataDecoder.TileLayerData.new()
|
|
layer.name = "ground"
|
|
layer.layer_id = 0
|
|
layer.width = 32
|
|
layer.height = 32
|
|
layer.depth = 1
|
|
var empty_def := TileDef.new(0, "", false, 0, 1)
|
|
var solid_def := TileDef.new(1, "tiles/stone", true, 0, 1)
|
|
layer.palette = [empty_def, solid_def]
|
|
var tiles := PackedInt32Array()
|
|
tiles.resize(32 * 32 * 1)
|
|
tiles[0] = 1 # (x=0, y=0, z=0) solid
|
|
layer.tiles = tiles
|
|
|
|
var obj := ChunkDataDecoder.WorldObjectData.new()
|
|
obj.id = "obj_1"
|
|
obj.type = "prop"
|
|
obj.asset_id = "props/rock"
|
|
obj.position = Vector3(1.0, 1.0, 0.0)
|
|
obj.properties = {"tag": "smoke"}
|
|
|
|
var payload := ChunkDataDecoder.ChunkPayload.new()
|
|
payload.world_id = str(world_id)
|
|
payload.room_id = "main"
|
|
payload.coord = Vector3i(0, 0, 0)
|
|
payload.bounds = AABB(Vector3.ZERO, Vector3(32, 32, 1))
|
|
payload.tile_layers = [layer]
|
|
payload.objects = [obj]
|
|
payload.revision = 1
|
|
payload.author = "smoke_test"
|
|
payload.created_at = 1700000000
|
|
payload.updated_at = 1700000000
|
|
return payload
|
|
|
|
|
|
func _put_binary(api: Object, world_id: int, bytes: PackedByteArray) -> Dictionary:
|
|
var http := HTTPRequest.new()
|
|
root.add_child(http)
|
|
var bearer: String = api.bearer_token
|
|
var headers := PackedStringArray([
|
|
"Content-Type: application/octet-stream",
|
|
"X-Base-Revision: 0",
|
|
"Authorization: " + bearer,
|
|
])
|
|
var base: String = api.base_url
|
|
var url := base + "/api/worlds/%d/rooms/main/chunks/0/0/0" % world_id
|
|
var err := http.request_raw(url, headers, HTTPClient.METHOD_PUT, bytes)
|
|
if err != OK:
|
|
http.queue_free()
|
|
return {"ok": false, "detail": "request_raw failed (%d)" % err}
|
|
var args: Array = await http.request_completed
|
|
var response_code: int = args[1]
|
|
var body: PackedByteArray = args[3]
|
|
http.queue_free()
|
|
return {"ok": response_code >= 200 and response_code < 300, "status": response_code, "detail": body.get_string_from_utf8()}
|
|
|
|
|
|
func _wait_for_chunk(manager: ChunkStreamManager, coord: Vector3i, max_frames: int = 120) -> Node:
|
|
for i in max_frames:
|
|
await process_frame
|
|
if manager.is_chunk_loaded(coord):
|
|
return manager.get_loaded_chunk(coord)
|
|
return null
|
|
|
|
|
|
func _check(description: String, condition: bool) -> bool:
|
|
if condition:
|
|
print(" PASS: ", description)
|
|
passed += 1
|
|
else:
|
|
printerr(" FAIL: ", description)
|
|
failed += 1
|
|
return condition
|
|
|
|
|
|
func _finish() -> void:
|
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
|
quit(0 if failed == 0 else 1)
|