Initial commit of Godot addon for GumYum NPC client

This commit is contained in:
Russell Ballestrini 2025-09-15 09:59:44 -04:00
commit 8ddda5d5ed
16 changed files with 2554 additions and 0 deletions

951
Client.gd Normal file
View file

@ -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)

1
Client.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://dosqywchh6bhk

202
LICENSE Normal file
View file

@ -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.

394
NPC.gd Normal file
View file

@ -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

1
NPC.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cr3364bton8l6

345
README.md Normal file
View file

@ -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)

171
examples/NpcChatExample.gd Normal file
View file

@ -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()

View file

@ -0,0 +1 @@
uid://cudrnyeh2ffk0

View file

@ -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)

182
examples/QuickstartChat.gd Normal file
View file

@ -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")

View file

@ -0,0 +1 @@
uid://tw52npn3dc2t

7
plugin.cfg Normal file
View file

@ -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"

17
plugin.gd Normal file
View file

@ -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")

1
plugin.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://j7o4o2y3aymy

218
tests/test_npc_features.gd Normal file
View file

@ -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")

View file

@ -0,0 +1 @@
uid://b5q5lpoo3s2dj