Initial commit of Unity SDK for GumYum NPC client

This commit is contained in:
Russell Ballestrini 2025-09-15 09:59:21 -04:00
commit db46a7bf9d
29 changed files with 3073 additions and 0 deletions

620
Runtime/Client.cs Normal file
View file

@ -0,0 +1,620 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Newtonsoft.Json;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace GumYum.NPC {
/// <summary>
/// GumYum Game Client for Unity (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
/// </summary>
public class Client : IDisposable {
// Events
public event EventHandler<NPC> CharacterSpawned;
public event EventHandler<DialogueReceivedEventArgs> DialogueReceived;
public event EventHandler<string> DialogueChunkReceived;
public event EventHandler DialogueStreamStarted;
public event EventHandler<DialogueStreamEndedEventArgs> DialogueStreamEnded;
public event EventHandler<RequestCompletedEventArgs> RequestCompleted;
public event EventHandler<RequestFailedEventArgs> RequestFailed;
// Configuration
public string BaseUrl { get; set; } = "https://npc.gumyum.com";
public string ApiVersion { get; set; } = "v1";
public TimeSpan Timeout {
get; set;
} = TimeSpan.FromSeconds(75); // Higher than server's 65s timeout
public int MaxRetries { get; set; } = 3;
public bool DebugMode { get; set; } = false;
// API Credentials
private string _apiKey = "";
private string _apiSecret = "";
private string _jwtToken = "";
private string _refreshToken = "";
private DateTime _tokenExpiresAt = DateTime.MinValue;
public string ApiKey {
get => _apiKey;
set {
_apiKey = value;
if (DebugMode)
Debug.Log($"[GumYum Game] API key set: {value}");
}
}
public string ApiSecret {
get => _apiSecret;
set {
_apiSecret = value;
if (DebugMode)
Debug.Log("[GumYum Game] API secret set: ****");
}
}
// HTTP Client
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _authSemaphore = new SemaphoreSlim(1, 1);
private readonly JsonSerializerSettings _jsonSettings;
// Managers
public NPCManager NPCs { get; private set; }
public UniverseManager Universes { get; private set; }
public ChatManager Chat { get; private set; }
public Client(HttpClient httpClient = null) {
_httpClient = httpClient ?? new HttpClient();
_httpClient.Timeout = Timeout;
_jsonSettings = new JsonSerializerSettings {
ContractResolver = new Newtonsoft.Json.Serialization
.CamelCasePropertyNamesContractResolver(),
MissingMemberHandling = MissingMemberHandling.Ignore
};
InitializeManagers();
if (DebugMode)
Debug.Log("[GumYum Game] Client initialized");
}
private void InitializeManagers() {
NPCs = new NPCManager(this);
Universes = new UniverseManager(this);
Chat = new ChatManager(this);
}
/// <summary>
/// Check if authenticated
/// </summary>
public bool IsReady() =>
!string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow < _tokenExpiresAt;
/// <summary>
/// Check if token needs refresh (within 60 seconds of expiry)
/// </summary>
private bool NeedsRefresh() =>
!string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow
> _tokenExpiresAt.AddSeconds(-60);
/// <summary>
/// Get full API URL
/// </summary>
private string GetApiUrl(string endpoint) =>
$"{BaseUrl}/{ApiVersion}/{endpoint.TrimStart('/')}";
/// <summary>
/// Get request headers
/// </summary>
private void SetRequestHeaders(HttpRequestMessage request) {
request.Headers.Clear();
request.Headers.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.UserAgent.ParseAdd("GumYum-Unity-SDK/1.0.0");
// Add API key header if we have credentials
if (!string.IsNullOrEmpty(_apiKey) && !string.IsNullOrEmpty(_apiSecret)) {
request.Headers.Add("X-API-Key", $"{_apiKey}:{_apiSecret}");
}
if (!string.IsNullOrEmpty(_jwtToken)) {
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", _jwtToken);
}
}
/// <summary>
/// Make API request
/// </summary>
public async Task<T>
RequestAsync<T>(HttpMethod method, string endpoint, object data = null,
Dictionary<string, string> queryParams = null,
CancellationToken cancellationToken = default) {
// Ensure we're authenticated
if (!string.IsNullOrEmpty(_apiKey) && !string.IsNullOrEmpty(_apiSecret) &&
!IsReady()) {
await ExchangeApiKeyForJwtAsync(cancellationToken);
}
// Check if we need to refresh token
if (NeedsRefresh() && !string.IsNullOrEmpty(_refreshToken)) {
await RefreshJwtTokenAsync(cancellationToken);
}
return await ExecuteRequestAsync<T>(method, endpoint, data, queryParams, 0,
cancellationToken);
}
private async Task<T>
ExecuteRequestAsync<T>(HttpMethod method, string endpoint, object data,
Dictionary<string, string> queryParams, int retryCount,
CancellationToken cancellationToken) {
var url = GetApiUrl(endpoint);
// Add query parameters
if (queryParams?.Count > 0) {
var queryString = string.Join(
"&", queryParams.Select(
kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"));
url += "?" + queryString;
}
using var request = new HttpRequestMessage(method, url);
SetRequestHeaders(request);
if (data != null &&
(method == HttpMethod.Post || method == HttpMethod.Put)) {
var json = JsonConvert.SerializeObject(data, _jsonSettings);
request.Content =
new StringContent(json, Encoding.UTF8, "application/json");
}
if (DebugMode) {
Debug.Log($"[GumYum Game] {method} {endpoint}");
Debug.Log($"[GumYum Game] Full URL: {url}");
}
try {
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync();
if (DebugMode) {
Debug.Log($"[GumYum Game] Response code: {(int)response.StatusCode}");
if (!response.IsSuccessStatusCode) {
Debug.Log(
$"[GumYum Game] Response body: {responseContent.Substring(0, Math.Min(200, responseContent.Length))}");
}
}
if (response.IsSuccessStatusCode) {
var result =
JsonConvert.DeserializeObject<T>(responseContent, _jsonSettings);
RequestCompleted?.Invoke(
this, new RequestCompletedEventArgs { Endpoint = endpoint,
Data = result });
return result;
}
// Handle errors
var errorData = JsonConvert.DeserializeObject<Dictionary<string, object>>(
responseContent, _jsonSettings);
var errorMsg = errorData?.GetValueOrDefault("message")?.ToString() ??
errorData?.GetValueOrDefault("error")?.ToString() ??
"Unknown error";
// Handle 403 - try to refresh token only for expired tokens
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden &&
!string.IsNullOrEmpty(_refreshToken) && retryCount == 0) {
var errorMessage =
errorData?.GetValueOrDefault("error")?.ToString() ?? "";
if (errorMessage.ToLower().Contains("expired") &&
errorMessage.ToLower().Contains("token")) {
if (DebugMode)
Debug.Log("[GumYum Game] Got 403 with expired token, attempting " +
"token refresh...");
await RefreshJwtTokenAsync(cancellationToken);
return await ExecuteRequestAsync<T>(method, endpoint, data,
queryParams, retryCount + 1,
cancellationToken);
}
}
// For 401 errors (like revoked API keys), don't retry - just fail
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) {
if (DebugMode)
Debug.Log("[GumYum Game] Got 401 Unauthorized - not retrying");
throw new HttpRequestException(
$"Error {(int)response.StatusCode}: {errorMsg}");
}
// Retry logic for temporary failures
if ((int)response.StatusCode >= 500 && retryCount < MaxRetries) {
if (DebugMode)
Debug.Log(
$"[GumYum Game] Retrying request (attempt {retryCount + 1}/{MaxRetries})");
// Exponential backoff
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retryCount + 1)),
cancellationToken);
return await ExecuteRequestAsync<T>(method, endpoint, data, queryParams,
retryCount + 1, cancellationToken);
}
throw new HttpRequestException(
$"Error {(int)response.StatusCode}: {errorMsg}");
} catch (Exception ex) {
var error = ex.Message;
if (DebugMode)
Debug.LogError($"[GumYum Game] Request failed: {endpoint} - {error}");
RequestFailed?.Invoke(
this,
new RequestFailedEventArgs { Endpoint = endpoint, Error = error });
throw;
}
}
private async
Task ExchangeApiKeyForJwtAsync(CancellationToken cancellationToken) {
if (string.IsNullOrEmpty(_apiKey) || string.IsNullOrEmpty(_apiSecret))
return;
await _authSemaphore.WaitAsync(cancellationToken);
try {
// Double-check after acquiring semaphore
if (IsReady())
return;
var data = new { public_key = _apiKey, secret_key = _apiSecret };
var url = GetApiUrl("auth/exchange");
using var request = new HttpRequestMessage(HttpMethod.Post, url);
SetRequestHeaders(request);
var json = JsonConvert.SerializeObject(data, _jsonSettings);
request.Content =
new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode) {
var responseData =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
responseContent, _jsonSettings);
_jwtToken =
responseData?.GetValueOrDefault("access_token")?.ToString() ?? "";
_refreshToken =
responseData?.GetValueOrDefault("refresh_token")?.ToString() ?? "";
var expiresIn = 3600;
if (responseData?.TryGetValue("expires_in", out var expiresInObj) == true) {
if (expiresInObj is Newtonsoft.Json.Linq.JValue jValue) {
expiresIn = jValue.ToObject<int>();
} else {
expiresIn = Convert.ToInt32(expiresInObj);
}
}
_tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn);
if (DebugMode) {
Debug.Log("[GumYum Game] API key exchanged for JWT");
Debug.Log(
$"[GumYum Game] Got refresh token: {!string.IsNullOrEmpty(_refreshToken)}");
}
} else {
var errorMsg = "Invalid API keys";
try {
var errorData =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
responseContent, _jsonSettings);
errorMsg = errorData?.GetValueOrDefault("error")?.ToString() ??
errorData?.GetValueOrDefault("message")?.ToString() ??
errorMsg;
} catch {
}
throw new HttpRequestException($"Authentication failed: {errorMsg}");
}
} finally {
_authSemaphore.Release();
}
}
private async Task RefreshJwtTokenAsync(CancellationToken cancellationToken) {
if (string.IsNullOrEmpty(_refreshToken))
return;
await _authSemaphore.WaitAsync(cancellationToken);
try {
// Double-check after acquiring semaphore
if (!NeedsRefresh())
return;
var data = new { refresh_token = _refreshToken };
var url = GetApiUrl("auth/refresh");
using var request = new HttpRequestMessage(HttpMethod.Post, url);
SetRequestHeaders(request);
var json = JsonConvert.SerializeObject(data, _jsonSettings);
request.Content =
new StringContent(json, Encoding.UTF8, "application/json");
if (DebugMode)
Debug.Log("[GumYum Game] Refreshing JWT token...");
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode) {
var responseData =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
responseContent, _jsonSettings);
_jwtToken =
responseData?.GetValueOrDefault("access_token")?.ToString() ?? "";
// Server may rotate refresh token
if (responseData?.ContainsKey("refresh_token") == true) {
_refreshToken =
responseData.GetValueOrDefault("refresh_token")?.ToString() ?? "";
}
var expiresIn = 3600;
if (responseData?.TryGetValue("expires_in", out var expiresInObj) == true) {
if (expiresInObj is Newtonsoft.Json.Linq.JValue jValue) {
expiresIn = jValue.ToObject<int>();
} else {
expiresIn = Convert.ToInt32(expiresInObj);
}
}
_tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn);
if (DebugMode)
Debug.Log("[GumYum Game] JWT token refreshed successfully");
} else if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) {
// Refresh token expired or invalid
Debug.LogError(
$"[GumYum Game] Refresh token expired or invalid (code 403)");
// Clear tokens
_jwtToken = "";
_refreshToken = "";
// If we have API keys, try to re-exchange
if (!string.IsNullOrEmpty(_apiKey)) {
if (DebugMode)
Debug.Log("[GumYum Game] Refresh failed, re-exchanging API key...");
await ExchangeApiKeyForJwtAsync(cancellationToken);
} else {
throw new HttpRequestException(
"Authentication expired - please re-authenticate");
}
} else {
throw new HttpRequestException(
$"Token refresh failed (code {(int)response.StatusCode})");
}
} finally {
_authSemaphore.Release();
}
}
// Streaming support for chat completions
internal async Task RequestStreamAsync(HttpMethod method, string endpoint,
object data,
Dictionary<string, string> queryParams,
Action<string> onChunk,
CancellationToken cancellationToken) {
if (!IsReady()) {
throw new InvalidOperationException("Not authenticated");
}
var url = GetApiUrl(endpoint);
// Add query parameters
if (queryParams?.Count > 0) {
var queryString = string.Join(
"&", queryParams.Select(
kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"));
url += "?" + queryString;
}
using var request = new HttpRequestMessage(method, url);
SetRequestHeaders(request);
request.Headers.Accept.Clear();
request.Headers.Accept.Add(
new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (data != null) {
var json = JsonConvert.SerializeObject(data, _jsonSettings);
request.Content =
new StringContent(json, Encoding.UTF8, "application/json");
}
if (DebugMode)
Debug.Log($"[GumYum Stream] Connecting to {url}");
using var response = await _httpClient.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
DialogueStreamStarted?.Invoke(this, EventArgs.Empty);
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new System.IO.StreamReader(stream);
var accumulatedContent = "";
var context = new Dictionary<string, object>();
var moodTransition = new Dictionary<string, object>();
while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) {
var line = await reader.ReadLineAsync();
if (line == null)
break;
line = line.Trim();
if (!line.StartsWith("data: "))
continue;
var dataStr = line.Substring(6);
if (DebugMode)
Debug.Log(
$"[GumYum Stream] Received data: {dataStr.Substring(0, Math.Min(100, dataStr.Length))}");
if (dataStr == "[DONE]" || dataStr == "done") {
// Stream finished
if (DebugMode)
Debug.Log(
$"[GumYum Stream] Stream complete, total content: {accumulatedContent.Length} chars");
DialogueStreamEnded?.Invoke(this, new DialogueStreamEndedEventArgs {
FullResponse = accumulatedContent, Context = context,
MoodTransition = moodTransition
});
onChunk?.Invoke(null); // Signal completion
return;
}
// Parse JSON data
try {
var eventData =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
dataStr, _jsonSettings);
// Handle different event types
if (eventData.ContainsKey("choices")) {
var choices =
JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(
eventData["choices"].ToString(), _jsonSettings);
if (choices?.Count > 0) {
var choice = choices[0];
if (choice.ContainsKey("delta")) {
var delta =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
choice["delta"].ToString(), _jsonSettings);
if (delta?.ContainsKey("content") == true) {
var contentChunk = delta["content"].ToString();
accumulatedContent += contentChunk;
DialogueChunkReceived?.Invoke(this, contentChunk);
onChunk?.Invoke(contentChunk);
}
} else if (choice.ContainsKey("message")) {
var message =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
choice["message"].ToString(), _jsonSettings);
if (message?.ContainsKey("content") == true) {
var content = message["content"].ToString();
accumulatedContent = content;
DialogueChunkReceived?.Invoke(this, content);
onChunk?.Invoke(content);
}
}
}
}
// Check for context and mood transition
if (eventData.ContainsKey("npc_context")) {
context = JsonConvert.DeserializeObject<Dictionary<string, object>>(
eventData["npc_context"].ToString(), _jsonSettings);
}
if (eventData.ContainsKey("mood_transition")) {
moodTransition =
JsonConvert.DeserializeObject<Dictionary<string, object>>(
eventData["mood_transition"].ToString(), _jsonSettings);
}
} catch (Exception ex) {
if (DebugMode)
Debug.LogError(
$"[GumYum Stream] Error parsing event data: {ex.Message}");
}
}
// Ensure we emit the final response
DialogueStreamEnded?.Invoke(this, new DialogueStreamEndedEventArgs {
FullResponse = accumulatedContent, Context = context,
MoodTransition = moodTransition
});
}
// Internal methods for Managers to raise events
internal void RaiseCharacterSpawned(NPC npc) {
CharacterSpawned?.Invoke(this, npc);
}
internal void RaiseDialogueReceived(DialogueReceivedEventArgs args) {
DialogueReceived?.Invoke(this, args);
}
internal void RaiseDialogueChunkReceived(string chunk) {
DialogueChunkReceived?.Invoke(this, chunk);
}
internal void RaiseDialogueStreamStarted() {
DialogueStreamStarted?.Invoke(this, EventArgs.Empty);
}
internal void RaiseDialogueStreamEnded(DialogueStreamEndedEventArgs args) {
DialogueStreamEnded?.Invoke(this, args);
}
internal void RaiseRequestCompleted(RequestCompletedEventArgs args) {
RequestCompleted?.Invoke(this, args);
}
internal void RaiseRequestFailed(RequestFailedEventArgs args) {
RequestFailed?.Invoke(this, args);
}
public void Dispose() {
_authSemaphore?.Dispose();
_httpClient?.Dispose();
}
}
// Event Args Classes
public class DialogueReceivedEventArgs : EventArgs {
public string Response { get; set; }
public Dictionary<string, object> Context { get; set; }
public Dictionary<string, object> MoodTransition { get; set; }
}
public class DialogueStreamEndedEventArgs : EventArgs {
public string FullResponse { get; set; }
public Dictionary<string, object> Context { get; set; }
public Dictionary<string, object> MoodTransition { get; set; }
}
public class RequestCompletedEventArgs : EventArgs {
public string Endpoint { get; set; }
public object Data { get; set; }
}
public class RequestFailedEventArgs : EventArgs {
public string Endpoint { get; set; }
public string Error { get; set; }
}
}

