From 8ddda5d5eda21543e4da396575bcac248c73e497 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Mon, 15 Sep 2025 09:59:44 -0400 Subject: [PATCH] Initial commit of Godot addon for GumYum NPC client --- Client.gd | 951 +++++++++++++++++++++++++++++++++ Client.gd.uid | 1 + LICENSE | 202 +++++++ NPC.gd | 394 ++++++++++++++ NPC.gd.uid | 1 + README.md | 345 ++++++++++++ examples/NpcChatExample.gd | 171 ++++++ examples/NpcChatExample.gd.uid | 1 + examples/NpcChatExample.tscn | 61 +++ examples/QuickstartChat.gd | 182 +++++++ examples/QuickstartChat.gd.uid | 1 + plugin.cfg | 7 + plugin.gd | 17 + plugin.gd.uid | 1 + tests/test_npc_features.gd | 218 ++++++++ tests/test_npc_features.gd.uid | 1 + 16 files changed, 2554 insertions(+) create mode 100644 Client.gd create mode 100644 Client.gd.uid create mode 100644 LICENSE create mode 100644 NPC.gd create mode 100644 NPC.gd.uid create mode 100644 README.md create mode 100644 examples/NpcChatExample.gd create mode 100644 examples/NpcChatExample.gd.uid create mode 100644 examples/NpcChatExample.tscn create mode 100644 examples/QuickstartChat.gd create mode 100644 examples/QuickstartChat.gd.uid create mode 100644 plugin.cfg create mode 100644 plugin.gd create mode 100644 plugin.gd.uid create mode 100644 tests/test_npc_features.gd create mode 100644 tests/test_npc_features.gd.uid diff --git a/Client.gd b/Client.gd new file mode 100644 index 0000000..331f3d7 --- /dev/null +++ b/Client.gd @@ -0,0 +1,951 @@ +extends Node + +## GumYum Game Client for Godot (API Key Authentication Only) +## +## Streamlined client for game integration using API keys only. +## Perfect for embedding in shipped games with just the essential features. +## +## This client does NOT support username/password authentication. +## Use API keys obtained from the GumYum dashboard. +## +## Features: +## - Character spawning and dialogue +## - API key authentication only (no login/register) +## - Automatic JWT token management via API key exchange +## - Automatic retry and error handling +## - Streaming dialogue support + +signal character_spawned(character: GumYumNPC) +signal dialogue_received(response: String, context: Dictionary, mood_transition: Dictionary) +signal dialogue_chunk_received(chunk: String) +signal dialogue_stream_started +signal dialogue_stream_ended( + full_response: String, context: Dictionary, mood_transition: Dictionary +) +signal request_completed(endpoint: String, data: Dictionary) +signal request_failed(endpoint: String, error: String) + +## Manager Classes + + +class NPCManager: + var client: Node + + func _init(c: Node) -> void: + client = c + + ## Spawn a character - returns GumYumNPC object + ## Pass null for npc_id to spawn a random NPC + func spawn( + universe_id: String, seed: int, npc_id = null, callback: Callable = Callable() + ) -> void: + var params = {"universe_id": universe_id, "seed": seed} + var endpoint: String + + if npc_id != null: + # Spawn specific NPC + params["npc_id"] = npc_id + endpoint = "npc" + else: + # Spawn random NPC - let API handle the randomness + endpoint = "npc/spawn" + + # Make the API request + client.request( + HTTPClient.METHOD_GET, + endpoint, + {}, + params, + func(response): + # Check if this is a redirect response + if response.has("redirect_url"): + # Follow the redirect to get actual NPC data + var redirect_path = response.redirect_url + # Strip the /v1/ prefix since client adds it + if redirect_path.begins_with("/v1/"): + redirect_path = redirect_path.substr(4) + + # Make the redirect request + client.request( + HTTPClient.METHOD_GET, + redirect_path, + {}, + {}, + func(npc_data): + if not npc_data.has("error"): + # Create NPC object with client reference + var npc = GumYumNPC.new(npc_data, client) + npc._connect_client_signals() + client.character_spawned.emit(npc) + if callback.is_valid(): + var npc_obj = ( + GumYumNPC.new(npc_data, client) + if not npc_data.has("error") + else null + ) + if npc_obj: + npc_obj._connect_client_signals() + callback.call(npc_obj if npc_obj else npc_data) + ) + else: + # Direct response (shouldn't happen with /npc/spawn) + if not response.has("error"): + var npc = GumYumNPC.new(response, client) + npc._connect_client_signals() + client.character_spawned.emit(npc) + if callback.is_valid(): + callback.call(npc) + ) + + ## Spawn with advanced filters - supports any NPC field + ## Examples: + ## spawn_filtered("kingdom", 12345, {"profession": ["warrior", "knight"], "location": ["barracks", "castle"]}) + ## spawn_filtered("blade-runner", 67890, {"personality_type": [8], "sex": ["female"]}) + func spawn_filtered( + universe_id: String, seed: int, filters: Dictionary = {}, callback: Callable = Callable() + ) -> void: + var data = {"universe_id": universe_id, "world_seed": seed, "filters": filters} + + var endpoint = "npc/spawn_filtered" + + # spawn_filtered uses POST with data in body + client.request( + HTTPClient.METHOD_POST, + endpoint, + data, + {}, + func(response): + # Check if this is a redirect response + if response.has("redirect_url"): + # Follow the redirect to get actual NPC data + var redirect_path = response.redirect_url + # Strip the /v1/ prefix since client adds it + if redirect_path.begins_with("/v1/"): + redirect_path = redirect_path.substr(4) + + # Make the redirect request + client.request( + HTTPClient.METHOD_GET, + redirect_path, + {}, + {}, + func(npc_data): + if not npc_data.has("error"): + # Create NPC object with client reference + var npc = GumYumNPC.new(npc_data, client) + npc._connect_client_signals() + client.character_spawned.emit(npc) + if callback.is_valid(): + var npc_obj = ( + GumYumNPC.new(npc_data, client) + if not npc_data.has("error") + else null + ) + if npc_obj: + npc_obj._connect_client_signals() + callback.call(npc_obj if npc_obj else npc_data) + ) + else: + # Direct response (shouldn't happen with /npc/spawn) + if not response.has("error"): + var npc = GumYumNPC.new(response, client) + npc._connect_client_signals() + client.character_spawned.emit(npc) + if callback.is_valid(): + callback.call(npc) + else: + if callback.is_valid(): + callback.call(response) + ) + + +class UniverseManager: + var client: Node + + func _init(c: Node) -> void: + client = c + + ## List public universes (works with API keys) + func list_public(callback: Callable) -> void: + client.request(HTTPClient.METHOD_GET, "public/universes", {}, {}, callback) + + ## List user's universes (works with JWT from API key exchange) + func list(callback: Callable) -> void: + client.request(HTTPClient.METHOD_GET, "universes", {}, {}, callback) + + func copy( + _public_universe_id: String, _custom_name: String = "", callback: Callable = Callable() + ) -> void: + push_error( + "[GumYum] copy() requires user authentication. Use API keys with a known universe ID instead." + ) + if callback.is_valid(): + callback.call( + {"error": "Authentication required. Use API keys with a known universe ID."} + ) + + func get_universe(universe_id: String, callback: Callable) -> void: + client.request(HTTPClient.METHOD_GET, "universes/" + universe_id, {}, {}, callback) + + +class ChatManager: + var client: Node + + func _init(c: Node) -> void: + client = c + + ## Generate dialogue (OpenAI-compatible chat.completions) + func completions( + messages: Array, + npc_params: Dictionary, + temperature: float = 0.8, + callback: Callable = Callable(), + max_tokens: int = 2000, + stream: bool = false + ) -> void: + var data = { + "model": "gumyum-npc", + "temperature": temperature, + "messages": messages, + "stream": stream, + "npc_params": npc_params, + "max_tokens": max_tokens + } + + # Extract npc_id to top level if it exists in npc_params + if npc_params.has("npc_id"): + data["npc_id"] = npc_params["npc_id"] + + if stream: + # Use streaming method + client._request_stream(HTTPClient.METHOD_POST, "chat/completions", data, {}, callback) + else: + client.request( + HTTPClient.METHOD_POST, + "chat/completions", + data, + {}, + func(response): + if ( + not response.has("error") + and response.has("choices") + and response.choices.size() > 0 + ): + var content = response.choices[0].message.content + var context = response.get("npc_context", {}) + var mood_transition = response.get("mood_transition", {}) + client.dialogue_received.emit(content, context, mood_transition) + if callback.is_valid(): + callback.call(response) + ) + + ## Generate dialogue (legacy method name for backwards compatibility) + func chat( + messages: Array, + npc_params: Dictionary, + temperature: float = 0.8, + callback: Callable = Callable(), + max_tokens: int = 2000 + ) -> void: + completions(messages, npc_params, temperature, callback, max_tokens, false) + + ## Generate dialogue with streaming support + func chat_stream( + messages: Array, + npc_params: Dictionary, + temperature: float = 0.8, + callback: Callable = Callable(), + max_tokens: int = 2000 + ) -> void: + var data = { + "model": "gumyum-npc", + "temperature": temperature, + "messages": messages, + "stream": true, # Enable streaming + "npc_params": npc_params, + "max_tokens": max_tokens + } + + # Extract npc_id to top level if it exists in npc_params + if npc_params.has("npc_id"): + data["npc_id"] = npc_params["npc_id"] + + # Use the streaming request method + client._request_stream(HTTPClient.METHOD_POST, "chat/completions", data, {}, callback) + + +## Configuration +@export var base_url: String = "https://npc.gumyum.com" +@export var api_version: String = "v1" +@export var timeout: float = 75.0 # Higher than server's 65s timeout +@export var max_retries: int = 3 +@export var debug_mode: bool = false + +## API Credentials +var _api_key: String = "" # The public API key +var _api_secret: String = "" # The secret key +var _jwt_token: String = "" +var _refresh_token: String = "" +var _token_expires_at: float = 0.0 + +## Set API credentials - matches Python client interface +var api_key: String: + set(value): + _api_key = value + if debug_mode: + print("[GumYum Game] API key set: ", value) + get: + return _api_key + +var api_secret: String: + set(value): + _api_secret = value + if debug_mode: + print("[GumYum Game] API secret set: ****") + get: + return _api_secret + +## HTTP Clients for parallel requests +var _http_clients: Array = [] # Pool of HTTP clients +var _max_parallel_requests: int = 5 # Max concurrent requests +var _pending_requests: Array = [] +var _active_requests: int = 0 +var _auth_in_progress: bool = false # Prevent concurrent auth requests + +## Managers (initialized in _ready) +var npcs: NPCManager +var universes: UniverseManager +var chat: ChatManager + + +func _ready() -> void: + _setup_http_client() + _initialize_managers() + + # No need to load auth for API key-only authentication + + if debug_mode: + print("[GumYum Game] Client initialized") + + +func _setup_http_client() -> void: + # Create a pool of HTTP clients for parallel requests + for i in range(_max_parallel_requests): + var http_client = HTTPRequest.new() + http_client.name = "HTTPClient_" + str(i) + add_child(http_client) + http_client.timeout = timeout + http_client.request_completed.connect(_on_request_completed.bind(http_client)) + _http_clients.append(http_client) + + +func _initialize_managers() -> void: + npcs = NPCManager.new(self) + universes = UniverseManager.new(self) + chat = ChatManager.new(self) + + +## Check if authenticated +func is_ready() -> bool: + return _jwt_token != "" and Time.get_ticks_msec() / 1000.0 < _token_expires_at + + +## Check if token needs refresh (within 60 seconds of expiry) +func _needs_refresh() -> bool: + return _jwt_token != "" and Time.get_ticks_msec() / 1000.0 > (_token_expires_at - 60) + + +## Get full API URL +func get_api_url(endpoint: String) -> String: + return "%s/%s/%s" % [base_url, api_version, endpoint.lstrip("/")] + + +## Get request headers +func get_headers() -> PackedStringArray: + var headers = PackedStringArray() + headers.append("Content-Type: application/json") + headers.append("User-Agent: GumYum-Godot-Game-SDK/1.0.0") + + # Add API key header if we have credentials + if _api_key and _api_secret: + headers.append("X-API-Key: " + _api_key + ":" + _api_secret) + + if _jwt_token: + headers.append("Authorization: Bearer " + _jwt_token) + + return headers + + +## Make API request +func request( + method: HTTPClient.Method, + endpoint: String, + data: Dictionary = {}, + query_params: Dictionary = {}, + callback: Callable = Callable() +) -> void: + # Queue the request + var request_data = { + "method": method, + "endpoint": endpoint, + "data": data, + "query_params": query_params, + "callback": callback, + "retries": 0 + } + + _pending_requests.append(request_data) + _process_request_queue() + + +func _process_request_queue() -> void: + # Process multiple requests in parallel up to the limit + while _active_requests < _max_parallel_requests and _pending_requests.size() > 0: + # Check if we need to exchange API key first + if _api_key != "" and _api_secret != "" and not is_ready(): + _exchange_api_key_for_jwt() + return + + # Check if we need to refresh token + if _needs_refresh() and _refresh_token != "": + _refresh_jwt_token() + return + + # Find an available HTTP client + var available_client: HTTPRequest = null + for client in _http_clients: + if client.get_meta("busy", false) == false: + available_client = client + break + + if available_client: + var request = _pending_requests.pop_front() + available_client.set_meta("busy", true) + available_client.set_meta("request_data", request) + _active_requests += 1 + _execute_request(request, available_client) + + +func _execute_request(request: Dictionary, http_client: HTTPRequest) -> void: + var url = get_api_url(request.endpoint) + + # Add query parameters + if request.query_params.size() > 0: + var query_parts = [] + for key in request.query_params: + query_parts.append(key + "=" + str(request.query_params[key]).uri_encode()) + url += "?" + "&".join(query_parts) + + var headers = get_headers() + var body = "" + + if request.method in [HTTPClient.METHOD_POST, HTTPClient.METHOD_PUT]: + body = JSON.stringify(request.data) + + if debug_mode: + print("[GumYum Game] %s %s" % [request.method, request.endpoint]) + print("[GumYum Game] Full URL: %s" % url) + + var error = http_client.request_raw( + url, headers, request.method, body.to_utf8_buffer() if body else PackedByteArray() + ) + + if error != OK: + _handle_request_error(request, "Failed to make request: " + error_string(error)) + http_client.set_meta("busy", false) + _active_requests -= 1 + _process_request_queue() + + +func _on_request_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, + http_client: HTTPRequest +) -> void: + var request = http_client.get_meta("request_data", {}) + if request.is_empty(): + return + + # Mark client as available + http_client.set_meta("busy", false) + http_client.remove_meta("request_data") + _active_requests -= 1 + + if result != HTTPRequest.RESULT_SUCCESS: + _handle_request_error(request, "Request failed: " + str(result)) + return + + var response_text = body.get_string_from_utf8() + var response_data = {} + + if response_text: + var json = JSON.new() + if json.parse(response_text) == OK: + response_data = json.data + else: + print("[GumYum Game] Failed to parse JSON response: ", response_text) + + if debug_mode: + print("[GumYum Game] Response code: ", response_code) + if response_code < 200 or response_code >= 300: + print("[GumYum Game] Response body: ", response_text.substr(0, 200)) # First 200 chars + + if response_code >= 200 and response_code < 300: + _handle_success_response(request, response_data) + else: + _handle_error_response(request, response_code, response_data) + + # Process next request + _process_request_queue() + + +func _handle_success_response(request: Dictionary, data: Dictionary) -> void: + request_completed.emit(request.endpoint, data) + + if request.callback.is_valid(): + request.callback.call(data) + + +func _handle_error_response(request: Dictionary, code: int, data: Dictionary) -> void: + var error_msg = data.get("message", data.get("error", "Unknown error")) + + if debug_mode: + print( + ( + "[GumYum Game] Error response for %s: HTTP %d - %s" + % [request.endpoint, code, error_msg] + ) + ) + if data.has("details"): + print("[GumYum Game] Details: ", data.get("details")) + + # Handle 403 - try to refresh token only for expired tokens + if code == 403 and _refresh_token != "" and request.retries == 0: + # Check if it's specifically an expired token error + var error_message = data.get("error", "").to_lower() + if error_message.contains("expired") and error_message.contains("token"): + if debug_mode: + print("[GumYum Game] Got 403 with expired token, attempting token refresh...") + request.retries += 1 + _pending_requests.push_front(request) + _refresh_jwt_token() + return + + # For 401 errors (like revoked API keys), don't retry - just fail + if code == 401: + if debug_mode: + print("[GumYum Game] Got 401 Unauthorized - not retrying") + _handle_request_error(request, "Error %d: %s" % [code, error_msg]) + return + + # Retry logic for temporary failures + if code >= 500 and request.retries < max_retries: + request.retries += 1 + if debug_mode: + print("[GumYum Game] Retrying request (attempt %d/%d)" % [request.retries, max_retries]) + + # Exponential backoff + await get_tree().create_timer(pow(2, request.retries)).timeout + _pending_requests.push_front(request) + return + + _handle_request_error(request, "Error %d: %s" % [code, error_msg]) + + +func _handle_request_error(request: Dictionary, error: String) -> void: + if debug_mode: + print("[GumYum Game] Request failed: %s - %s" % [request.endpoint, error]) + + request_failed.emit(request.endpoint, error) + + if request.callback.is_valid(): + request.callback.call({"error": error}) + + +func _exchange_api_key_for_jwt() -> void: + if _api_key == "" or _api_secret == "": + return + + if _auth_in_progress: + return # Already exchanging, skip + + _auth_in_progress = true + + var data = {"public_key": _api_key, "secret_key": _api_secret} + + var url = get_api_url("auth/exchange") + var headers = get_headers() + var body = JSON.stringify(data) + + # Use first HTTP client for auth + var auth_client = _http_clients[0] + var error = auth_client.request_raw(url, headers, HTTPClient.METHOD_POST, body.to_utf8_buffer()) + + if error == OK: + # Wait for response + var result = await auth_client.request_completed + + if result[1] == 200: # response_code + var response_text = result[3].get_string_from_utf8() # body + var json = JSON.new() + if json.parse(response_text) == OK: + var response_data = json.data + _jwt_token = response_data.get("access_token", "") + _refresh_token = response_data.get("refresh_token", "") + var expires_in = response_data.get("expires_in", 3600) + _token_expires_at = Time.get_ticks_msec() / 1000.0 + expires_in + + if debug_mode: + print("[GumYum Game] API key exchanged for JWT") + print("[GumYum Game] Got refresh token: ", _refresh_token != "") + + _auth_in_progress = false + + # Process queued requests + _process_request_queue() + else: + # Handle authentication error + var error_text = result[3].get_string_from_utf8() if result[3] else "Unknown error" + push_error( + "[GumYum Game] API key exchange failed (code %d): %s" % [result[1], error_text] + ) + + # Try to parse error message + var json = JSON.new() + if json.parse(error_text) == OK and json.data is Dictionary: + var error_msg = json.data.get("error", json.data.get("message", "Invalid API keys")) + _auth_in_progress = false + # Process pending requests with error + while _pending_requests.size() > 0: + var req = _pending_requests.pop_front() + _handle_request_error(req, "Authentication failed: " + error_msg) + else: + _auth_in_progress = false + # Process pending requests with generic error + while _pending_requests.size() > 0: + var req = _pending_requests.pop_front() + _handle_request_error(req, "Invalid public key or secret key") + else: + _auth_in_progress = false + push_error("[GumYum Game] Failed to make exchange request: " + error_string(error)) + + +func _refresh_jwt_token() -> void: + if _refresh_token == "": + return + + if _auth_in_progress: + return # Already refreshing, skip + + _auth_in_progress = true + + var data = {"refresh_token": _refresh_token} + + var url = get_api_url("auth/refresh") + var headers = get_headers() + var body = JSON.stringify(data) + + if debug_mode: + print("[GumYum Game] Refreshing JWT token...") + + # Use first HTTP client for auth + var auth_client = _http_clients[0] + var error = auth_client.request_raw(url, headers, HTTPClient.METHOD_POST, body.to_utf8_buffer()) + + if error == OK: + # Wait for response + var result = await auth_client.request_completed + + if result[1] == 200: # response_code + var response_text = result[3].get_string_from_utf8() # body + var json = JSON.new() + if json.parse(response_text) == OK: + var response_data = json.data + _jwt_token = response_data.get("access_token", "") + # Server may rotate refresh token + if response_data.has("refresh_token"): + _refresh_token = response_data.get("refresh_token", "") + var expires_in = response_data.get("expires_in", 3600) + _token_expires_at = Time.get_ticks_msec() / 1000.0 + expires_in + + if debug_mode: + print("[GumYum Game] JWT token refreshed successfully") + + _auth_in_progress = false + + # Process queued requests + _process_request_queue() + elif result[1] == 403: # Forbidden - refresh token invalid + # Handle refresh token expiry + var error_text = result[3].get_string_from_utf8() if result[3] else "Unknown error" + push_error( + "[GumYum Game] Refresh token expired or invalid (code 403): %s" % [error_text] + ) + + # Clear tokens + _jwt_token = "" + _refresh_token = "" + _auth_in_progress = false + + # If we have API keys, try to re-exchange + if _api_key != "": + if debug_mode: + print("[GumYum Game] Refresh failed, re-exchanging API key...") + _exchange_api_key_for_jwt() + else: + # Process pending requests with error + while _pending_requests.size() > 0: + var req = _pending_requests.pop_front() + _handle_request_error(req, "Authentication expired - please re-authenticate") + else: + # Handle other refresh errors + var error_text = result[3].get_string_from_utf8() if result[3] else "Unknown error" + push_error("[GumYum Game] Token refresh failed (code %d): %s" % [result[1], error_text]) + + _auth_in_progress = false + + # Process pending requests with error + while _pending_requests.size() > 0: + var req = _pending_requests.pop_front() + _handle_request_error(req, "Token refresh failed") + else: + _auth_in_progress = false + push_error("[GumYum Game] Failed to make refresh request: " + error_string(error)) + + +## Internal method for streaming requests +func _request_stream( + method: HTTPClient.Method, + endpoint: String, + data: Dictionary = {}, + query_params: Dictionary = {}, + callback: Callable = Callable() +) -> void: + # Check if we need to wait for auth + if not is_ready(): + push_error("[GumYum Stream] Not authenticated yet") + if callback.is_valid(): + callback.call({"error": "Not authenticated"}) + return + + # Create a new thread for streaming + var thread = Thread.new() + thread.start(_stream_request_thread.bind(method, endpoint, data, query_params, callback)) + + +func _stream_request_thread( + method: HTTPClient.Method, + endpoint: String, + data: Dictionary, + query_params: Dictionary, + callback: Callable +) -> void: + var http = HTTPClient.new() + var url = get_api_url(endpoint) + var parsed_url = url.split("/") + var host = parsed_url[2].split(":")[0] + var port = 443 if url.begins_with("https") else 80 + + if ":" in parsed_url[2]: + port = int(parsed_url[2].split(":")[1]) + + var path = "/" + "/".join(parsed_url.slice(3)) + + if debug_mode: + print("[GumYum Stream] Connecting to %s:%d%s" % [host, port, path]) + + # Add query parameters + if query_params.size() > 0: + var query_parts = [] + for key in query_params: + query_parts.append(key + "=" + str(query_params[key]).uri_encode()) + path += "?" + "&".join(query_parts) + + # Connect to server + var err = OK + if port == 443: + # For HTTPS, create TLS options + var tls_options = TLSOptions.client() + err = http.connect_to_host(host, port, tls_options) + else: + # For HTTP, no TLS needed + err = http.connect_to_host(host, port) + + if err != OK: + call_deferred("_handle_stream_error", endpoint, "Failed to connect: " + str(err), callback) + return + + # Wait for connection + while ( + http.get_status() == HTTPClient.STATUS_CONNECTING + or http.get_status() == HTTPClient.STATUS_RESOLVING + ): + http.poll() + OS.delay_msec(10) + + if http.get_status() != HTTPClient.STATUS_CONNECTED: + call_deferred("_handle_stream_error", endpoint, "Connection failed", callback) + return + + # Prepare headers + var headers = get_headers() + headers.append("Accept: text/event-stream") + + # Make request + var body_string = JSON.stringify(data) + err = http.request(method, path, headers, body_string) + + if err != OK: + call_deferred("_handle_stream_error", endpoint, "Request failed: " + str(err), callback) + return + + # Process streaming response + call_deferred("emit_signal", "dialogue_stream_started") + + var response_data = "" + var accumulated_content = "" + var context = {} + var mood_transition = {} + var chunk_count = 0 + + while http.get_status() == HTTPClient.STATUS_REQUESTING: + http.poll() + OS.delay_msec(1) + + if ( + http.get_status() != HTTPClient.STATUS_BODY + and http.get_status() != HTTPClient.STATUS_CONNECTED + ): + call_deferred( + "_handle_stream_error", + endpoint, + "Request failed with status: " + str(http.get_status()), + callback + ) + return + + if debug_mode: + print("[GumYum Stream] Starting to read response body...") + + # Read streaming response + while http.get_status() == HTTPClient.STATUS_BODY: + http.poll() + var chunk = http.read_response_body_chunk() + + if chunk.size() > 0: + response_data += chunk.get_string_from_utf8() + + # Process SSE events + var lines = response_data.split("\n") + for i in range(lines.size() - 1): + var line = lines[i].strip_edges() + + if line.begins_with("data: "): + var data_str = line.substr(6) + if debug_mode: + print("[GumYum Stream] Received data: " + data_str.substr(0, 100)) + + if data_str == "[DONE]" or data_str == "done": + # Stream finished + if debug_mode: + print( + ( + "[GumYum Stream] Stream complete, total content: " + + str(accumulated_content.length()) + + " chars" + ) + ) + call_deferred( + "emit_signal", + "dialogue_stream_ended", + accumulated_content, + context, + mood_transition + ) + if callback.is_valid(): + call_deferred( + "_call_callback", + callback, + { + "choices": [{"message": {"content": accumulated_content}}], + "npc_context": context, + "mood_transition": mood_transition + } + ) + return + + # Parse JSON data + var json = JSON.new() + if json.parse(data_str) == OK: + var event_data = json.data + + # Handle different event types + if event_data.has("choices") and event_data.choices.size() > 0: + var choice = event_data.choices[0] + if choice.has("delta") and choice.delta.has("content"): + var content_chunk = choice.delta.content + accumulated_content += content_chunk + chunk_count += 1 + if debug_mode: + print( + ( + "[GumYum Stream] Chunk %d: %s" + % [chunk_count, content_chunk] + ) + ) + call_deferred( + "emit_signal", "dialogue_chunk_received", content_chunk + ) + elif choice.has("message") and choice.message.has("content"): + # Full message format + var content = choice.message.content + accumulated_content = content + if debug_mode: + print( + ( + "[GumYum Stream] Full message received: " + + content.substr(0, 50) + + "..." + ) + ) + call_deferred("emit_signal", "dialogue_chunk_received", content) + + # Check for context and mood transition + if event_data.has("npc_context"): + context = event_data.npc_context + if event_data.has("mood_transition"): + mood_transition = event_data.mood_transition + + # Keep the last incomplete line for next iteration + response_data = lines[lines.size() - 1] + + OS.delay_msec(1) + + # Ensure we emit the final response + call_deferred( + "emit_signal", "dialogue_stream_ended", accumulated_content, context, mood_transition + ) + if callback.is_valid(): + call_deferred( + "_call_callback", + callback, + { + "choices": [{"message": {"content": accumulated_content}}], + "npc_context": context, + "mood_transition": mood_transition + } + ) + + +func _handle_stream_error(endpoint: String, error: String, callback: Callable) -> void: + push_error("[GumYum Game] Stream error on %s: %s" % [endpoint, error]) + request_failed.emit(endpoint, error) + if callback.is_valid(): + callback.call({"error": error}) + + +func _call_callback(callback: Callable, data: Dictionary) -> void: + if callback.is_valid(): + callback.call(data) diff --git a/Client.gd.uid b/Client.gd.uid new file mode 100644 index 0000000..eb41b44 --- /dev/null +++ b/Client.gd.uid @@ -0,0 +1 @@ +uid://dosqywchh6bhk diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..46b601e --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ +Copyright 2025 GumYum Author TimeHexOn timehexon@gumyum.com | + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 GumYum Author TimeHexOn timehexon@gumyum.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/NPC.gd b/NPC.gd new file mode 100644 index 0000000..80cfd89 --- /dev/null +++ b/NPC.gd @@ -0,0 +1,394 @@ +extends Resource +class_name GumYumNPC + +## GumYum NPC Class - Elegant chat interface similar to Python client +## +## This class represents a spawned NPC and provides convenient methods +## for chatting with them. Similar to Python's npc.chat.completions() +## +## Usage: +## var npc = await client.characters.spawn("universe-id", 12345, 123456789) +## var response = await npc.chat.completions([{"role": "user", "content": "Hello!"}]) +## print(npc.name + ": " + response.choices[0].message.content) + +signal dialogue_received(response: String, context: Dictionary, mood_transition: Dictionary) +signal dialogue_chunk_received(chunk: String) +signal dialogue_stream_started +signal dialogue_stream_ended( + full_response: String, context: Dictionary, mood_transition: Dictionary +) + +## NPC Data (from API response) +@export var npc_id: int +@export var name: String = "" +@export var profession: String = "" +@export var personality_type: int = 5 +@export var universe_id: String = "" +@export var seed: int = 0 +@export var cached: bool = false +@export var cache_url: String = "" + +## Spawned data (nested) +var spawned: Dictionary = {} # Contains location, mood, stress_level + +## Current mood state +var current_mood: String = "" +var previous_mood: String = "" +var stress_level: int = 5 +var last_mood_change_reason: String = "" +var last_mood_change_confidence: float = 0.0 + +## Client reference (not exported - set when spawned) +var client: Node + +## Chat history for this NPC (array of message dicts with role and content) +var chat_history: Array = [] + +## Internal chat manager for this NPC +var _chat_manager: NPCChatManager + + +## Chat manager class - provides the .chat interface +class NPCChatManager: + var npc: GumYumNPC + + func _init(parent_npc: GumYumNPC): + npc = parent_npc + + ## Main chat completion method - matches Python API + func completions( + messages: Array, + temperature: float = 0.8, + max_tokens: int = 2000, + stream: bool = false, + callback: Callable = Callable() + ) -> void: + if not npc.client: + push_error("NPC has no client reference. Was it spawned properly?") + if callback.is_valid(): + callback.call({"error": "NPC has no client reference"}) + return + + # Build npc_params with this NPC's context including current mood + var npc_params = { + "universe_id": npc.universe_id, + "world_seed": npc.seed, + "npc_id": npc.npc_id, + "current_mood": npc.get_mood(), + "stress_level": npc.get_stress_level() + } + + # Create a wrapper callback to capture history + var history_callback = func(response): + if ( + not response.has("error") + and response.has("choices") + and response["choices"].size() > 0 + ): + # Add user message(s) to history + if messages.size() > 0: + var last_user_msg = messages[messages.size() - 1] + if last_user_msg.has("role") and last_user_msg["role"] == "user": + npc.chat_history.append(last_user_msg) + + # Add assistant response to history + var choice = response["choices"][0] + if choice.has("message"): + var assistant_msg = choice["message"] + if assistant_msg.has("role") and assistant_msg.has("content"): + npc.chat_history.append( + {"role": assistant_msg["role"], "content": assistant_msg["content"]} + ) + + # Call original callback + if callback.is_valid(): + callback.call(response) + + if stream: + # Use streaming chat manager + npc.client.chat.chat_stream( + messages, npc_params, temperature, history_callback, max_tokens + ) + else: + # Use regular chat manager + npc.client.chat.chat(messages, npc_params, temperature, history_callback, max_tokens) + + ## Chat with conversation history - includes all previous messages in this session + func chat_with_history( + message: String, + temperature: float = 0.8, + max_tokens: int = 2000, + stream: bool = false, + callback: Callable = Callable() + ) -> void: + # Build messages array with history plus new message + var messages_with_history = npc.chat_history.duplicate() + messages_with_history.append({"role": "user", "content": message}) + + # Use regular completions which will also update history + completions(messages_with_history, temperature, max_tokens, stream, callback) + + +func _init(npc_data: Dictionary = {}, client_ref: Node = null): + if npc_data.size() > 0: + setup_from_data(npc_data) + if client_ref: + client = client_ref + + # Create chat manager + _chat_manager = NPCChatManager.new(self) + + +## Setup NPC from API response data +func setup_from_data(npc_data: Dictionary) -> void: + # Handle npc_id conversion - API might return String or int + var npc_id_raw = npc_data.get("npc_id", 0) + if typeof(npc_id_raw) == TYPE_STRING: + npc_id = int(npc_id_raw) + else: + npc_id = npc_id_raw + + name = npc_data.get("name", "") + profession = npc_data.get("profession", "") + personality_type = npc_data.get("personality_type", 5) + universe_id = npc_data.get("universe_id", "") + seed = npc_data.get("seed", 0) + cached = npc_data.get("cached", false) + cache_url = npc_data.get("cache_url", "") + + # Handle spawned data + if npc_data.has("spawned"): + spawned = npc_data.spawned + # Initialize mood from spawned data + if spawned.has("mood"): + current_mood = spawned.mood + if spawned.has("stress_level"): + stress_level = spawned.stress_level + + +## Get the chat manager (similar to Python npc.chat) +func get_chat() -> NPCChatManager: + return _chat_manager + + +## Property for easy access (npc.chat.completions()) +var chat: NPCChatManager: + get: + return _chat_manager + + +## Convenience method - direct completions alias (like Python npc.completions()) +func completions( + messages: Array, + temperature: float = 0.8, + max_tokens: int = 2000, + stream: bool = false, + callback: Callable = Callable() +) -> void: + chat.completions(messages, temperature, max_tokens, stream, callback) + + +## Chat with conversation history - includes all previous messages in this session +func chat_with_history( + message: String, + temperature: float = 0.8, + max_tokens: int = 2000, + stream: bool = false, + callback: Callable = Callable() +) -> void: + chat.chat_with_history(message, temperature, max_tokens, stream, callback) + + +## Clear conversation history +func clear_history() -> void: + chat_history.clear() + + +## Convert chat history to JSON string for saving +func chat_history_to_json() -> String: + return JSON.stringify(chat_history) + + +## Convert entire NPC data to dictionary for saving +func to_dict() -> Dictionary: + return { + "npc_id": npc_id, + "name": name, + "profession": profession, + "personality_type": personality_type, + "universe_id": universe_id, + "seed": seed, + "spawned": spawned, + "cached": cached, + "cache_url": cache_url, + "chat_history": chat_history + } + + +## Convert NPC data to JSON string for saving +func to_json() -> String: + return JSON.stringify(to_dict()) + + +## Load NPC data from dictionary (e.g., from saved file) +static func from_dict(data: Dictionary, client_ref: Node = null) -> GumYumNPC: + var npc = GumYumNPC.new({}, client_ref) + npc.npc_id = data.get("npc_id", 0) + npc.name = data.get("name", "") + npc.profession = data.get("profession", "") + npc.personality_type = data.get("personality_type", 5) + npc.universe_id = data.get("universe_id", "") + npc.seed = data.get("seed", 0) + npc.spawned = data.get("spawned", {}) + npc.cached = data.get("cached", false) + npc.cache_url = data.get("cache_url", "") + npc.chat_history = data.get("chat_history", []) + return npc + + +## Load NPC data from JSON string +static func from_json(json_str: String, client_ref: Node = null) -> GumYumNPC: + var json = JSON.new() + var parse_result = json.parse(json_str) + if parse_result != OK: + push_error("Failed to parse NPC JSON: " + json.get_error_message()) + return null + return from_dict(json.data, client_ref) + + +## Save NPC to file (example helper) +func save_to_file(path: String) -> Error: + var file = FileAccess.open(path, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_string(to_json()) + file.close() + return OK + + +## Load NPC from file (example helper) +static func load_from_file(path: String, client_ref: Node = null) -> GumYumNPC: + var file = FileAccess.open(path, FileAccess.READ) + if file == null: + push_error("Failed to open NPC file: " + path) + return null + var json_str = file.get_as_text() + file.close() + return from_json(json_str, client_ref) + + +## Get NPC's current location +func get_location() -> String: + return spawned.get("location", "unknown") + + +## Get NPC's current mood +func get_mood() -> String: + if current_mood != "": + return current_mood + return spawned.get("mood", "neutral") + + +## Get NPC's stress level +func get_stress_level() -> int: + return stress_level + + +## Get previous mood (before last change) +func get_previous_mood() -> String: + return previous_mood + + +## Get last mood change reason +func get_last_mood_change_reason() -> String: + return last_mood_change_reason + + +## Get last mood change confidence +func get_last_mood_change_confidence() -> float: + return last_mood_change_confidence + + +## Check if mood has changed +func has_mood_changed() -> bool: + return previous_mood != "" and previous_mood != current_mood + + +## Get detailed info string +func get_info() -> String: + return ( + "%s the %s (Type %d) at %s, feeling %s" + % [name, profession, personality_type, get_location(), get_mood()] + ) + + +## Save this NPC to the server (persistent storage) +func save_to_server(custom_name: String = "", callback: Callable = Callable()) -> void: + if not client: + push_error("Cannot save NPC: no client reference") + return + + var data = {"universe_id": universe_id, "world_seed": seed, "npc_id": npc_id} + + if custom_name != "": + data["custom_name"] = custom_name + + client.request(HTTPClient.METHOD_POST, "npc/save", data, {}, callback) + + +## Connect to client's dialogue signals to forward them +func _connect_client_signals() -> void: + if client and not client.dialogue_received.is_connected(_on_dialogue_received): + client.dialogue_received.connect(_on_dialogue_received) + client.dialogue_chunk_received.connect(_on_dialogue_chunk_received) + client.dialogue_stream_started.connect(_on_dialogue_stream_started) + client.dialogue_stream_ended.connect(_on_dialogue_stream_ended) + + +func _on_dialogue_received( + response: String, context: Dictionary, mood_transition: Dictionary +) -> void: + # Only emit if this response is for our NPC + if context.has("npc_id") and context.npc_id == npc_id: + # Update mood data if transition occurred + _update_mood_from_transition(mood_transition) + dialogue_received.emit(response, context, mood_transition) + + +func _on_dialogue_chunk_received(chunk: String) -> void: + dialogue_chunk_received.emit(chunk) + + +func _on_dialogue_stream_started() -> void: + dialogue_stream_started.emit() + + +func _on_dialogue_stream_ended( + full_response: String, context: Dictionary, mood_transition: Dictionary +) -> void: + # Only emit if this response is for our NPC + if context.has("npc_id") and context.npc_id == npc_id: + # Update mood data if transition occurred + _update_mood_from_transition(mood_transition) + dialogue_stream_ended.emit(full_response, context, mood_transition) + + +## Update internal mood state from mood transition data +func _update_mood_from_transition(mood_transition: Dictionary) -> void: + if mood_transition.is_empty(): + return + + # Store previous mood if we're changing + if mood_transition.has("new_mood") and mood_transition.new_mood != current_mood: + previous_mood = current_mood + current_mood = mood_transition.new_mood + + # Update stress level + if mood_transition.has("stress_level"): + stress_level = mood_transition.stress_level + + # Store mood change metadata + if mood_transition.has("confidence"): + last_mood_change_confidence = mood_transition.confidence + if mood_transition.has("reasoning"): + last_mood_change_reason = mood_transition.reasoning diff --git a/NPC.gd.uid b/NPC.gd.uid new file mode 100644 index 0000000..8b80300 --- /dev/null +++ b/NPC.gd.uid @@ -0,0 +1 @@ +uid://cr3364bton8l6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aed9a72 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# GumYum NPC Game Client for Godot + +Bring NPCs to life in your Godot games with AI-powered dialogue and personality systems. + +## Features + +- **API Key Authentication**: Simple setup with public/secret key pairs +- **Character Spawning**: Create NPCs from any GumYum universe +- **Natural Dialogue**: Chat with NPCs that remember context and have personalities +- **Streaming Support**: Real-time typewriter effect for dialogue +- **Auto-Retry**: Built-in error handling and request retry +- **Lightweight**: Minimal footprint, perfect for production builds + +## Installation + +### Method 1: Clone from GitLab (Recommended) + +Since the client is stable & shouldn't be change, we recommend cloning & copying: + +1. Clone the public repository: + ```bash + git clone https://git.unturf.com/engineering/gumyum/npc-clients.git + ``` +2. Copy the addon to your project: + ```bash + cp -r npc-clients/godot/gumyum_npc_game_client /path/to/your/project/addons/ + ``` +3. Enable the plugin in Project Settings → Plugins → GumYum NPC Game Client +4. The `GumyumClient` singleton will be available globally + +If you ever need to you can `git pull` the latest clients and patch game files as needed. + +### Method 2: Direct Download Zip + +1. Download the zip of all the client SDKs + + * https://git.unturf.com/engineering/gumyum/npc-clients/-/archive/main/npc-clients-main.zip + +2. Decompress & copy the `godot/gumyum_npc_game_client` directory into your project's `addons/` directory +3. Enable the plugin in Project Settings → Plugins → GumYum NPC Game Client + +### Method 3: Asset Library (Coming Soon) + +The GumYum NPC addon will be available in the Godot Asset Library. + +## Quickstart + +Create a working chat UI with NPCs in under 5 minutes: + +```gdscript +# Quickstart: Chat UI with NPCs +# Save as ChatDemo.gd and attach to a Control node +extends Control + +@onready var chat_display = RichTextLabel.new() +@onready var input_field = LineEdit.new() +@onready var send_button = Button.new() + +var current_npc: GumYumNPC + +func _ready(): + # Setup UI + setup_ui() + + # Configure API + GumyumClient.api_key = "gumyum-pk-your-public-key" + GumyumClient.api_secret = "gumyum-sk-your-secret-key" + GumyumClient.base_url = "https://npc.gumyum.com" + + # Spawn a random NPC + add_system_message("Connecting to GumYum API...") + GumyumClient.npcs.spawn( + "kingdom", # universe + 42, # seed + null, # npc_id (null = random) + _on_npc_spawned + ) + +func _on_npc_spawned(npc: GumYumNPC): + if npc == null: + add_system_message("[color=red]Failed to spawn NPC![/color]") + return + + current_npc = npc + add_system_message( + "[color=green]Connected to " + npc.name + " the " + npc.profession + "[/color]" + ) + +func send_message(text: String): + if text.is_empty() or current_npc == null: + return + + add_player_message(text) + + # Use chat_with_history for easy conversation tracking + current_npc.chat_with_history( + text, 0.8, 1500, false, + _on_chat_response + ) + +func _on_chat_response(response): + if response.has("error"): + add_system_message("[color=red]Error: " + response.error + "[/color]") + else: + var content = response.choices[0].message.content + add_npc_message(content) +``` + +## API Reference + +### Configuration + +```gdscript +# Set your API credentials +GumyumClient.api_key = "gumyum-pk-your-public-key" +GumyumClient.api_secret = "gumyum-sk-your-secret-key" + +# Optional: Set custom API endpoint +GumyumClient.base_url = "https://npc.gumyum.com" + +# Optional: Enable debug mode +GumyumClient.debug_mode = true +``` + +### Spawning NPCs + +#### Method 1: Spawn by ID (Specific or Random) + +```gdscript +# Spawn a specific NPC +GumyumClient.npcs.spawn( + "kingdom", # universe_id + 42, # world_seed + 123456789, # npc_id (specific) + _on_npc_spawned +) + +# Spawn a random NPC (API generates from seed) +GumyumClient.npcs.spawn( + "kingdom", # universe_id + 42, # world_seed + null, # npc_id (null/omit for random) + _on_npc_spawned +) +``` + +#### Method 2: Spawn with Filters + +```gdscript +# Find NPCs by profession and location +GumyumClient.npcs.spawn_filtered( + "kingdom", 42, + { + "profession": ["warrior", "knight", "guard"], + "sex": ["female"], + "location": ["castle", "barracks"] + }, + _on_npc_spawned +) + +# Filter by personality type and mood +GumyumClient.npcs.spawn_filtered( + "blade-runner", 42, + { + "personality_type": [8, 3, 5], + "mood": ["paranoid", "vigilant"], + "location": ["police_station"] + }, + _on_npc_spawned +) +``` + +### Chatting with NPCs + +#### Simple Chat with History + +```gdscript +# chat_with_history automatically tracks the conversation +current_npc.chat_with_history( + "Hello there!", # message + 0.8, # temperature (0.0-1.0) + 1500, # max_tokens + false, # streaming + _on_chat_response # callback +) +``` + +#### Advanced Chat API + +```gdscript +# Full control over messages +current_npc.chat.completions( + [ + {"role": "system", "content": "You are in a tavern"}, + {"role": "user", "content": "What's on the menu?"} + ], + 0.8, # temperature + 2000, # max_tokens + false, # streaming + _on_chat_response +) +``` + +### Streaming Responses + +Enable real-time streaming for a typewriter effect: + +```gdscript +func setup_streaming(): + # Connect streaming signals + npc.dialogue_chunk_received.connect(_on_chunk_received) + npc.dialogue_stream_started.connect(_on_stream_started) + npc.dialogue_stream_ended.connect(_on_stream_ended) + + # Enable streaming in chat + npc.chat_with_history( + "Tell me a story", + 0.8, 1500, + true, # streaming enabled! + _on_final_response + ) + +func _on_chunk_received(chunk: String): + dialogue_box.text += chunk +``` + +## Mood Transitions + +The GumYum API tracks NPC moods and can detect mood changes during conversations. Both streaming and non-streaming responses include mood transition data: + +```gdscript +func _on_dialogue_received(response): + if response.has("choices") and response.choices.size() > 0: + var content = response.choices[0]["message"]["content"] + # Display the dialogue + + # Check for mood changes + if response.has("mood_transition"): + var mood_data = response.mood_transition + var new_mood = mood_data.get("new_mood", "") + var old_mood = mood_data.get("old_mood", "") + var confidence = mood_data.get("confidence", 0.0) + var reasoning = mood_data.get("reasoning", "") + + if new_mood and new_mood != old_mood: + print("NPC mood changed from %s to %s (confidence: %.1f)" % [old_mood, new_mood, confidence]) + print("Reason: %s" % reasoning) +``` + +Mood transition data includes: +- `new_mood`: The NPC's current mood after this response +- `old_mood`: The NPC's previous mood +- `confidence`: How confident the AI is about this mood change (0.0-1.0) +- `reasoning`: AI's explanation for why the mood changed +- `stress_level`: Current stress level (1-10) + +## NPC Properties + +Each `GumYumNPC` object has these properties: + +```gdscript +# Basic info +npc.name # "Aldric" +npc.profession # "blacksmith" +npc.personality_type # 8 (Enneagram type) + +# Identity +npc.npc_id # 123456789 +npc.universe_id # "kingdom" +npc.seed # 42 + +# Current state +npc.get_location() # "forge" +npc.get_mood() # "content" +npc.get_stress_level() # 3 + +# Conversation history +npc.chat_history # Array of messages +``` + +## Universe Management (Limited) + +With API keys, universe management is limited: + +```gdscript +# List public universes (works with API keys) +GumyumClient.universes.list_public( + func(universes): + for u in universes: + print(u.name, " - ", u.description) +) + +# These methods require user authentication and will NOT work: +# - universes.list() # List user's universes +# - universes.list_user() # Same as list() +# - universes.copy() # Copy a universe to user vault +# - universes.get_universe() # Get universe details +``` + +**Note**: Games using API keys should hardcode their universe ID when spawning NPCs. + + +## Error Handling + +All callbacks receive either a valid response or an error dictionary: + +```gdscript +func _on_response(response): + if response.has("error"): + print("Error: ", response.error) + # Handle error + else: + # Process successful response + pass +``` + +## Signals + +The client emits these signals: + +- `character_spawned(character: GumYumNPC)` - When an NPC is successfully spawned +- `dialogue_received(response: String, context: Dictionary, mood_transition: Dictionary)` - Regular chat response with mood data +- `dialogue_chunk_received(chunk: String)` - For streaming responses +- `dialogue_stream_started` - When streaming begins +- `dialogue_stream_ended(full_response: String, context: Dictionary, mood_transition: Dictionary)` - Streaming complete with mood data +- `request_completed(endpoint: String, data: Dictionary)` +- `request_failed(endpoint: String, error: String)` + +## Examples + +Check the `examples/` folder for: + +- `QuickstartChat.gd` - Simple programmatic UI example (matches the docs quickstart) +- `NpcChatExample.gd` + `.tscn` - Full scene-based example with streaming support + +## Requirements + +- Godot 4.0 or later +- GumYum API keys (get one at [npc.gumyum.com](https://npc.gumyum.com)) +- Internet connection for API requests + +## Support + +- [Discord Community](https://discord.com/invite/TZPmQdPj8a) +- [GitLab Repository](https://git.unturf.com/engineering/gumyum/npc-clients) diff --git a/examples/NpcChatExample.gd b/examples/NpcChatExample.gd new file mode 100644 index 0000000..2c256d7 --- /dev/null +++ b/examples/NpcChatExample.gd @@ -0,0 +1,171 @@ +extends Control + +## Simple example of chatting with an NPC +## +## This example shows how to: +## - Spawn an NPC +## - Send messages using chat_with_history +## - Handle streaming responses + +@export var api_base_url: String = "https://npc.gumyum.com" +@export var api_key: String = "gumyum-pk-your-public-key" +@export var api_secret: String = "gumyum-sk-your-secret-key" +@export var universe_id: String = "kingdom" +@export var enable_streaming: bool = true + +var current_npc: GumYumNPC +var is_waiting_response: bool = false + +@onready var chat_input: LineEdit = $VBoxContainer/ChatInput +@onready var chat_display: RichTextLabel = $VBoxContainer/ChatDisplay +@onready var npc_name_label: Label = $VBoxContainer/NPCInfo/NameLabel +@onready var npc_mood_label: Label = $VBoxContainer/NPCInfo/MoodLabel +@onready var send_button: Button = $VBoxContainer/SendButton + + +func _ready(): + # Connect UI signals + send_button.pressed.connect(_on_send_pressed) + chat_input.text_submitted.connect(_on_text_submitted) + + # Initialize GumYum client + _setup_client() + + # Spawn an NPC when ready + _spawn_random_npc() + + +func _setup_client(): + # Set up authentication + GumyumClient.base_url = api_base_url + GumyumClient.api_key = api_key + GumyumClient.api_secret = api_secret + + # Optional: Enable debug mode + GumyumClient.debug_mode = true + + chat_display.append_bbcode("[color=green]Connected to GumYum API[/color]\n") + + +func _spawn_random_npc(): + chat_display.append_bbcode("[color=yellow]Spawning NPC...[/color]\n") + + # Use the npcs manager to spawn + # The response will be a GumYumNPC object + GumyumClient.npcs.spawn(universe_id, 42, null, _on_npc_spawned) # universe # seed (consistent with docs) # Random NPC (null) + + +func _on_npc_spawned(npc: GumYumNPC): + if npc == null: + push_error("Failed to spawn NPC") + chat_display.append_bbcode("[color=red]Failed to spawn NPC![/color]\n") + return + + current_npc = npc + + # Connect to NPC's dialogue signals + npc.dialogue_received.connect(_on_dialogue_received) + npc.dialogue_stream_started.connect(_on_stream_started) + npc.dialogue_chunk_received.connect(_on_stream_chunk) + npc.dialogue_stream_ended.connect(_on_stream_ended) + + # Update UI + npc_name_label.text = "Name: " + npc.name + npc_mood_label.text = "Mood: " + npc.get_mood() + + # Announce spawn + chat_display.append_bbcode("\n[b][color=cyan]" + npc.name + " appears![/color][/b]\n") + chat_display.append_bbcode("[i]A " + npc.profession + " approaches you.[/i]\n\n") + + # Enable chat + chat_input.editable = true + send_button.disabled = false + + +func _on_send_pressed(): + _send_message() + + +func _on_text_submitted(_text: String): + _send_message() + + +func _send_message(): + var message = chat_input.text.strip_edges() + if message.is_empty() or is_waiting_response: + return + + # Clear input + chat_input.text = "" + + # Add user message to display + chat_display.append_bbcode("[b]You:[/b] " + message + "\n") + + # Disable input while processing + is_waiting_response = true + chat_input.editable = false + send_button.disabled = true + + # Send message using NPC's chat_with_history + current_npc.chat_with_history( + message, # message text + 0.8, # temperature + 2000, # max_tokens + enable_streaming, # streaming + _on_dialogue_received if not enable_streaming else Callable() + ) + + +func _on_dialogue_received(response): + # Handle the API response format + if response.has("error"): + chat_display.append_bbcode("[color=red]Error: " + response.error + "[/color]\n") + elif response.has("choices") and response.choices.size() > 0: + var content = response.choices[0]["message"]["content"] + chat_display.append_bbcode("[b][color=cyan]" + current_npc.name + ":[/color][/b] ") + chat_display.append_bbcode(content + "\n\n") + + # Check for mood transition in response + if response.has("mood_transition"): + var new_mood = response.mood_transition.get("new_mood", "") + if new_mood: + npc_mood_label.text = "Mood: " + new_mood + chat_display.append_bbcode("[i][color=gray](mood: " + new_mood + ")[/color][/i]\n") + + # Re-enable input + is_waiting_response = false + chat_input.editable = true + send_button.disabled = false + chat_input.grab_focus() + + +var stream_buffer: String = "" + + +func _on_stream_started(): + stream_buffer = "" + chat_display.append_bbcode("[b][color=cyan]" + current_npc.name + ":[/color][/b] ") + + +func _on_stream_chunk(chunk: String): + stream_buffer += chunk + # Clear and rewrite the current line with updated buffer + # Note: This is a simple approach - you may want more sophisticated handling + chat_display.append_bbcode(chunk) + + +func _on_stream_ended(_full_response: String, _context: Dictionary, mood_transition: Dictionary): + chat_display.append_bbcode("\n\n") + + # Handle mood change + if mood_transition and not mood_transition.is_empty(): + var new_mood = mood_transition.get("new_mood", "") + if new_mood: + npc_mood_label.text = "Mood: " + new_mood + chat_display.append_bbcode("[i][color=gray](mood: " + new_mood + ")[/color][/i]\n") + + # Re-enable input + is_waiting_response = false + chat_input.editable = true + send_button.disabled = false + chat_input.grab_focus() diff --git a/examples/NpcChatExample.gd.uid b/examples/NpcChatExample.gd.uid new file mode 100644 index 0000000..84661a6 --- /dev/null +++ b/examples/NpcChatExample.gd.uid @@ -0,0 +1 @@ +uid://cudrnyeh2ffk0 diff --git a/examples/NpcChatExample.tscn b/examples/NpcChatExample.tscn new file mode 100644 index 0000000..82c2127 --- /dev/null +++ b/examples/NpcChatExample.tscn @@ -0,0 +1,61 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://addons/gumyum_npc_game_client/examples/NpcChatExample.gd" id="1"] + +[node name="NpcChatExample" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 20.0 +margin_top = 20.0 +margin_right = -20.0 +margin_bottom = -20.0 + +[node name="Title" type="Label" parent="VBoxContainer"] +layout_mode = 2 +text = "GumYum NPC Chat Example" +theme_override_font_sizes/font_size = 24 + +[node name="HSeparator" type="HSeparator" parent="VBoxContainer"] +layout_mode = 2 + +[node name="NPCInfo" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="NameLabel" type="Label" parent="VBoxContainer/NPCInfo"] +layout_mode = 2 +text = "Name: Loading..." + +[node name="VSeparator" type="VSeparator" parent="VBoxContainer/NPCInfo"] +layout_mode = 2 +custom_minimum_size = Vector2(20, 0) + +[node name="MoodLabel" type="Label" parent="VBoxContainer/NPCInfo"] +layout_mode = 2 +text = "Mood: Unknown" + +[node name="ChatDisplay" type="RichTextLabel" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 +bbcode_enabled = true +text = "[color=gray]Initializing GumYum NPC API...[/color]" + +[node name="ChatInput" type="LineEdit" parent="VBoxContainer"] +layout_mode = 2 +placeholder_text = "Type your message here..." +editable = false + +[node name="SendButton" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Send" +disabled = true + +[node name="Instructions" type="Label" parent="VBoxContainer"] +layout_mode = 2 +text = "Press PageUp to spawn a new random NPC" +theme_override_font_sizes/font_size = 12 +theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1) \ No newline at end of file diff --git a/examples/QuickstartChat.gd b/examples/QuickstartChat.gd new file mode 100644 index 0000000..0e27d25 --- /dev/null +++ b/examples/QuickstartChat.gd @@ -0,0 +1,182 @@ +# Quickstart: Chat UI with NPCs (with Streaming Support) +# Save as QuickstartChat.gd and attach to a Control node +extends Control + +@onready var chat_display = RichTextLabel.new() +@onready var input_field = LineEdit.new() +@onready var send_button = Button.new() +@onready var streaming_toggle = CheckBox.new() + +var current_npc: GumYumNPC +var is_streaming: bool = false + + +func _ready(): + # Setup UI + setup_ui() + + # Configure API + GumyumClient.api_key = "gumyum-pk-your-public-key" + GumyumClient.api_secret = "gumyum-sk-your-secret-key" + GumyumClient.base_url = "https://npc.gumyum.com" + + # Spawn a random NPC + add_system_message("Connecting to GumYum API...") + GumyumClient.npcs.spawn("kingdom", 42, null, _on_npc_spawned) # universe # seed # npc_id (null = random) + + +func setup_ui(): + # Create a VBox container + var vbox = VBoxContainer.new() + vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(vbox) + + # Title + var title = Label.new() + title.text = "GumYum NPC Chat (Quickstart)" + title.add_theme_font_size_override("font_size", 24) + vbox.add_child(title) + + # Streaming toggle + streaming_toggle.text = "Enable streaming (typewriter effect)" + streaming_toggle.toggled.connect(_on_streaming_toggled) + vbox.add_child(streaming_toggle) + + # Chat display (scrollable) + chat_display.bbcode_enabled = true + chat_display.scroll_following = true + chat_display.custom_minimum_size = Vector2(0, 400) + vbox.add_child(chat_display) + + # Input container + var hbox = HBoxContainer.new() + vbox.add_child(hbox) + + # Input field + input_field.placeholder_text = "Type your message..." + input_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL + input_field.text_submitted.connect(_on_text_submitted) + hbox.add_child(input_field) + + # Send button + send_button.text = "Send" + send_button.pressed.connect(_on_send_pressed) + hbox.add_child(send_button) + + +func _on_npc_spawned(npc: GumYumNPC): + if npc == null: + add_system_message("[color=red]Failed to spawn NPC![/color]") + return + + current_npc = npc + + # Connect streaming signals for this NPC + current_npc.dialogue_stream_started.connect(_on_stream_started) + current_npc.dialogue_chunk_received.connect(_on_chunk_received) + current_npc.dialogue_stream_ended.connect(_on_stream_ended) + + add_system_message( + "[color=green]Connected to " + npc.name + " the " + npc.profession + "[/color]" + ) + + input_field.editable = true + send_button.disabled = false + input_field.grab_focus() + + +func _on_streaming_toggled(button_pressed: bool): + add_system_message( + "[color=yellow]Streaming " + ("enabled" if button_pressed else "disabled") + "[/color]" + ) + + +func _on_text_submitted(text: String): + send_message(text) + + +func _on_send_pressed(): + send_message(input_field.text) + + +func send_message(text: String): + if text.is_empty() or current_npc == null or is_streaming: + return + + add_player_message(text) + input_field.text = "" + + # Disable input while processing + input_field.editable = false + send_button.disabled = true + + # Check if streaming is enabled + var use_streaming = streaming_toggle.button_pressed + + # Send to NPC with or without streaming + current_npc.chat_with_history(text, 0.8, 1500, use_streaming, _on_chat_response) # temperature # max_tokens # streaming based on toggle + + # Set streaming flag if streaming is enabled + if use_streaming: + is_streaming = true + + +func _on_chat_response(response): + if response.has("error"): + add_system_message("[color=red]Error: " + response.error + "[/color]") + # Re-enable input on error + is_streaming = false + input_field.editable = true + send_button.disabled = false + input_field.grab_focus() + elif not streaming_toggle.button_pressed: + # Non-streaming response + if response.has("choices") and response.choices.size() > 0: + var content = response.choices[0]["message"]["content"] + add_npc_message(content) + + # Re-enable input + input_field.editable = true + send_button.disabled = false + input_field.grab_focus() + + +# Streaming-specific functions +func _on_stream_started(): + # Show NPC is typing + chat_display.append_text("[b][color=cyan]" + current_npc.name + ":[/color][/b] ") + + +func _on_chunk_received(chunk: String): + # Append each chunk as it arrives + chat_display.append_text(chunk) + # Auto-scroll to bottom + chat_display.scroll_to_line(chat_display.get_line_count() - 1) + + +func _on_stream_ended(_full_response: String, _context: Dictionary, mood_transition: Dictionary): + chat_display.append_text("\n\n") + + # Check for mood changes + if mood_transition.has("new_mood"): + chat_display.append_text("[i](mood: " + mood_transition.new_mood + ")[/i]\n\n") + + # Re-enable input + is_streaming = false + input_field.editable = true + send_button.disabled = false + input_field.grab_focus() + + +func add_player_message(text: String): + chat_display.append_text("[b]You:[/b] " + text + "\n\n") + + +func add_npc_message(text: String): + chat_display.append_text( + "[b][color=cyan]" + current_npc.name + ":[/color][/b] " + text + "\n\n" + ) + + +func add_system_message(text: String): + chat_display.append_text("[i]" + text + "[/i]\n\n") diff --git a/examples/QuickstartChat.gd.uid b/examples/QuickstartChat.gd.uid new file mode 100644 index 0000000..79b9ac7 --- /dev/null +++ b/examples/QuickstartChat.gd.uid @@ -0,0 +1 @@ +uid://tw52npn3dc2t diff --git a/plugin.cfg b/plugin.cfg new file mode 100644 index 0000000..4c7e33e --- /dev/null +++ b/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="GumYum NPC Game Client" +description="Simplified GumYum NPC client for shipped games. API key authentication only with essential features for AI-powered NPCs. Minimal footprint, perfect for production builds." +author="GumYum Games" +version="1.0.0" +script="plugin.gd" \ No newline at end of file diff --git a/plugin.gd b/plugin.gd new file mode 100644 index 0000000..cb94890 --- /dev/null +++ b/plugin.gd @@ -0,0 +1,17 @@ +@tool +extends EditorPlugin + +const AUTOLOAD_NAME = "GumYum" +const AUTOLOAD_PATH = "res://addons/gumyum_npc_game_client/Client.gd" + + +func _enter_tree(): + # Add the GumYum game client as an autoload singleton + add_autoload_singleton(AUTOLOAD_NAME, AUTOLOAD_PATH) + print("GumYum NPC Game Client plugin activated") + + +func _exit_tree(): + # Remove the autoload singleton + remove_autoload_singleton(AUTOLOAD_NAME) + print("GumYum NPC Game Client plugin deactivated") diff --git a/plugin.gd.uid b/plugin.gd.uid new file mode 100644 index 0000000..6d84909 --- /dev/null +++ b/plugin.gd.uid @@ -0,0 +1 @@ +uid://j7o4o2y3aymy diff --git a/tests/test_npc_features.gd b/tests/test_npc_features.gd new file mode 100644 index 0000000..e7ae9ad --- /dev/null +++ b/tests/test_npc_features.gd @@ -0,0 +1,218 @@ +extends GutTest +# Unit tests for NPC chat history and serialization features + +var mock_client +var test_npc + + +func before_each(): + # Create a mock client + mock_client = Node.new() + + # Create a test NPC with sample data + var npc_data = { + "npc_id": 123456789, + "name": "Test Merchant", + "profession": "merchant", + "personality_type": 7, + "universe_id": "test-universe", + "seed": 42, + "spawned": {"location": "market", "mood": "friendly", "stress_level": 3}, + "cached": false, + "cache_url": "" + } + test_npc = GumYumNPC.new(npc_data, mock_client) + + +func after_each(): + if mock_client: + mock_client.queue_free() + + +func test_chat_history_starts_empty(): + assert_eq(test_npc.chat_history.size(), 0, "Chat history should start empty") + + +func test_chat_history_tracking(): + # Simulate adding messages to history + test_npc.chat_history.append({"role": "user", "content": "Hello there!"}) + test_npc.chat_history.append({"role": "assistant", "content": "Greetings, traveler!"}) + + assert_eq(test_npc.chat_history.size(), 2, "Should have 2 messages") + assert_eq(test_npc.chat_history[0]["role"], "user") + assert_eq(test_npc.chat_history[1]["content"], "Greetings, traveler!") + + +func test_clear_history(): + # Add some messages + test_npc.chat_history.append({"role": "user", "content": "Test 1"}) + test_npc.chat_history.append({"role": "assistant", "content": "Response 1"}) + + # Clear history + test_npc.clear_history() + + assert_eq(test_npc.chat_history.size(), 0, "History should be empty after clear") + + +func test_chat_history_to_json(): + # Add test messages + test_npc.chat_history.append({"role": "user", "content": "What are you selling?"}) + test_npc.chat_history.append({"role": "assistant", "content": "Fine weapons and armor!"}) + + var json_str = test_npc.chat_history_to_json() + assert_ne(json_str, "", "JSON should not be empty") + + # Parse it back to verify + var json = JSON.new() + var parse_result = json.parse(json_str) + assert_eq(parse_result, OK, "Should parse valid JSON") + assert_eq(json.data.size(), 2, "Should have 2 messages") + assert_eq(json.data[0]["content"], "What are you selling?") + + +func test_to_dict(): + # Add some chat history + test_npc.chat_history.append({"role": "user", "content": "Hello"}) + + var dict = test_npc.to_dict() + + # Verify all fields are present + assert_has(dict, "npc_id") + assert_has(dict, "name") + assert_has(dict, "profession") + assert_has(dict, "personality_type") + assert_has(dict, "universe_id") + assert_has(dict, "seed") + assert_has(dict, "spawned") + assert_has(dict, "chat_history") + + # Verify values + assert_eq(dict["npc_id"], 123456789) + assert_eq(dict["name"], "Test Merchant") + assert_eq(dict["profession"], "merchant") + assert_eq(dict["chat_history"].size(), 1) + + +func test_to_json(): + var json_str = test_npc.to_json() + assert_ne(json_str, "", "JSON should not be empty") + + # Parse to verify it's valid + var json = JSON.new() + var parse_result = json.parse(json_str) + assert_eq(parse_result, OK, "Should produce valid JSON") + assert_eq(json.data["name"], "Test Merchant") + + +func test_from_dict(): + # Create dict with test data + var test_data = { + "npc_id": 987654321, + "name": "Guard Captain", + "profession": "guard", + "personality_type": 1, + "universe_id": "kingdom", + "seed": 100, + "spawned": {"location": "castle", "mood": "stern", "stress_level": 5}, + "chat_history": + [ + {"role": "user", "content": "Who goes there?"}, + {"role": "assistant", "content": "State your business!"} + ] + } + + var loaded_npc = GumYumNPC.from_dict(test_data, mock_client) + + assert_not_null(loaded_npc, "Should create NPC from dict") + assert_eq(loaded_npc.npc_id, 987654321) + assert_eq(loaded_npc.name, "Guard Captain") + assert_eq(loaded_npc.profession, "guard") + assert_eq(loaded_npc.chat_history.size(), 2) + assert_eq(loaded_npc.client, mock_client) + + +func test_from_json(): + # Create JSON string + var json_data = { + "npc_id": 111222333, + "name": "Wise Elder", + "profession": "sage", + "personality_type": 5, + "universe_id": "fantasy", + "seed": 777, + "spawned": {"location": "temple", "mood": "contemplative"}, + "chat_history": [] + } + var json_str = JSON.stringify(json_data) + + var loaded_npc = GumYumNPC.from_json(json_str, mock_client) + + assert_not_null(loaded_npc, "Should create NPC from JSON") + assert_eq(loaded_npc.name, "Wise Elder") + assert_eq(loaded_npc.universe_id, "fantasy") + + +func test_from_json_invalid(): + var invalid_json = "{ invalid json [" + var loaded_npc = GumYumNPC.from_json(invalid_json, mock_client) + + assert_null(loaded_npc, "Should return null for invalid JSON") + + +func test_round_trip_serialization(): + # Add chat history + test_npc.chat_history.append({"role": "user", "content": "Test message 1"}) + test_npc.chat_history.append({"role": "assistant", "content": "Test response 1"}) + + # Convert to JSON and back + var json_str = test_npc.to_json() + var restored_npc = GumYumNPC.from_json(json_str, mock_client) + + assert_not_null(restored_npc, "Should restore NPC") + assert_eq(restored_npc.npc_id, test_npc.npc_id) + assert_eq(restored_npc.name, test_npc.name) + assert_eq(restored_npc.chat_history.size(), 2) + assert_eq(restored_npc.chat_history[0]["content"], "Test message 1") + + +func test_save_to_file(): + # Note: This test requires file system access + var test_path = "user://test_npc_save.json" + + # Add some data + test_npc.chat_history.append({"role": "user", "content": "Save test"}) + + var result = test_npc.save_to_file(test_path) + assert_eq(result, OK, "Should save successfully") + + # Verify file exists + var file = FileAccess.open(test_path, FileAccess.READ) + assert_not_null(file, "File should exist") + if file: + file.close() + + # Clean up + DirAccess.remove_absolute(test_path) + + +func test_load_from_file(): + # First save an NPC + var test_path = "user://test_npc_load.json" + test_npc.chat_history.append({"role": "user", "content": "Load test"}) + test_npc.save_to_file(test_path) + + # Load it back + var loaded_npc = GumYumNPC.load_from_file(test_path, mock_client) + + assert_not_null(loaded_npc, "Should load NPC from file") + assert_eq(loaded_npc.name, test_npc.name) + assert_eq(loaded_npc.chat_history.size(), 1) + assert_eq(loaded_npc.chat_history[0]["content"], "Load test") + + # Clean up + DirAccess.remove_absolute(test_path) + + +func test_load_from_nonexistent_file(): + var loaded_npc = GumYumNPC.load_from_file("user://does_not_exist.json", mock_client) + assert_null(loaded_npc, "Should return null for nonexistent file") diff --git a/tests/test_npc_features.gd.uid b/tests/test_npc_features.gd.uid new file mode 100644 index 0000000..8eefc76 --- /dev/null +++ b/tests/test_npc_features.gd.uid @@ -0,0 +1 @@ +uid://b5q5lpoo3s2dj