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" ## Authenticated user identity var current_user_id: int = 0 var current_username: String = "" ## 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" current_user_id = 0 current_username = "" # --------------------------------------------------------------------------- # 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 ## Real-time WebSocket gateway client instance var ws: WsGatewayClient = null 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("/") ws = WsGatewayClient.new() ws.name = "WsGatewayClient" add_child(ws) ## Connect to the WebSocket gateway using the current base_url and stored token. func connect_ws_gateway(token: String = "") -> Error: if ws == null: return ERR_UNCONFIGURED var tok := token if token != "" else _access_token return ws.connect_gateway(_base_url, tok) ## Disconnect from the WebSocket gateway. func disconnect_ws_gateway() -> void: if ws != null: ws.disconnect_gateway() # --------------------------------------------------------------------------- # 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, custom_headers: PackedStringArray = PackedStringArray(), custom_timeout: float = 0.0 ) -> Dictionary: var http := HTTPRequest.new() http.timeout = custom_timeout if custom_timeout > 0.0 else 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) for ch in custom_headers: headers.append(ch) 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"): var raw_detail = (parsed as Dictionary)["detail"] if raw_detail is Array and not raw_detail.is_empty(): var parts: Array[String] = [] for val_err in raw_detail: if val_err is Dictionary and val_err.has("msg"): var loc_arr = val_err.get("loc", []) var loc_str := "" if loc_arr is Array: var filtered_loc: Array[String] = [] for x in loc_arr: if str(x) != "body": filtered_loc.append(str(x)) loc_str = ".".join(filtered_loc) if loc_str != "": parts.append("%s: %s" % [loc_str, str(val_err.get("msg"))]) else: parts.append(str(val_err.get("msg"))) else: parts.append(str(val_err)) detail = ", ".join(parts) else: detail = str(raw_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) if result.ok: current_username = username var me := await get_me() if me.ok and me.data is Dictionary: current_user_id = int((me.data as Dictionary).get("id", 0)) current_username = str((me.data as Dictionary).get("username", username)) return result ## Register a new user. On success stores the returned access token. ## ModBison is an 18+ platform. Supports over_18 boolean or optional legacy DOB string. func register( username: String, email: String, password: String, p4: Variant = true, p5: String = "" ) -> Dictionary: var over_18: bool = true var date_of_birth: String = "" if p4 is bool: over_18 = p4 date_of_birth = p5 elif p4 is String: date_of_birth = p4 over_18 = true var payload: Dictionary = { "username": username, "email": email, "password": password, "over_18": over_18, } if not date_of_birth.is_empty(): payload["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) if result.ok: current_username = username var me := await get_me() if me.ok and me.data is Dictionary: current_user_id = int((me.data as Dictionary).get("id", 0)) current_username = str((me.data as Dictionary).get("username", username)) 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) ## Create a new world owned by the current user (requires auth). ## On success, result.data is the created World object containing {"id", "name", ...}. func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = [], content_guidelines: String = "") -> Dictionary: var payload: Dictionary = { "name": world_name, "description": description, "visibility": visibility, "category": category, "tags": tags, "content_guidelines": content_guidelines, } return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "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: var result := await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true) if result.ok and result.data is Dictionary: current_user_id = int((result.data as Dictionary).get("id", 0)) current_username = str((result.data as Dictionary).get("username", current_username)) return result ## Search active users by username substring (requires auth). func search_users(query: String, limit: int = 20) -> Dictionary: var path := "/api/users/search?q=%s&limit=%d" % [query.uri_encode(), limit] return await request(HTTPClient.METHOD_GET, path, "", "application/json", true) ## Get active user summary by exact username (requires auth). func get_user_by_username(username: String) -> Dictionary: var path := "/api/users/by-username/%s" % username.strip_edges().uri_encode() return await request(HTTPClient.METHOD_GET, path, "", "application/json", true) ## Fetch the current user's saved avatar loadout (requires auth). ## On success, result.data is {"data": , "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, PackedStringArray(), 25.0) ## 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) # --------------------------------------------------------------------------- # Social Features: Friends, Follow & Block # --------------------------------------------------------------------------- ## Send a friend request to target user_id (int) or username (String). func send_friend_request(target: Variant) -> Dictionary: var payload: Dictionary = {} if target is int: payload = {"user_id": target} elif target is String: if (target as String).is_valid_int(): payload = {"user_id": int(target)} else: payload = {"username": (target as String).strip_edges()} else: payload = {"user_id": int(target)} return await request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify(payload), "application/json", true) ## Get pending incoming friend requests. func get_friend_requests() -> Dictionary: return await SocialApiClient.get_friend_requests(self) ## Accept an incoming friend request. func accept_friend_request(request_id: int) -> Dictionary: return await SocialApiClient.accept_friend_request(self, request_id) ## Decline an incoming friend request. func decline_friend_request(request_id: int) -> Dictionary: return await SocialApiClient.decline_friend_request(self, request_id) ## Remove an existing friend. func unfriend(user_id: int) -> Dictionary: return await SocialApiClient.unfriend(self, user_id) ## Get list of mutual friends. func get_friends() -> Dictionary: return await SocialApiClient.get_friends(self) ## Follow a user by ID or username. func follow_user(target: Variant) -> Dictionary: if target is String and not (target as String).is_valid_int(): var lookup := await get_user_by_username((target as String).strip_edges()) if not lookup.ok: return lookup target = int(lookup.data["id"]) return await request(HTTPClient.METHOD_POST, "/api/friends/%d/follow" % int(target), "", "application/json", true) ## Unfollow a user. func unfollow_user(user_id: int) -> Dictionary: return await SocialApiClient.unfollow_user(self, user_id) ## Get list of users followed by current user. func get_following() -> Dictionary: return await SocialApiClient.get_following(self) ## Block a user by ID or username. func block_user(target: Variant) -> Dictionary: if target is String and not (target as String).is_valid_int(): var lookup := await get_user_by_username((target as String).strip_edges()) if not lookup.ok: return lookup target = int(lookup.data["id"]) return await request(HTTPClient.METHOD_POST, "/api/friends/%d/block" % int(target), "", "application/json", true) ## Unblock a user. func unblock_user(user_id: int) -> Dictionary: return await SocialApiClient.unblock_user(self, user_id) ## Get list of blocked users. func get_blocked() -> Dictionary: return await SocialApiClient.get_blocked(self) # --------------------------------------------------------------------------- # Social Features: Presence # --------------------------------------------------------------------------- ## Send presence heartbeat with optional custom status. func send_presence_heartbeat(custom_status: String = "") -> Dictionary: return await SocialApiClient.send_presence_heartbeat(self, custom_status) ## Get presence status of mutual friends. func get_friends_presence() -> Dictionary: return await SocialApiClient.get_friends_presence(self) ## Get presence status of a specific user. func get_user_presence(user_id: int) -> Dictionary: return await SocialApiClient.get_user_presence(self, user_id) # --------------------------------------------------------------------------- # Social Features: Profiles # --------------------------------------------------------------------------- ## Get current user's own profile. func get_my_profile() -> Dictionary: return await SocialApiClient.get_my_profile(self) ## Update current user's bio, avatar asset, or privacy settings. func update_my_profile(bio: Variant = null, avatar_asset_id: Variant = null, privacy: Variant = null) -> Dictionary: return await SocialApiClient.update_my_profile(self, bio, avatar_asset_id, privacy) ## Get current user's favorited world IDs. func get_my_favorites() -> Dictionary: return await SocialApiClient.get_my_favorites(self) ## Replace current user's favorited world IDs. func replace_my_favorites(world_ids: Array) -> Dictionary: return await SocialApiClient.replace_my_favorites(self, world_ids) ## Get public profile of another user. func get_public_profile(user_id: int) -> Dictionary: return await SocialApiClient.get_public_profile(self, user_id) # --------------------------------------------------------------------------- # Social Features: Groups # --------------------------------------------------------------------------- ## Create a new group. func create_group(name: String, description: String = "") -> Dictionary: return await SocialApiClient.create_group(self, name, description) ## List groups. func list_groups(page: int = 1, page_size: int = 50, mine: bool = false) -> Dictionary: return await SocialApiClient.list_groups(self, page, page_size, mine) ## Get group details and member list. func get_group(group_id: int) -> Dictionary: return await SocialApiClient.get_group(self, group_id) ## Update group name or description. func update_group(group_id: int, name: Variant = null, description: Variant = null) -> Dictionary: return await SocialApiClient.update_group(self, group_id, name, description) ## Delete a group (owner only). func delete_group(group_id: int) -> Dictionary: return await SocialApiClient.delete_group(self, group_id) ## Join a group. func join_group(group_id: int) -> Dictionary: return await SocialApiClient.join_group(self, group_id) ## Leave a group. func leave_group(group_id: int) -> Dictionary: return await SocialApiClient.leave_group(self, group_id) ## Set a member's role in a group. func set_group_member_role(group_id: int, user_id: int, role: String) -> Dictionary: return await SocialApiClient.set_group_member_role(self, group_id, user_id, role) ## Transfer ownership of a group. func transfer_group_ownership(group_id: int, new_owner_id: int) -> Dictionary: return await SocialApiClient.transfer_group_ownership(self, group_id, new_owner_id) # --------------------------------------------------------------------------- # Social Features: Chat & Moderation # --------------------------------------------------------------------------- ## List DM conversations for the authenticated user. func get_dm_conversations() -> Dictionary: return await SocialApiClient.get_dm_conversations(self) ## Start or get existing DM conversation with target user. func start_dm_conversation(target_user_id: int) -> Dictionary: return await SocialApiClient.start_dm_conversation(self, target_user_id) ## Get DM messages for a conversation. func get_dm_messages(conversation_id: int, before_id: int = 0, limit: int = 50) -> Dictionary: return await SocialApiClient.get_dm_messages(self, conversation_id, before_id, limit) ## Send a DM message to a conversation. func send_dm_message(conversation_id: int, content: String) -> Dictionary: return await SocialApiClient.send_dm_message(self, conversation_id, content) ## Get messages in world chat. func get_world_chat_messages(world_id: int, before_id: int = 0, limit: int = 50) -> Dictionary: return await SocialApiClient.get_world_chat_messages(self, world_id, before_id, limit) ## Send a message to world chat. func send_world_chat_message(world_id: int, content: String) -> Dictionary: return await SocialApiClient.send_world_chat_message(self, world_id, content) ## Get messages in room chat. func get_room_chat_messages(room_id: String, before_id: int = 0, limit: int = 50) -> Dictionary: return await SocialApiClient.get_room_chat_messages(self, room_id, before_id, limit) ## Send a message to room chat. func send_room_chat_message(room_id: String, content: String) -> Dictionary: return await SocialApiClient.send_room_chat_message(self, room_id, content) ## Report a message to the moderation queue. func report_message(message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary: return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text) ## Submit a moderation report for a world, user, or chat message. func submit_moderation_report(target_type: String, target_id: int, reason: String, category: String = "general") -> Dictionary: var payload := { "target_type": target_type, "target_id": target_id, "reason": reason, "category": category, } return await request( HTTPClient.METHOD_POST, "/api/moderation/reports", JSON.stringify(payload), "application/json", true ) # --------------------------------------------------------------------------- # Discovery Features # --------------------------------------------------------------------------- ## List public worlds for discovery with optional search, tags, category, and sort. func get_discovery_worlds( q: String = "", tags: Array = [], category: String = "", sort: String = "newest", skip: int = 0, limit: int = 50 ) -> Dictionary: return await DiscoveryApiClient.get_worlds(self, q, tags, category, sort, skip, limit) ## Get distinct categories of public worlds. func get_discovery_categories() -> Dictionary: return await DiscoveryApiClient.get_categories(self) ## Record a visit to a world. func record_world_visit(world_id: int, anonymous_token: String = "") -> Dictionary: return await DiscoveryApiClient.record_visit(self, world_id, anonymous_token) # --------------------------------------------------------------------------- # 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