2
Runtime/Client.cs.meta Normal file
View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aa8266018932c06b7885a800ff63b7a3

View file

@ -0,0 +1,22 @@
{
"name": "GumYumNPC.Runtime",
"rootNamespace": "GumYum.NPC",
"references": [
"Unity.TextMeshPro"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [
{
"name": "com.unity.nuget.newtonsoft-json",
"expression": "3.0.0",
"define": "NEWTONSOFT_JSON"
}
],
"noEngineReferences": false
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 26a697b167a9ebcc689a6ae9368b98d7
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,293 @@
#if UNITY_5_3_OR_NEWER
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
using GumYum.NPC;
namespace GumYum.NPC.Unity {
/// <summary>
/// Unity-specific wrapper for GumYum Client that integrates with Unity's
/// coroutine system This provides a MonoBehaviour-based interface for easier
/// Unity integration
/// </summary>
public class GumYumUnityClient : MonoBehaviour {
[Header("Configuration")]
[SerializeField]
private string baseUrl = "https://npc.gumyum.com";
[SerializeField]
private string apiVersion = "v1";
[SerializeField]
private float timeout = 75f;
[SerializeField]
private int maxRetries = 3;
[SerializeField]
private bool debugMode = false;
[Header("API Credentials")]
[SerializeField]
private string apiKey = "";
[SerializeField]
private string apiSecret = "";
// The underlying C# client
private Client _client;
// Unity-specific events
public event Action<NPC> OnCharacterSpawned;
public event Action<string, Dictionary<string, object>,
Dictionary<string, object>> OnDialogueReceived;
public event Action<string> OnDialogueChunkReceived;
public event Action OnDialogueStreamStarted;
public event Action<string, Dictionary<string, object>,
Dictionary<string, object>> OnDialogueStreamEnded;
public event Action<string, object> OnRequestCompleted;
public event Action<string, string> OnRequestFailed;
// Singleton pattern (optional)
private static GumYumUnityClient _instance;
public static GumYumUnityClient Instance {
get {
if (_instance == null) {
_instance = FindFirstObjectByType<GumYumUnityClient>();
if (_instance == null) {
GameObject go = new GameObject("GumYumUnityClient");
_instance = go.AddComponent<GumYumUnityClient>();
DontDestroyOnLoad(go);
}
}
return _instance;
}
}
/// <summary>
/// Access to the underlying client
/// </summary>
public Client Client => _client;
/// <summary>
/// Manager accessors for convenience
/// </summary>
public NPCManager NPCs => _client?.NPCs;
public UniverseManager Universes => _client?.Universes;
public ChatManager Chat => _client?.Chat;
private void Awake() {
if (_instance != null && _instance != this) {
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
InitializeClient();
}
private void InitializeClient() {
// Create the client
_client = new Client() { BaseUrl = baseUrl,
ApiVersion = apiVersion,
Timeout = TimeSpan.FromSeconds(timeout),
MaxRetries = maxRetries,
DebugMode = debugMode,
ApiKey = apiKey,
ApiSecret = apiSecret };
// Subscribe to events and forward them
_client.CharacterSpawned += (sender, npc) =>
OnCharacterSpawned?.Invoke(npc);
_client.DialogueReceived += (sender, e) =>
OnDialogueReceived?.Invoke(e.Response, e.Context, e.MoodTransition);
_client.DialogueChunkReceived += (sender, chunk) =>
OnDialogueChunkReceived?.Invoke(chunk);
_client.DialogueStreamStarted += (sender, e) =>
OnDialogueStreamStarted?.Invoke();
_client.DialogueStreamEnded += (sender, e) => OnDialogueStreamEnded?.Invoke(
e.FullResponse, e.Context, e.MoodTransition);
_client.RequestCompleted += (sender, e) =>
OnRequestCompleted?.Invoke(e.Endpoint, e.Data);
_client.RequestFailed += (sender, e) =>
OnRequestFailed?.Invoke(e.Endpoint, e.Error);
}
/// <summary>
/// Update API credentials at runtime
/// </summary>
public void SetCredentials(string apiKey, string apiSecret) {
this.apiKey = apiKey;
this.apiSecret = apiSecret;
if (_client != null) {
_client.ApiKey = apiKey;
_client.ApiSecret = apiSecret;
}
}
/// <summary>
/// Coroutine-based NPC spawning
/// </summary>
public IEnumerator SpawnNPCCoroutine(string universeId, int seed, long? npcId,
Action<NPC> callback) {
var task = NPCs.SpawnAsync(universeId, seed, npcId);
yield return new WaitUntil(() => task.IsCompleted);
if (task.IsFaulted) {
Debug.LogError(
$"Failed to spawn NPC: {task.Exception?.GetBaseException().Message}");
callback?.Invoke(null);
} else {
callback?.Invoke(task.Result);
}
}
/// <summary>
/// Coroutine-based filtered NPC spawning
/// </summary>
public IEnumerator SpawnFilteredNPCCoroutine(string universeId, int seed,
object filters,
Action<NPC> callback) {
var task = NPCs.SpawnFilteredAsync(universeId, seed, filters);
yield return new WaitUntil(() => task.IsCompleted);
if (task.IsFaulted) {
Debug.LogError(
$"Failed to spawn filtered NPC: {task.Exception?.GetBaseException().Message}");
callback?.Invoke(null);
} else {
callback?.Invoke(task.Result);
}
}
/// <summary>
/// Coroutine-based chat completion
/// </summary>
public IEnumerator ChatCoroutine(NPC npc, List<ChatMessage> messages,
Action<ChatCompletionResponse> callback,
double temperature = 0.8,
int maxTokens = 2000) {
var task = npc.Chat.CompletionsAsync(messages, temperature, maxTokens);
yield return new WaitUntil(() => task.IsCompleted);
if (task.IsFaulted) {
Debug.LogError(
$"[GumYumUnityClient] Failed to complete chat: {task.Exception?.GetBaseException().Message}");
Debug.LogError($"[GumYumUnityClient] Full exception: {task.Exception}");
callback?.Invoke(null);
} else {
callback?.Invoke(task.Result);
}
}
/// <summary>
/// Simple chat helper that creates the message structure
/// </summary>
public IEnumerator SimpleChatCoroutine(NPC npc, string userMessage,
Action<string> callback,
double temperature = 0.8,
int maxTokens = 2000) {
var messages =
new List<ChatMessage> { new ChatMessage { Role = "user",
Content = userMessage } };
yield return ChatCoroutine(npc, messages, response => {
if (response?.Choices?.Count > 0) {
callback?.Invoke(response.Choices[0].Message.Content);
} else {
callback?.Invoke(null);
}
}, temperature, maxTokens);
}
/// <summary>
/// List public universes coroutine
/// </summary>
public IEnumerator ListPublicUniversesCoroutine(
Action<List<Dictionary<string, object>>> callback) {
var task = Universes.ListPublicAsync();
yield return new WaitUntil(() => task.IsCompleted);
if (task.IsFaulted) {
Debug.LogError(
$"Failed to list public universes: {task.Exception?.GetBaseException().Message}");
callback?.Invoke(null);
} else {
callback?.Invoke(task.Result);
}
}
/// <summary>
/// List user's universes coroutine
/// </summary>
public IEnumerator
ListUniversesCoroutine(Action<List<Dictionary<string, object>>> callback) {
var task = Universes.ListAsync();
yield return new WaitUntil(() => task.IsCompleted);
if (task.IsFaulted) {
Debug.LogError(
$"Failed to list user universes: {task.Exception?.GetBaseException().Message}");
callback?.Invoke(null);
} else {
callback?.Invoke(task.Result);
}
}
/// <summary>
/// Helper to convert async operations to coroutines
/// </summary>
public static IEnumerator ToCoroutine(Task task) {
while (!task.IsCompleted) {
yield return null;
}
if (task.IsFaulted) {
throw task.Exception.GetBaseException();
}
}
/// <summary>
/// Helper to convert async operations to coroutines with result
/// </summary>
public static IEnumerator ToCoroutine<T>(Task<T> task,
Action<T> resultCallback) {
while (!task.IsCompleted) {
yield return null;
}
if (task.IsFaulted) {
Debug.LogError(
$"Task failed: {task.Exception?.GetBaseException().Message}");
resultCallback?.Invoke(default(T));
} else {
resultCallback?.Invoke(task.Result);
}
}
private void OnDestroy() { _client?.Dispose(); }
}
/// <summary>
/// Extension methods for easier Unity integration
/// </summary>
public static class GumYumUnityExtensions {
/// <summary>
/// Convert Task to Coroutine
/// </summary>
public static IEnumerator AsCoroutine(this Task task) {
return GumYumUnityClient.ToCoroutine(task);
}
/// <summary>
/// Convert Task<T> to Coroutine
/// </summary>
public static IEnumerator AsCoroutine<T>(this Task<T> task,
Action<T> resultCallback) {
return GumYumUnityClient.ToCoroutine(task, resultCallback);
}
}
}
#endif

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac7e37da835b6259384d76e3c893d1e0

373
Runtime/Managers.cs Normal file
View file

@ -0,0 +1,373 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace GumYum.NPC {
/// <summary>
/// Manager for NPC-related operations
/// </summary>
public class NPCManager {
private readonly Client _client;
public NPCManager(Client client) { _client = client; }
/// <summary>
/// Spawn a character - returns NPC object
/// Pass null for npcId to spawn a random NPC
/// </summary>
public async Task<NPC>
SpawnAsync(string universeId, int seed, long? npcId = null,
CancellationToken cancellationToken = default) {
var queryParams =
new Dictionary<string, string> { ["universe_id"] = universeId,
["seed"] = seed.ToString() };
string endpoint;
if (npcId.HasValue) {
// Spawn specific NPC
queryParams["npc_id"] = npcId.Value.ToString();
endpoint = "npc";
} else {
// Spawn random NPC - let API handle the randomness
endpoint = "npc/spawn";
}
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, endpoint, null, queryParams, cancellationToken);
// Check if this is a redirect response
if (response.ContainsKey("redirect_url")) {
// Follow the redirect to get actual NPC data
var redirectPath = response["redirect_url"].ToString();
// Strip the /v1/ prefix since client adds it
if (redirectPath.StartsWith("/v1/")) {
redirectPath = redirectPath.Substring(4);
}
// Make the redirect request
var npcData = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, redirectPath, null, null, cancellationToken);
var npc = new NPC(npcData, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
} else {
// Direct response (shouldn't happen with /npc/spawn)
var npc = new NPC(response, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
}
}
/// <summary>
/// Spawn with advanced filters - supports any NPC field
/// Examples:
/// SpawnFilteredAsync("kingdom", 12345, new { profession = new[] {
/// "warrior", "knight" }, location = new[] { "barracks", "castle" } })
/// SpawnFilteredAsync("blade-runner", 67890, new { personality_type = new[]
/// { 8 }, sex = new[] { "female" } })
/// </summary>
public async Task<NPC>
SpawnFilteredAsync(string universeId, int seed, object filters = null,
CancellationToken cancellationToken = default) {
var data = new { universe_id = universeId, world_seed = seed,
filters = filters ?? new {} };
var endpoint = "npc/spawn_filtered";
// spawn_filtered uses POST with data in body
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Post, endpoint, data, null, cancellationToken);
// Check if this is a redirect response
if (response.ContainsKey("redirect_url")) {
// Follow the redirect to get actual NPC data
var redirectPath = response["redirect_url"].ToString();
// Strip the /v1/ prefix since client adds it
if (redirectPath.StartsWith("/v1/")) {
redirectPath = redirectPath.Substring(4);
}
// Make the redirect request
var npcData = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, redirectPath, null, null, cancellationToken);
var npc = new NPC(npcData, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
} else {
// Direct response (shouldn't happen with /npc/spawn_filtered)
var npc = new NPC(response, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
}
}
}
/// <summary>
/// Manager for Universe-related operations
/// </summary>
public class UniverseManager {
private readonly Client _client;
public UniverseManager(Client client) { _client = client; }
/// <summary>
/// List public universes (works with API keys)
/// </summary>
public async Task<List<Dictionary<string, object>>>
ListPublicAsync(CancellationToken cancellationToken = default) {
// API returns {"public_universes": [...]}
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, "public/universes", null, null, cancellationToken);
if (response != null &&
response.TryGetValue("public_universes", out var universesObj)) {
// Handle Newtonsoft.Json types
if (universesObj is Newtonsoft.Json.Linq.JArray jArray) {
return jArray.ToObject<List<Dictionary<string, object>>>();
} else if (universesObj is List<Dictionary<string, object>> list) {
return list;
}
}
return new List<Dictionary<string, object>>();
}
/// <summary>
/// List user's universes (works with JWT from API key exchange)
/// </summary>
public async Task<List<Dictionary<string, object>>>
ListAsync(CancellationToken cancellationToken = default) {
// API returns {"universes": [...]}
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, "universes", null, null, cancellationToken);
if (response != null &&
response.TryGetValue("universes", out var universesObj)) {
// Handle Newtonsoft.Json types
if (universesObj is Newtonsoft.Json.Linq.JArray jArray) {
return jArray.ToObject<List<Dictionary<string, object>>>();
} else if (universesObj is List<Dictionary<string, object>> list) {
return list;
}
}
return new List<Dictionary<string, object>>();
}
/// <summary>
/// Copy a universe (requires user authentication)
/// </summary>
public Task<Dictionary<string, object>>
CopyAsync(string publicUniverseId, string customName = "",
CancellationToken cancellationToken = default) {
throw new NotSupportedException(
"[GumYum] Copy() requires user authentication. Use API keys with a " +
"known universe ID instead.");
}
/// <summary>
/// Get universe details
/// </summary>
public async Task<Dictionary<string, object>>
GetUniverseAsync(string universeId,
CancellationToken cancellationToken = default) {
return await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, $"universes/{universeId}", null, null,
cancellationToken);
}
}
/// <summary>
/// Manager for Chat-related operations
/// </summary>
public class ChatManager {
private readonly Client _client;
public ChatManager(Client client) { _client = client; }
/// <summary>
/// Generate dialogue (OpenAI-compatible chat.completions)
/// </summary>
public async Task<ChatCompletionResponse> CompletionsAsync(
List<ChatMessage> messages, Dictionary<string, object> npcParams,
double temperature = 0.8, int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
var data = new Dictionary<string, object> { ["model"] = "gumyum-npc",
["temperature"] = temperature,
["messages"] = messages,
["stream"] = stream,
["npc_params"] = npcParams,
["max_tokens"] = maxTokens };
// Extract npc_id to top level if it exists in npc_params
if (npcParams.ContainsKey("npc_id")) {
data["npc_id"] = npcParams["npc_id"];
}
if (stream) {
throw new NotImplementedException(
"Streaming is handled via CompletionsStreamAsync");
} else {
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Post, "chat/completions", data, null, cancellationToken);
var completionResponse = new ChatCompletionResponse(response);
if (completionResponse.Choices?.Count > 0) {
var firstChoice = completionResponse.Choices[0];
if (firstChoice?.Message == null) {
return completionResponse;
}
var content = firstChoice.Message.Content;
var context = completionResponse.NpcContext;
var moodTransition = completionResponse.MoodTransition;
_client.RaiseDialogueReceived(new DialogueReceivedEventArgs {
Response = content, Context = context, MoodTransition = moodTransition
});
}
return completionResponse;
}
}
/// <summary>
/// Generate dialogue with streaming support
/// </summary>
public async Task CompletionsStreamAsync(
List<ChatMessage> messages, Dictionary<string, object> npcParams,
Action<string> onChunk, double temperature = 0.8, int maxTokens = 2000,
CancellationToken cancellationToken = default) {
var data = new Dictionary<string, object> { ["model"] = "gumyum-npc",
["temperature"] = temperature,
["messages"] = messages,
["stream"] = true,
["npc_params"] = npcParams,
["max_tokens"] = maxTokens };
// Extract npc_id to top level if it exists in npc_params
if (npcParams.ContainsKey("npc_id")) {
data["npc_id"] = npcParams["npc_id"];
}
await _client.RequestStreamAsync(HttpMethod.Post, "chat/completions", data,
null, onChunk, cancellationToken);
}
/// <summary>
/// Generate dialogue (legacy method name for backwards compatibility)
/// </summary>
public Task<ChatCompletionResponse>
ChatAsync(List<ChatMessage> messages, Dictionary<string, object> npcParams,
double temperature = 0.8, int maxTokens = 2000,
CancellationToken cancellationToken = default) {
return CompletionsAsync(messages, npcParams, temperature, maxTokens, false,
cancellationToken);
}
/// <summary>
/// Generate dialogue with streaming support (legacy method)
/// </summary>
public Task ChatStreamAsync(List<ChatMessage> messages,
Dictionary<string, object> npcParams,
Action<string> onChunk, double temperature = 0.8,
int maxTokens = 2000,
CancellationToken cancellationToken = default) {
return CompletionsStreamAsync(messages, npcParams, onChunk, temperature,
maxTokens, cancellationToken);
}
}
/// <summary>
/// Chat message structure
/// </summary>
public class ChatMessage {
public string Role { get; set; }
public string Content { get; set; }
}
/// <summary>
/// Chat completion response
/// </summary>
public class ChatCompletionResponse {
public List<ChatChoice> Choices { get; set; }
public Dictionary<string, object> NpcContext { get; set; }
public Dictionary<string, object> MoodTransition { get; set; }
public ChatCompletionResponse(Dictionary<string, object> data) {
if (data == null) {
Debug.LogError(
"[ChatCompletionResponse] Constructor received null data!");
Choices = new List<ChatChoice>();
return;
}
if (data.ContainsKey("choices")) {
var choicesData = data["choices"] as List<object>;
if (choicesData == null && data["choices"] != null) {
// Try to handle different array types
if (data["choices"] is Newtonsoft.Json.Linq.JArray jArray) {
choicesData = jArray.ToObject<List<object>>();
}
}
Choices = new List<ChatChoice>();
if (choicesData != null) {
foreach (var choice in choicesData) {
Dictionary<string, object> choiceDict = null;
if (choice is Dictionary<string, object> dict) {
choiceDict = dict;
} else if (choice is Newtonsoft.Json.Linq.JObject jObj) {
choiceDict = jObj.ToObject<Dictionary<string, object>>();
}
if (choiceDict != null) {
Choices.Add(new ChatChoice(choiceDict));
}
// Silently skip unparseable choices
}
}
} else {
Choices = new List<ChatChoice>();
}
if (data.ContainsKey("npc_context")) {
NpcContext = data["npc_context"] as Dictionary<string, object>;
}
if (data.ContainsKey("mood_transition")) {
MoodTransition = data["mood_transition"] as Dictionary<string, object>;
}
}
}
/// <summary>
/// Chat choice in completion response
/// </summary>
public class ChatChoice {
public ChatMessage Message { get; set; }
public ChatChoice(Dictionary<string, object> data) {
if (data?.ContainsKey("message") == true) {
var msgData = data["message"] as Dictionary<string, object>;
if (msgData == null &&
data["message"] is Newtonsoft.Json.Linq.JObject jObj) {
msgData = jObj.ToObject<Dictionary<string, object>>();
}
if (msgData != null) {
Message = new ChatMessage {
Role = msgData.GetValueOrDefault("role")?.ToString() ?? "assistant",
Content = msgData.GetValueOrDefault("content")?.ToString() ?? ""
};
}
// Silently handle missing message data
}
}
}
}

