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)