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