npc-clients-godot/NPC.gd

394 lines
11 KiB
GDScript

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