diff --git a/scripts/api_client.gd b/scripts/api_client.gd index d5961a7..6182f3a 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -456,6 +456,31 @@ func report_message(message_id: int, conversation_type: String, reason: String, return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text) +# --------------------------------------------------------------------------- +# 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 diff --git a/scripts/discovery_api_client.gd b/scripts/discovery_api_client.gd new file mode 100644 index 0000000..4c331d9 --- /dev/null +++ b/scripts/discovery_api_client.gd @@ -0,0 +1,140 @@ +extends RefCounted +class_name DiscoveryApiClient +## Helper client for AGP Discovery REST endpoints (Browse, Search, Categories, Visit tracking). +## +## docs/planning/discovery.md (task 56c49440d0a6). + +class WorldSummary extends RefCounted: + var id: int = 0 + var name: String = "" + var description: String = "" + var category: String = "" + var tags: Array = [] + var play_count: int = 0 + var created_at: String = "" + var owner_id: int = 0 + var owner_username: String = "" + + static func from_dict(d: Dictionary) -> WorldSummary: + var w := WorldSummary.new() + w.id = int(d.get("id", 0)) + w.name = str(d.get("name", "")) + w.description = str(d.get("description", "")) if d.get("description") != null else "" + w.category = str(d.get("category", "")) if d.get("category") != null else "" + var raw_tags = d.get("tags", []) + w.tags = raw_tags if raw_tags is Array else [] + w.play_count = int(d.get("play_count", 0)) + w.created_at = str(d.get("created_at", "")) if d.get("created_at") != null else "" + var owner: Dictionary = d.get("owner", {}) if d.get("owner") is Dictionary else {} + w.owner_id = int(owner.get("id", 0)) + w.owner_username = str(owner.get("username", "")) + return w + + +class DiscoveryResult extends RefCounted: + var items: Array[WorldSummary] = [] + var total: int = 0 + var skip: int = 0 + var limit: int = 50 + + static func from_dict(d: Dictionary) -> DiscoveryResult: + var res := DiscoveryResult.new() + res.total = int(d.get("total", 0)) + res.skip = int(d.get("skip", 0)) + res.limit = int(d.get("limit", 50)) + var raw_items = d.get("items", []) + if raw_items is Array: + for item in raw_items: + if item is Dictionary: + res.items.append(WorldSummary.from_dict(item)) + return res + + +static func get_api_client(node: Node) -> Node: + if node.has_node("/root/ApiClient"): + return node.get_node("/root/ApiClient") + return null + + +## Fetch worlds with search, tags, category filter, sort, and pagination. +## Attaches JWT token if client has one; works anonymously without token. +## Surfaces transport/HTTP errors in the standard result dictionary. +static func get_worlds( + api: Node, + q: String = "", + tags: Array = [], + category: String = "", + sort: String = "newest", + skip: int = 0, + limit: int = 50 +) -> Dictionary: + var params: Array[String] = [] + if q != "": + params.append("q=" + q.uri_encode()) + if tags.size() > 0: + var tag_strs: Array[String] = [] + for t in tags: + tag_strs.append(str(t)) + params.append("tags=" + ",".join(tag_strs).uri_encode()) + if category != "": + params.append("category=" + category.uri_encode()) + if sort != "": + params.append("sort=" + sort.uri_encode()) + params.append("skip=%d" % skip) + params.append("limit=%d" % limit) + + var url := "/api/discovery/worlds" + if params.size() > 0: + url += "?" + "&".join(params) + + var custom_headers := PackedStringArray() + if api != null and api.has_method("has_token") and api.has_token(): + custom_headers.append("Authorization: " + api.bearer_token) + + var res: Dictionary = await api.request( + HTTPClient.METHOD_GET, + url, + "", + "application/json", + false, + custom_headers + ) + if res.ok and res.data is Dictionary: + res["parsed"] = DiscoveryResult.from_dict(res.data) + return res + + +## Fetch distinct categories of public worlds. +static func get_categories(api: Node) -> Dictionary: + var custom_headers := PackedStringArray() + if api != null and api.has_method("has_token") and api.has_token(): + custom_headers.append("Authorization: " + api.bearer_token) + + return await api.request( + HTTPClient.METHOD_GET, + "/api/discovery/categories", + "", + "application/json", + false, + custom_headers + ) + + +## Record a visit to a world. Supports optional anonymous token or logged in user. +static func record_visit(api: Node, world_id: int, anonymous_token: String = "") -> Dictionary: + var custom_headers := PackedStringArray() + if api != null and api.has_method("has_token") and api.has_token(): + custom_headers.append("Authorization: " + api.bearer_token) + + var payload := {} + if anonymous_token != "": + payload["anonymous_token"] = anonymous_token + + return await api.request( + HTTPClient.METHOD_POST, + "/api/discovery/worlds/%d/visit" % world_id, + JSON.stringify(payload) if payload.size() > 0 else "", + "application/json", + false, + custom_headers + ) diff --git a/scripts/smoke_test_discovery_client.gd b/scripts/smoke_test_discovery_client.gd new file mode 100644 index 0000000..d1f7ba1 --- /dev/null +++ b/scripts/smoke_test_discovery_client.gd @@ -0,0 +1,62 @@ +extends SceneTree +## Headless smoke test for DiscoveryApiClient. + +func _init() -> void: + print("[SMOKE] Starting DiscoveryApiClient smoke test...") + + # 1. Test WorldSummary parsing + var test_dict := { + "id": 42, + "name": "Neon Arcade", + "description": "Retro gaming lounge", + "category": "Arcade", + "tags": ["retro", "multiplayer"], + "play_count": 128, + "created_at": "2026-09-15T12:00:00Z", + "owner": { + "id": 7, + "username": "arcadegamer" + } + } + + var summary := DiscoveryApiClient.WorldSummary.from_dict(test_dict) + assert(summary.id == 42, "summary id mismatch") + assert(summary.name == "Neon Arcade", "summary name mismatch") + assert(summary.category == "Arcade", "summary category mismatch") + assert(summary.tags.size() == 2, "summary tags size mismatch") + assert(summary.play_count == 128, "summary play_count mismatch") + assert(summary.owner_id == 7, "summary owner_id mismatch") + assert(summary.owner_username == "arcadegamer", "summary owner_username mismatch") + print("[PASS] WorldSummary parsing verified") + + # 2. Test DiscoveryResult parsing + var page_dict := { + "items": [test_dict], + "total": 1, + "skip": 0, + "limit": 50 + } + var res := DiscoveryApiClient.DiscoveryResult.from_dict(page_dict) + assert(res.total == 1, "res total mismatch") + assert(res.items.size() == 1, "res items size mismatch") + assert(res.items[0].id == 42, "res items[0] id mismatch") + print("[PASS] DiscoveryResult parsing verified") + + # 3. Test ApiClient integration + var api_script = load("res://scripts/api_client.gd") + if not api_script: + printerr("[FAIL] Could not load api_client.gd") + quit(1) + return + + var api: Node = api_script.new() + root.add_child(api) + + assert(api.has_method("get_discovery_worlds"), "Missing get_discovery_worlds on ApiClient") + assert(api.has_method("get_discovery_categories"), "Missing get_discovery_categories on ApiClient") + assert(api.has_method("record_world_visit"), "Missing record_world_visit on ApiClient") + print("[PASS] ApiClient discovery methods verified") + + api.queue_free() + print("[SMOKE] DiscoveryApiClient smoke test PASSED") + quit(0)