Initial commit of Godot addon for GumYum NPC client
This commit is contained in:
commit
8ddda5d5ed
16 changed files with 2554 additions and 0 deletions
951
Client.gd
Normal file
951
Client.gd
Normal 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
1
Client.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dosqywchh6bhk
|
||||
202
LICENSE
Normal file
202
LICENSE
Normal 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
394
NPC.gd
Normal 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
1
NPC.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cr3364bton8l6
|
||||
345
README.md
Normal file
345
README.md
Normal 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
171
examples/NpcChatExample.gd
Normal 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()
|
||||
1
examples/NpcChatExample.gd.uid
Normal file
1
examples/NpcChatExample.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cudrnyeh2ffk0
|
||||
61
examples/NpcChatExample.tscn
Normal file
61
examples/NpcChatExample.tscn
Normal 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
182
examples/QuickstartChat.gd
Normal 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")
|
||||
1
examples/QuickstartChat.gd.uid
Normal file
1
examples/QuickstartChat.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://tw52npn3dc2t
|
||||
7
plugin.cfg
Normal file
7
plugin.cfg
Normal 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
17
plugin.gd
Normal 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
1
plugin.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://j7o4o2y3aymy
|
||||
218
tests/test_npc_features.gd
Normal file
218
tests/test_npc_features.gd
Normal 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")
|
||||
1
tests/test_npc_features.gd.uid
Normal file
1
tests/test_npc_features.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b5q5lpoo3s2dj
|
||||
Loading…
Add table
Add a link
Reference in a new issue