using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using System.Threading; using System.Threading.Tasks; using UnityEngine; namespace GumYum.NPC { /// /// 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.NPCs.SpawnAsync("universe-id", 12345, 123456789); /// var response = await npc.Chat.CompletionsAsync(new[] { new ChatMessage { /// Role = "user", Content = "Hello!" } }); Debug.Log($"{npc.Name}: /// {response.Choices[0].Message.Content}"); /// [System.Serializable] public class NPC { // Events public event EventHandler DialogueReceived; public event EventHandler DialogueChunkReceived; public event EventHandler DialogueStreamStarted; public event EventHandler DialogueStreamEnded; // NPC Data (from API response) - Serializable for Unity Inspector [SerializeField] private long npcId; [SerializeField] private string name = ""; [SerializeField] private string profession = ""; [SerializeField] private int personalityType = 5; [SerializeField] private string universeId = ""; [SerializeField] private int seed = 0; [SerializeField] private bool cached = false; [SerializeField] private string cacheUrl = ""; // Properties for public access public long NpcId => npcId; public string Name => name; public string Profession => profession; public int PersonalityType => personalityType; public string UniverseId => universeId; public int Seed => seed; public bool Cached => cached; public string CacheUrl => cacheUrl; // Spawned data (nested) public Dictionary Spawned { get; set; } = new Dictionary(); // Current mood state public string CurrentMood { get; private set; } = ""; public string PreviousMood { get; private set; } = ""; public int StressLevel { get; private set; } = 5; public string LastMoodChangeReason { get; private set; } = ""; public double LastMoodChangeConfidence { get; private set; } = 0.0; // Client reference (not serialized) [System.NonSerialized] private Client _client; // Chat history for this NPC (array of message dicts with role and content) public List ChatHistory { get; } = new List(); // Internal chat manager for this NPC public NPCChatManager Chat { get; private set; } public NPC(Dictionary npcData, Client client) { _client = client; SetupFromData(npcData); Chat = new NPCChatManager(this); ConnectClientSignals(); } /// /// Setup NPC from API response data /// private void SetupFromData(Dictionary npcData) { // Check if data is nested in "spawned_npc" first if (npcData.ContainsKey("spawned_npc")) { if (npcData["spawned_npc"] is Dictionary spawnedData) { npcData = spawnedData; } else if (npcData["spawned_npc"] is Newtonsoft.Json.Linq.JObject jObj) { npcData = jObj.ToObject>(); } } // Handle npc_id conversion - API might return String or int if (npcData.TryGetValue("npc_id", out var npcIdRaw)) { if (npcIdRaw is Newtonsoft.Json.Linq.JValue jValue) { npcId = jValue.ToObject(); } else { npcId = Convert.ToInt64(npcIdRaw); } } if (npcData.TryGetValue("name", out var nameValue)) name = nameValue.ToString(); if (npcData.TryGetValue("profession", out var professionValue)) profession = professionValue.ToString(); if (npcData.TryGetValue("personality_type", out var personalityTypeValue)) { if (personalityTypeValue is Newtonsoft.Json.Linq.JValue ptJValue) { personalityType = ptJValue.ToObject(); } else { personalityType = Convert.ToInt32(personalityTypeValue); } } if (npcData.TryGetValue("universe_id", out var universeIdValue)) universeId = universeIdValue.ToString(); // Try "world_seed" first (what Elixir API returns), then fall back to // "seed" if (npcData.TryGetValue("world_seed", out var seedValue)) { if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) { seed = seedJValue.ToObject(); } else { seed = Convert.ToInt32(seedValue); } } else if (npcData.TryGetValue("seed", out seedValue)) { if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) { seed = seedJValue.ToObject(); } else { seed = Convert.ToInt32(seedValue); } } if (npcData.TryGetValue("cached", out var cachedValue)) { if (cachedValue is Newtonsoft.Json.Linq.JValue cachedJValue) { cached = cachedJValue.ToObject(); } else { cached = Convert.ToBoolean(cachedValue); } } if (npcData.TryGetValue("cache_url", out var cacheUrlValue)) cacheUrl = cacheUrlValue.ToString(); // Handle spawned data if (npcData.TryGetValue("spawned", out var spawned) && spawned is Dictionary spawnedDict) { Spawned = spawnedDict; // Initialize mood from spawned data if (spawnedDict.TryGetValue("mood", out var mood)) CurrentMood = mood.ToString(); if (spawnedDict.TryGetValue("stress_level", out var stressLevel)) { if (stressLevel is Newtonsoft.Json.Linq.JValue slJValue) { StressLevel = slJValue.ToObject(); } else { StressLevel = Convert.ToInt32(stressLevel); } } } } /// /// Convenience method - direct completions alias (like Python /// npc.completions()) /// public Task CompletionsAsync(List messages, double temperature = 0.8, int maxTokens = 2000, bool stream = false, CancellationToken cancellationToken = default) { return Chat.CompletionsAsync(messages, temperature, maxTokens, stream, cancellationToken); } /// /// Chat with conversation history - includes all previous messages in this /// session /// public Task ChatWithHistoryAsync(string message, double temperature = 0.8, int maxTokens = 2000, bool stream = false, CancellationToken cancellationToken = default) { return Chat.ChatWithHistoryAsync(message, temperature, maxTokens, stream, cancellationToken); } /// /// Clear conversation history /// public void ClearHistory() { ChatHistory.Clear(); } /// /// Convert chat history to JSON string for saving /// public string ChatHistoryToJson() { return JsonConvert.SerializeObject(ChatHistory); } /// /// Convert entire NPC data to dictionary for saving /// public Dictionary ToDict() { return new Dictionary { ["npc_id"] = npcId, ["name"] = name, ["profession"] = profession, ["personality_type"] = personalityType, ["universe_id"] = universeId, ["seed"] = seed, ["spawned"] = Spawned, ["cached"] = cached, ["cache_url"] = cacheUrl, ["chat_history"] = ChatHistory }; } /// /// Convert NPC data to JSON string for saving /// public string ToJson() { return JsonConvert.SerializeObject(ToDict()); } /// /// Load NPC data from dictionary (e.g., from saved file) /// public static NPC FromDict(Dictionary data, Client client) { return new NPC(data, client); } /// /// Load NPC data from JSON string /// public static NPC FromJson(string jsonStr, Client client) { var data = JsonConvert.DeserializeObject>(jsonStr); return FromDict(data, client); } /// /// Get NPC's current location /// public string GetLocation() { return Spawned.TryGetValue("location", out var location) ? location.ToString() : "unknown"; } /// /// Get NPC's current mood /// public string GetMood() { if (!string.IsNullOrEmpty(CurrentMood)) return CurrentMood; return Spawned.TryGetValue("mood", out var mood) ? mood.ToString() : "neutral"; } /// /// Get NPC's stress level /// public int GetStressLevel() { return StressLevel; } /// /// Get previous mood (before last change) /// public string GetPreviousMood() { return PreviousMood; } /// /// Get last mood change reason /// public string GetLastMoodChangeReason() { return LastMoodChangeReason; } /// /// Get last mood change confidence /// public double GetLastMoodChangeConfidence() { return LastMoodChangeConfidence; } /// /// Check if mood has changed /// public bool HasMoodChanged() { return !string.IsNullOrEmpty(PreviousMood) && PreviousMood != CurrentMood; } /// /// Get detailed info string /// public string GetInfo() { return $"{name} the {profession} (Type {personalityType}) at {GetLocation()}, feeling {GetMood()}"; } /// /// Get Enneagram type description /// public string GetEnneagramDescription() { return personalityType switch { 1 => "The Reformer", 2 => "The Helper", 3 => "The Achiever", 4 => "The Individualist", 5 => "The Investigator", 6 => "The Loyalist", 7 => "The Enthusiast", 8 => "The Challenger", 9 => "The Peacemaker", _ => "Unknown Type" }; } /// /// Convert NPC to dictionary for API params /// public Dictionary ToDictionary() { return new Dictionary { ["npc_id"] = npcId, ["universe_id"] = universeId, ["seed"] = seed }; } /// /// Save this NPC to the server (persistent storage) /// public async Task SaveToServerAsync(string customName = "", CancellationToken cancellationToken = default) { var data = new Dictionary { ["universe_id"] = universeId, ["world_seed"] = seed, ["npc_id"] = npcId }; if (!string.IsNullOrEmpty(customName)) data["custom_name"] = customName; await _client.RequestAsync>( System.Net.Http.HttpMethod.Post, "npc/save", data, null, cancellationToken); } /// /// Connect to client's dialogue signals to forward them /// private void ConnectClientSignals() { if (_client != null) { _client.DialogueReceived += OnClientDialogueReceived; _client.DialogueChunkReceived += OnClientDialogueChunkReceived; _client.DialogueStreamStarted += OnClientDialogueStreamStarted; _client.DialogueStreamEnded += OnClientDialogueStreamEnded; } } private void OnClientDialogueReceived(object sender, DialogueReceivedEventArgs e) { // Only emit if this response is for our NPC if (e.Context?.TryGetValue("npc_id", out var npcIdValue) == true) { long npcIdLong = 0; if (npcIdValue is Newtonsoft.Json.Linq.JValue jValue) { npcIdLong = jValue.ToObject(); } else { npcIdLong = Convert.ToInt64(npcIdValue); } if (npcIdLong == npcId) { // Update mood data if transition occurred UpdateMoodFromTransition(e.MoodTransition); DialogueReceived?.Invoke(this, e); } } } private void OnClientDialogueChunkReceived(object sender, string chunk) { DialogueChunkReceived?.Invoke(this, chunk); } private void OnClientDialogueStreamStarted(object sender, EventArgs e) { DialogueStreamStarted?.Invoke(this, e); } private void OnClientDialogueStreamEnded(object sender, DialogueStreamEndedEventArgs e) { // Only emit if this response is for our NPC if (e.Context?.TryGetValue("npc_id", out var npcIdValue) == true) { long npcIdLong = 0; if (npcIdValue is Newtonsoft.Json.Linq.JValue jValue) { npcIdLong = jValue.ToObject(); } else { npcIdLong = Convert.ToInt64(npcIdValue); } if (npcIdLong == npcId) { // Update mood data if transition occurred UpdateMoodFromTransition(e.MoodTransition); DialogueStreamEnded?.Invoke(this, e); } } } /// /// Update internal mood state from mood transition data /// private void UpdateMoodFromTransition(Dictionary moodTransition) { if (moodTransition == null || moodTransition.Count == 0) return; // Store previous mood if we're changing if (moodTransition.TryGetValue("new_mood", out var newMood) && newMood.ToString() != CurrentMood) { PreviousMood = CurrentMood; CurrentMood = newMood.ToString(); } // Update stress level if (moodTransition.TryGetValue("stress_level", out var stressLevel)) { if (stressLevel is Newtonsoft.Json.Linq.JValue slJValue) { StressLevel = slJValue.ToObject(); } else { StressLevel = Convert.ToInt32(stressLevel); } } // Store mood change metadata if (moodTransition.TryGetValue("confidence", out var confidence)) { if (confidence is Newtonsoft.Json.Linq.JValue confJValue) { LastMoodChangeConfidence = confJValue.ToObject(); } else { LastMoodChangeConfidence = Convert.ToDouble(confidence); } } if (moodTransition.TryGetValue("reasoning", out var reasoning)) { LastMoodChangeReason = reasoning.ToString(); } } /// /// Chat manager class - provides the .Chat interface /// public class NPCChatManager { private readonly NPC _npc; public NPCChatManager(NPC npc) { _npc = npc; } /// /// Main chat completion method - matches Python API /// public async Task CompletionsAsync(List messages, double temperature = 0.8, int maxTokens = 2000, bool stream = false, CancellationToken cancellationToken = default) { // Build npc_params with this NPC's context including current mood var npcParams = new Dictionary { ["universe_id"] = _npc.universeId, ["world_seed"] = _npc.seed, ["npc_id"] = _npc.npcId, ["current_mood"] = _npc.GetMood(), ["stress_level"] = _npc.GetStressLevel() }; ChatCompletionResponse response; if (stream) { // For streaming, we need to handle it differently var tcs = new TaskCompletionSource(); string accumulatedContent = ""; Dictionary lastContext = null; Dictionary lastMoodTransition = null; await _npc._client.Chat.CompletionsStreamAsync( messages, npcParams, chunk => { if (chunk == null) // Stream ended { // Create a response object with the accumulated content var streamResponse = new ChatCompletionResponse(new Dictionary { ["choices"] = new List { new Dictionary { ["message"] = new Dictionary< string, object> { ["role"] = "assistant", ["content"] = accumulatedContent } } }, ["npc_context"] = lastContext, ["mood_transition"] = lastMoodTransition }); tcs.SetResult(streamResponse); } else { accumulatedContent += chunk; } }, temperature, maxTokens, cancellationToken); response = await tcs.Task; } else { response = await _npc._client.Chat.CompletionsAsync( messages, npcParams, temperature, maxTokens, false, cancellationToken); } // Update chat history if (response?.Choices?.Count > 0) { // Add user message(s) to history if (messages.Count > 0) { var lastUserMsg = messages[messages.Count - 1]; if (lastUserMsg.Role == "user") { _npc.ChatHistory.Add(lastUserMsg); } } // Add assistant response to history var choice = response.Choices[0]; if (choice.Message != null) { _npc.ChatHistory.Add( new ChatMessage { Role = choice.Message.Role, Content = choice.Message.Content }); } } return response; } /// /// Chat with conversation history - includes all previous messages in this /// session /// public async Task ChatWithHistoryAsync(string message, double temperature = 0.8, int maxTokens = 2000, bool stream = false, CancellationToken cancellationToken = default) { // Build messages array with history plus new message var messagesWithHistory = new List(_npc.ChatHistory); messagesWithHistory.Add( new ChatMessage { Role = "user", Content = message }); // Use regular completions which will also update history return await CompletionsAsync(messagesWithHistory, temperature, maxTokens, stream, cancellationToken); } } } }