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