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
327 lines
11 KiB
GDScript
327 lines
11 KiB
GDScript
extends Node
|
|
## Central HTTP client singleton (autoload name: "ApiClient").
|
|
##
|
|
## Every network call in the game flows through this class. Callers use the
|
|
## high-level helpers ([method login], [method register], [method get_worlds],
|
|
## [method health_check], [method get_me]) or the generic [method request]
|
|
## primitive. All results conform to the result-dictionary contract below.
|
|
##
|
|
## [b]Result Dictionary Contract[/b]
|
|
## Every public method returns (after `await`) a Dictionary with this shape:
|
|
##
|
|
## {
|
|
## "ok": bool, # true for HTTP 2xx, false otherwise
|
|
## "status": int, # HTTP status code (0 on network failure)
|
|
## "data": Variant, # parsed JSON body on success (null otherwise)
|
|
## "detail": String, # human-readable message
|
|
## }
|
|
##
|
|
## Network failures (timeout / DNS / refused connection) return `status == 0`
|
|
## with a `detail` describing the transport failure. Non-2xx responses return
|
|
## the real HTTP status and the backend's `detail` field from the JSON error
|
|
## body when present. No caller can crash on a failed request.
|
|
##
|
|
## [b]Token storage[/b]
|
|
## JWTs are kept in memory. [method set_token] is the single seam for future
|
|
## disk persistence (override it to save to `user://`).
|
|
##
|
|
## [b]Usage[/b]
|
|
## var result: Dictionary = await ApiClient.login("alice", "secret")
|
|
## if result.ok:
|
|
## print("Logged in!")
|
|
## else:
|
|
## print("Error: ", result.detail)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token storage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _access_token: String = ""
|
|
var _token_type: String = "Bearer"
|
|
|
|
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
|
|
var bearer_token: String:
|
|
get:
|
|
return _token_type + " " + _access_token if _access_token != "" else ""
|
|
|
|
|
|
## Whether an access token is currently stored.
|
|
func has_token() -> bool:
|
|
return _access_token != ""
|
|
|
|
|
|
## Set the access token (called after login / register / token refresh).
|
|
## This is the persistence seam: override to also write to disk.
|
|
func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
|
_access_token = access_token
|
|
_token_type = token_type
|
|
|
|
|
|
## Clear stored tokens.
|
|
func clear_token() -> void:
|
|
_access_token = ""
|
|
_token_type = "Bearer"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _base_url: String = "http://127.0.0.1:8000"
|
|
|
|
## Read-only access to the configured backend base URL (no trailing slash).
|
|
## Lets other client-side code that needs to build its own request outside
|
|
## the [method request] primitive -- e.g. [HttpChunkSource], which needs raw
|
|
## response bytes and custom headers `request()`'s JSON-only contract can't
|
|
## give it -- target the same backend without duplicating the env var /
|
|
## ProjectSettings resolution logic in `_ready()`.
|
|
var base_url: String:
|
|
get:
|
|
return _base_url
|
|
|
|
## Request timeout in seconds. 0 disables the timeout.
|
|
var timeout_seconds: float = 10.0
|
|
|
|
|
|
func _ready() -> void:
|
|
# Environment variable override (e.g. for headless smoke tests against a
|
|
# backend on a non-default port). Falls back to the ProjectSetting, then
|
|
# to the documented default.
|
|
var env_url := OS.get_environment("AGP_API_BASE_URL")
|
|
if env_url != "":
|
|
_base_url = env_url.rstrip("/")
|
|
else:
|
|
_base_url = str(ProjectSettings.get_setting(
|
|
"application/agp/api_base_url",
|
|
"http://127.0.0.1:8000"
|
|
)).rstrip("/")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core request primitive
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Generic request primitive. Awaitable via `await`.
|
|
##
|
|
## Uses a dedicated HTTPRequest node per call so concurrent requests can never
|
|
## cross-talk, and so the result maps 1:1 to the awaiting caller.
|
|
##
|
|
## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE).
|
|
## @param path URL path, e.g. "/api/health".
|
|
## @param body Request body string (JSON or form-encoded).
|
|
## @param content_type "application/json" or "application/x-www-form-urlencoded".
|
|
## @param require_auth If true, attach the stored bearer token when present.
|
|
## @return Result dictionary conforming to the contract.
|
|
func request(
|
|
method: HTTPClient.Method,
|
|
path: String,
|
|
body: String = "",
|
|
content_type: String = "application/json",
|
|
require_auth: bool = false
|
|
) -> Dictionary:
|
|
var http := HTTPRequest.new()
|
|
http.timeout = timeout_seconds
|
|
http.max_redirects = 8
|
|
http.accept_gzip = true
|
|
add_child(http)
|
|
|
|
var headers := PackedStringArray()
|
|
headers.append("Accept: application/json")
|
|
if content_type != "":
|
|
headers.append("Content-Type: " + content_type)
|
|
if require_auth and has_token():
|
|
headers.append("Authorization: " + bearer_token)
|
|
|
|
var url := _base_url + path
|
|
var err := http.request(url, headers, method, body)
|
|
if err != OK:
|
|
http.queue_free()
|
|
return _make_result(false, 0, null, "Failed to start request: " + _error_string(err))
|
|
|
|
# request_completed emits (result, response_code, headers, body).
|
|
var args: Array = await http.request_completed
|
|
var result_code: int = args[0]
|
|
var response_code: int = args[1]
|
|
var response_body: PackedByteArray = args[3]
|
|
http.queue_free()
|
|
|
|
# Transport-level failure (timeout / DNS / refused / TLS, etc.).
|
|
if result_code != HTTPRequest.RESULT_SUCCESS:
|
|
return _make_result(false, 0, null, _transport_error_detail(result_code))
|
|
|
|
# Server accepted the connection but returned no HTTP status line.
|
|
if response_code == 0:
|
|
return _make_result(false, 0, null, "No response received from server.")
|
|
|
|
var json_string := response_body.get_string_from_utf8()
|
|
var ok := response_code >= 200 and response_code < 300
|
|
|
|
if ok:
|
|
var data: Variant = _parse_json(json_string)
|
|
return _make_result(true, response_code, data, "OK")
|
|
|
|
# Non-2xx: surface the backend's "detail" message when available.
|
|
var detail := "HTTP error %d" % response_code
|
|
var parsed: Variant = _parse_json(json_string)
|
|
if parsed is Dictionary and (parsed as Dictionary).has("detail"):
|
|
detail = str((parsed as Dictionary)["detail"])
|
|
return _make_result(false, response_code, null, detail)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# High-level helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Log in with username/password (OAuth2PasswordRequestForm).
|
|
## On success stores the returned access token.
|
|
func login(username: String, password: String) -> Dictionary:
|
|
var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode()
|
|
var result := await request(
|
|
HTTPClient.METHOD_POST,
|
|
"/api/auth/login",
|
|
form,
|
|
"application/x-www-form-urlencoded",
|
|
false
|
|
)
|
|
_store_token_from_result(result)
|
|
return result
|
|
|
|
|
|
## Register a new user. On success stores the returned access token.
|
|
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
|
|
## and rejects under-18s (403).
|
|
func register(
|
|
username: String,
|
|
email: String,
|
|
password: String,
|
|
date_of_birth: String
|
|
) -> Dictionary:
|
|
var payload := {
|
|
"username": username,
|
|
"email": email,
|
|
"password": password,
|
|
"date_of_birth": date_of_birth,
|
|
}
|
|
var result := await request(
|
|
HTTPClient.METHOD_POST,
|
|
"/api/auth/register",
|
|
JSON.stringify(payload),
|
|
"application/json",
|
|
false
|
|
)
|
|
_store_token_from_result(result)
|
|
return result
|
|
|
|
|
|
## List worlds visible to the current user (requires auth).
|
|
## @param visibility Optional filter: "public", "private", "friends_only".
|
|
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
|
var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit]
|
|
if visibility != "":
|
|
path += "&visibility=" + visibility.uri_encode()
|
|
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
|
|
|
|
|
|
## Ping the backend health endpoint (no auth).
|
|
func health_check() -> Dictionary:
|
|
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
|
|
|
|
|
## Fetch the current user's profile from the stored JWT (requires auth).
|
|
func get_me() -> Dictionary:
|
|
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
|
|
|
|
|
|
## Fetch the current user's saved avatar loadout (requires auth).
|
|
## On success, result.data is {"data": <loadout dict>, "is_default": bool} --
|
|
## `is_default` true means the user has never customised and this is the
|
|
## server default.
|
|
func get_avatar_loadout() -> Dictionary:
|
|
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
|
|
|
|
|
|
## Ask the backend for a dedicated server to host [param world_id] and return
|
|
## its address. On success result.data is {"world_id", "host", "port"}.
|
|
## 503 if this backend has no game-server orchestration.
|
|
func join_world(world_id: int) -> Dictionary:
|
|
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
|
|
|
|
|
|
## Save (replace) the current user's avatar loadout (requires auth).
|
|
## @param loadout A dict in the AvatarLoadout.to_json() shape
|
|
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
|
|
func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
|
|
var body := JSON.stringify({"data": loadout})
|
|
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _store_token_from_result(result: Dictionary) -> void:
|
|
if result.ok and result.data is Dictionary:
|
|
var token: String = str((result.data as Dictionary).get("access_token", ""))
|
|
if token != "":
|
|
set_token(token)
|
|
|
|
|
|
func _parse_json(text: String) -> Variant:
|
|
if text == "":
|
|
return null
|
|
var parsed: Variant = JSON.parse_string(text)
|
|
if parsed == null:
|
|
# null is ambiguous (valid JSON "null" vs parse failure). Fall back to
|
|
# the raw string so callers never lose the body entirely.
|
|
return text if text != "null" else null
|
|
return parsed
|
|
|
|
|
|
func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary:
|
|
return {
|
|
"ok": ok,
|
|
"status": status,
|
|
"data": data,
|
|
"detail": detail,
|
|
}
|
|
|
|
|
|
func _transport_error_detail(result_code: int) -> String:
|
|
match result_code:
|
|
HTTPRequest.RESULT_CANT_CONNECT:
|
|
return "Could not connect to server. Is it running?"
|
|
HTTPRequest.RESULT_CANT_RESOLVE:
|
|
return "Could not resolve server hostname."
|
|
HTTPRequest.RESULT_CONNECTION_ERROR:
|
|
return "Connection error."
|
|
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
|
return "TLS handshake failed."
|
|
HTTPRequest.RESULT_NO_RESPONSE:
|
|
return "No response from server."
|
|
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
|
|
return "Response body too large."
|
|
HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED:
|
|
return "Failed to decompress response body."
|
|
HTTPRequest.RESULT_REQUEST_FAILED:
|
|
return "Request failed."
|
|
HTTPRequest.RESULT_TIMEOUT:
|
|
return "Request timed out."
|
|
_:
|
|
return "Network error (code %d)." % result_code
|
|
|
|
|
|
func _error_string(err_code: int) -> String:
|
|
match err_code:
|
|
OK:
|
|
return "OK"
|
|
ERR_UNCONFIGURED:
|
|
return "HTTPRequest not configured (not in tree)."
|
|
ERR_BUSY:
|
|
return "HTTPRequest is busy with a previous request."
|
|
ERR_INVALID_PARAMETER:
|
|
return "Invalid URL format."
|
|
ERR_CANT_CONNECT:
|
|
return "Cannot connect to host."
|
|
_:
|
|
return "Unknown error (%d)." % err_code
|