2
Runtime/Managers.cs.meta Normal file
View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 232c26c33ff3d4c95b32e8c9178b8bb7

542
Runtime/NPC.cs Normal file
View file

@ -0,0 +1,542 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace GumYum.NPC {
/// <summary>
/// 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}");
/// </summary>
[System.Serializable]
public class NPC {
// Events
public event EventHandler<DialogueReceivedEventArgs> DialogueReceived;
public event EventHandler<string> DialogueChunkReceived;
public event EventHandler DialogueStreamStarted;
public event EventHandler<DialogueStreamEndedEventArgs> 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<string, object> Spawned { get; set; } =
new Dictionary<string, object>();
// 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<ChatMessage> ChatHistory { get; } = new List<ChatMessage>();
// Internal chat manager for this NPC
public NPCChatManager Chat { get; private set; }
public NPC(Dictionary<string, object> npcData, Client client) {
_client = client;
SetupFromData(npcData);
Chat = new NPCChatManager(this);
ConnectClientSignals();
}
/// <summary>
/// Setup NPC from API response data
/// </summary>
private void SetupFromData(Dictionary<string, object> npcData) {
// Check if data is nested in "spawned_npc" first
if (npcData.ContainsKey("spawned_npc")) {
if (npcData["spawned_npc"] is Dictionary<string, object> spawnedData) {
npcData = spawnedData;
} else if (npcData["spawned_npc"] is Newtonsoft.Json.Linq.JObject jObj) {
npcData = jObj.ToObject<Dictionary<string, object>>();
}
}
// 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<long>();
} 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<int>();
} 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<int>();
} else {
seed = Convert.ToInt32(seedValue);
}
} else if (npcData.TryGetValue("seed", out seedValue)) {
if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) {
seed = seedJValue.ToObject<int>();
} else {
seed = Convert.ToInt32(seedValue);
}
}
if (npcData.TryGetValue("cached", out var cachedValue)) {
if (cachedValue is Newtonsoft.Json.Linq.JValue cachedJValue) {
cached = cachedJValue.ToObject<bool>();
} 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<string, object> 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<int>();
} else {
StressLevel = Convert.ToInt32(stressLevel);
}
}
}
}
/// <summary>
/// Convenience method - direct completions alias (like Python
/// npc.completions())
/// </summary>
public Task<ChatCompletionResponse>
CompletionsAsync(List<ChatMessage> messages, double temperature = 0.8,
int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
return Chat.CompletionsAsync(messages, temperature, maxTokens, stream,
cancellationToken);
}
/// <summary>
/// Chat with conversation history - includes all previous messages in this
/// session
/// </summary>
public Task<ChatCompletionResponse>
ChatWithHistoryAsync(string message, double temperature = 0.8,
int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
return Chat.ChatWithHistoryAsync(message, temperature, maxTokens, stream,
cancellationToken);
}
/// <summary>
/// Clear conversation history
/// </summary>
public void ClearHistory() { ChatHistory.Clear(); }
/// <summary>
/// Convert chat history to JSON string for saving
/// </summary>
public string ChatHistoryToJson() {
return JsonConvert.SerializeObject(ChatHistory);
}
/// <summary>
/// Convert entire NPC data to dictionary for saving
/// </summary>
public Dictionary<string, object> ToDict() {
return new Dictionary<string,
object> { ["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 };
}
/// <summary>
/// Convert NPC data to JSON string for saving
/// </summary>
public string ToJson() { return JsonConvert.SerializeObject(ToDict()); }
/// <summary>
/// Load NPC data from dictionary (e.g., from saved file)
/// </summary>
public static NPC FromDict(Dictionary<string, object> data, Client client) {
return new NPC(data, client);
}
/// <summary>
/// Load NPC data from JSON string
/// </summary>
public static NPC FromJson(string jsonStr, Client client) {
var data =
JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonStr);
return FromDict(data, client);
}
/// <summary>
/// Get NPC's current location
/// </summary>
public string GetLocation() {
return Spawned.TryGetValue("location", out var location)
? location.ToString()
: "unknown";
}
/// <summary>
/// Get NPC's current mood
/// </summary>
public string GetMood() {
if (!string.IsNullOrEmpty(CurrentMood))
return CurrentMood;
return Spawned.TryGetValue("mood", out var mood) ? mood.ToString()
: "neutral";
}
/// <summary>
/// Get NPC's stress level
/// </summary>
public int GetStressLevel() { return StressLevel; }
/// <summary>
/// Get previous mood (before last change)
/// </summary>
public string GetPreviousMood() { return PreviousMood; }
/// <summary>
/// Get last mood change reason
/// </summary>
public string GetLastMoodChangeReason() { return LastMoodChangeReason; }
/// <summary>
/// Get last mood change confidence
/// </summary>
public double GetLastMoodChangeConfidence() {
return LastMoodChangeConfidence;
}
/// <summary>
/// Check if mood has changed
/// </summary>
public bool HasMoodChanged() {
return !string.IsNullOrEmpty(PreviousMood) && PreviousMood != CurrentMood;
}
/// <summary>
/// Get detailed info string
/// </summary>
public string GetInfo() {
return $"{name} the {profession} (Type {personalityType}) at {GetLocation()}, feeling {GetMood()}";
}
/// <summary>
/// Get Enneagram type description
/// </summary>
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"
};
}
/// <summary>
/// Convert NPC to dictionary for API params
/// </summary>
public Dictionary<string, object> ToDictionary() {
return new Dictionary<string, object> {
["npc_id"] = npcId,
["universe_id"] = universeId,
["seed"] = seed
};
}
/// <summary>
/// Save this NPC to the server (persistent storage)
/// </summary>
public async
Task SaveToServerAsync(string customName = "",
CancellationToken cancellationToken = default) {
var data = new Dictionary<string, object> { ["universe_id"] = universeId,
["world_seed"] = seed,
["npc_id"] = npcId };
if (!string.IsNullOrEmpty(customName))
data["custom_name"] = customName;
await _client.RequestAsync<Dictionary<string, object>>(
System.Net.Http.HttpMethod.Post, "npc/save", data, null,
cancellationToken);
}
/// <summary>
/// Connect to client's dialogue signals to forward them
/// </summary>
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<long>();
} 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<long>();
} else {
npcIdLong = Convert.ToInt64(npcIdValue);
}
if (npcIdLong == npcId) {
// Update mood data if transition occurred
UpdateMoodFromTransition(e.MoodTransition);
DialogueStreamEnded?.Invoke(this, e);
}
}
}
/// <summary>
/// Update internal mood state from mood transition data
/// </summary>
private void
UpdateMoodFromTransition(Dictionary<string, object> 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<int>();
} 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<double>();
} else {
LastMoodChangeConfidence = Convert.ToDouble(confidence);
}
}
if (moodTransition.TryGetValue("reasoning", out var reasoning)) {
LastMoodChangeReason = reasoning.ToString();
}
}
/// <summary>
/// Chat manager class - provides the .Chat interface
/// </summary>
public class NPCChatManager {
private readonly NPC _npc;
public NPCChatManager(NPC npc) { _npc = npc; }
/// <summary>
/// Main chat completion method - matches Python API
/// </summary>
public async Task<ChatCompletionResponse>
CompletionsAsync(List<ChatMessage> 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<string, object> { ["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<ChatCompletionResponse>();
string accumulatedContent = "";
Dictionary<string, object> lastContext = null;
Dictionary<string, object> 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<string, object> {
["choices"] =
new List<object> { new Dictionary<string, object> {
["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;
}
/// <summary>
/// Chat with conversation history - includes all previous messages in this
/// session
/// </summary>
public async Task<ChatCompletionResponse>
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<ChatMessage>(_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);
}
}
}
}

2
Runtime/NPC.cs.meta Normal file
View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 66691e15e0986a4a4a18cd94e53c917f