Files
modbison/scripts/smoke_test_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

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)