commit bde434356c5f52a008dc3ba2145d4f87233170c3 Author: Russell Ballestrini Date: Mon Sep 15 10:00:27 2025 -0400 Initial commit of C# SDK for GumYum NPC client diff --git a/Client.cs b/Client.cs new file mode 100644 index 0000000..8121954 --- /dev/null +++ b/Client.cs @@ -0,0 +1,628 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace GumYum.NPC { +/// +/// GumYum Game Client for .NET/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 +/// +public class Client : IDisposable { + // Events + public event EventHandler CharacterSpawned; + public event EventHandler DialogueReceived; + public event EventHandler DialogueChunkReceived; + public event EventHandler DialogueStreamStarted; + public event EventHandler DialogueStreamEnded; + public event EventHandler RequestCompleted; + public event EventHandler 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) + _logger?.LogDebug($"[GumYum Game] API key set: {value}"); + } + } + + public string ApiSecret { + get => _apiSecret; + set { + _apiSecret = value; + if (DebugMode) + _logger?.LogDebug("[GumYum Game] API secret set: ****"); + } + } + + // HTTP Client + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly SemaphoreSlim _authSemaphore = new SemaphoreSlim(1, 1); + private readonly JsonSerializerOptions _jsonOptions; + + // Managers + public NPCManager NPCs { get; private set; } + public UniverseManager Universes { get; private set; } + public ChatManager Chat { get; private set; } + + public Client(HttpClient httpClient = null, ILogger logger = null) { + _httpClient = httpClient ?? new HttpClient(); + _httpClient.Timeout = Timeout; + _logger = logger; + + _jsonOptions = + new JsonSerializerOptions { PropertyNamingPolicy = + JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true }; + + InitializeManagers(); + + if (DebugMode) + _logger?.LogDebug("[GumYum Game] Client initialized"); + } + + private void InitializeManagers() { + NPCs = new NPCManager(this); + Universes = new UniverseManager(this); + Chat = new ChatManager(this); + } + + /// + /// Check if authenticated + /// + public bool IsReady() => + !string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow < _tokenExpiresAt; + + /// + /// Check if token needs refresh (within 60 seconds of expiry) + /// + private bool NeedsRefresh() => + !string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow + > _tokenExpiresAt.AddSeconds(-60); + + /// + /// Get full API URL + /// + private string GetApiUrl(string endpoint) => + $"{BaseUrl}/{ApiVersion}/{endpoint.TrimStart('/')}"; + + /// + /// Get request headers + /// + private void SetRequestHeaders(HttpRequestMessage request) { + request.Headers.Clear(); + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.ParseAdd("GumYum-CSharp-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); + } + } + + /// + /// Make API request + /// + public async Task + RequestAsync(HttpMethod method, string endpoint, object data = null, + Dictionary 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(method, endpoint, data, queryParams, 0, + cancellationToken); + } + + private async Task + ExecuteRequestAsync(HttpMethod method, string endpoint, object data, + Dictionary 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 = JsonSerializer.Serialize(data, _jsonOptions); + request.Content = + new StringContent(json, Encoding.UTF8, "application/json"); + } + + if (DebugMode) { + _logger?.LogDebug($"[GumYum Game] {method} {endpoint}"); + _logger?.LogDebug($"[GumYum Game] Full URL: {url}"); + } + + try { + var response = await _httpClient.SendAsync(request, cancellationToken); + var responseContent = + await response.Content.ReadAsStringAsync(cancellationToken); + + if (DebugMode) { + _logger?.LogDebug( + $"[GumYum Game] Response code: {(int)response.StatusCode}"); + if (!response.IsSuccessStatusCode) { + _logger?.LogDebug( + $"[GumYum Game] Response body: {responseContent.Substring(0, Math.Min(200, responseContent.Length))}"); + } + } + + if (response.IsSuccessStatusCode) { + var result = + JsonSerializer.Deserialize(responseContent, _jsonOptions); + RequestCompleted?.Invoke( + this, new RequestCompletedEventArgs { Endpoint = endpoint, + Data = result }); + return result; + } + + // Handle errors + var errorData = JsonSerializer.Deserialize>( + responseContent, _jsonOptions); + 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) + _logger?.LogDebug("[GumYum Game] Got 403 with expired token, " + + "attempting token refresh..."); + + await RefreshJwtTokenAsync(cancellationToken); + return await ExecuteRequestAsync(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) + _logger?.LogDebug( + "[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) + _logger?.LogDebug( + $"[GumYum Game] Retrying request (attempt {retryCount + 1}/{MaxRetries})"); + + // Exponential backoff + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retryCount + 1)), + cancellationToken); + return await ExecuteRequestAsync(method, endpoint, data, queryParams, + retryCount + 1, cancellationToken); + } + + throw new HttpRequestException( + $"Error {(int)response.StatusCode}: {errorMsg}"); + } catch (Exception ex) { + var error = ex.Message; + if (DebugMode) + _logger?.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 = JsonSerializer.Serialize(data, _jsonOptions); + request.Content = + new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.SendAsync(request, cancellationToken); + var responseContent = + await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.IsSuccessStatusCode) { + var responseData = + JsonSerializer.Deserialize>( + responseContent, _jsonOptions); + _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 JsonElement jsonEl) { + expiresIn = jsonEl.GetInt32(); + } else { + expiresIn = Convert.ToInt32(expiresInObj); + } + } + _tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn); + + if (DebugMode) { + _logger?.LogDebug("[GumYum Game] API key exchanged for JWT"); + _logger?.LogDebug( + $"[GumYum Game] Got refresh token: {!string.IsNullOrEmpty(_refreshToken)}"); + } + } else { + var errorMsg = "Invalid API keys"; + try { + var errorData = + JsonSerializer.Deserialize>( + responseContent, _jsonOptions); + 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 = JsonSerializer.Serialize(data, _jsonOptions); + request.Content = + new StringContent(json, Encoding.UTF8, "application/json"); + + if (DebugMode) + _logger?.LogDebug("[GumYum Game] Refreshing JWT token..."); + + var response = await _httpClient.SendAsync(request, cancellationToken); + var responseContent = + await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.IsSuccessStatusCode) { + var responseData = + JsonSerializer.Deserialize>( + responseContent, _jsonOptions); + _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 JsonElement jsonEl) { + expiresIn = jsonEl.GetInt32(); + } else { + expiresIn = Convert.ToInt32(expiresInObj); + } + } + _tokenExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn); + + if (DebugMode) + _logger?.LogDebug("[GumYum Game] JWT token refreshed successfully"); + } else if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) { + // Refresh token expired or invalid + _logger?.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) + _logger?.LogDebug( + "[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 queryParams, + Action 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 = JsonSerializer.Serialize(data, _jsonOptions); + request.Content = + new StringContent(json, Encoding.UTF8, "application/json"); + } + + if (DebugMode) + _logger?.LogDebug($"[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(cancellationToken); + using var reader = new System.IO.StreamReader(stream); + + var accumulatedContent = ""; + var context = new Dictionary(); + var moodTransition = new Dictionary(); + + 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) + _logger?.LogDebug( + $"[GumYum Stream] Received data: {dataStr.Substring(0, Math.Min(100, dataStr.Length))}"); + + if (dataStr == "[DONE]" || dataStr == "done") { + // Stream finished + if (DebugMode) + _logger?.LogDebug( + $"[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 = JsonSerializer.Deserialize>( + dataStr, _jsonOptions); + + // Handle different event types + if (eventData.ContainsKey("choices")) { + var choices = + JsonSerializer.Deserialize>>( + eventData["choices"].ToString(), _jsonOptions); + + if (choices?.Count > 0) { + var choice = choices[0]; + if (choice.ContainsKey("delta")) { + var delta = + JsonSerializer.Deserialize>( + choice["delta"].ToString(), _jsonOptions); + + 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 = + JsonSerializer.Deserialize>( + choice["message"].ToString(), _jsonOptions); + + 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 = JsonSerializer.Deserialize>( + eventData["npc_context"].ToString(), _jsonOptions); + } + if (eventData.ContainsKey("mood_transition")) { + moodTransition = + JsonSerializer.Deserialize>( + eventData["mood_transition"].ToString(), _jsonOptions); + } + } catch (Exception ex) { + if (DebugMode) + _logger?.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 Context { get; set; } + public Dictionary MoodTransition { get; set; } +} + +public class DialogueStreamEndedEventArgs : EventArgs { + public string FullResponse { get; set; } + public Dictionary Context { get; set; } + public Dictionary 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; } +} +} \ No newline at end of file diff --git a/GumYum.NPC.SDK.csproj b/GumYum.NPC.SDK.csproj new file mode 100644 index 0000000..0e5437e --- /dev/null +++ b/GumYum.NPC.SDK.csproj @@ -0,0 +1,83 @@ + + + + net6.0;net8.0;netstandard2.0 + latest + enable + true + + + GumYum.NPC.SDK + GumYum NPC SDK + 0.1.0 + GumYum NPC API Team + GumYum + C# SDK for GumYum NPC API - Cross-engine AI-powered dialogue system for Unity, Godot, and .NET games + Copyright © 2024 GumYum + + + https://github.com/gumyum/npc-api + git + https://docs.gumyum.com/sdk/csharp + https://docs.gumyum.com/sdk/csharp + MIT + false + + + gamedev;npc;ai;dialogue;unity;godot;csharp;sdk + Initial release of GumYum NPC SDK for C#/.NET + + + true + bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + + + UNITY_COMPATIBLE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94cad6b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Managers.cs b/Managers.cs new file mode 100644 index 0000000..f3769d8 --- /dev/null +++ b/Managers.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace GumYum.NPC { +/// +/// Manager for NPC-related operations +/// +public class NPCManager { + private readonly Client _client; + + public NPCManager(Client client) { _client = client; } + + /// + /// Spawn a character - returns NPC object + /// Pass null for npcId to spawn a random NPC + /// + public async Task + SpawnAsync(string universeId, int seed, long? npcId = null, + CancellationToken cancellationToken = default) { + var queryParams = + new Dictionary { ["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>( + 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>( + 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; + } + } + + /// + /// 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" } }) + /// + public async Task + 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>( + 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>( + 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; + } + } +} + +/// +/// Manager for Universe-related operations +/// +public class UniverseManager { + private readonly Client _client; + + public UniverseManager(Client client) { _client = client; } + + /// + /// List public universes (works with API keys) + /// + public async Task>> + ListPublicAsync(CancellationToken cancellationToken = default) { + // API returns {"public_universes": [...]} + var response = await _client.RequestAsync>( + HttpMethod.Get, "public/universes", null, null, cancellationToken); + + if (response != null && + response.TryGetValue("public_universes", out var universesObj)) { + // Handle System.Text.Json + if (universesObj is System.Text.Json.JsonElement jsonElement) { + return System.Text.Json.JsonSerializer.Deserialize>>(jsonElement.GetRawText()); + } else if (universesObj is List> list) { + return list; + } + } + + return new List>(); + } + + /// + /// List user's universes (works with JWT from API key exchange) + /// + public async Task>> + ListAsync(CancellationToken cancellationToken = default) { + // API returns {"universes": [...]} + var response = await _client.RequestAsync>( + HttpMethod.Get, "universes", null, null, cancellationToken); + + if (response != null && + response.TryGetValue("universes", out var universesObj)) { + // Handle System.Text.Json + if (universesObj is System.Text.Json.JsonElement jsonElement) { + return System.Text.Json.JsonSerializer.Deserialize>>(jsonElement.GetRawText()); + } else if (universesObj is List> list) { + return list; + } + } + + return new List>(); + } + + /// + /// Copy a universe (requires user authentication) + /// + public Task> + 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."); + } + + /// + /// Get universe details + /// + public async Task> + GetUniverseAsync(string universeId, + CancellationToken cancellationToken = default) { + return await _client.RequestAsync>( + HttpMethod.Get, $"universes/{universeId}", null, null, + cancellationToken); + } +} + +/// +/// Manager for Chat-related operations +/// +public class ChatManager { + private readonly Client _client; + + public ChatManager(Client client) { _client = client; } + + /// + /// Generate dialogue (OpenAI-compatible chat.completions) + /// + public async Task CompletionsAsync( + List messages, Dictionary npcParams, + double temperature = 0.8, int maxTokens = 2000, bool stream = false, + CancellationToken cancellationToken = default) { + var data = new Dictionary { ["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>( + 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; + } + } + + /// + /// Generate dialogue with streaming support + /// + public async Task CompletionsStreamAsync( + List messages, Dictionary npcParams, + Action onChunk, double temperature = 0.8, int maxTokens = 2000, + CancellationToken cancellationToken = default) { + var data = new Dictionary { ["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); + } + + /// + /// Generate dialogue (legacy method name for backwards compatibility) + /// + public Task + ChatAsync(List messages, Dictionary npcParams, + double temperature = 0.8, int maxTokens = 2000, + CancellationToken cancellationToken = default) { + return CompletionsAsync(messages, npcParams, temperature, maxTokens, false, + cancellationToken); + } + + /// + /// Generate dialogue with streaming support (legacy method) + /// + public Task ChatStreamAsync(List messages, + Dictionary npcParams, + Action onChunk, double temperature = 0.8, + int maxTokens = 2000, + CancellationToken cancellationToken = default) { + return CompletionsStreamAsync(messages, npcParams, onChunk, temperature, + maxTokens, cancellationToken); + } +} + +/// +/// Chat message structure +/// +public class ChatMessage { + public string Role { get; set; } + public string Content { get; set; } +} + +/// +/// Chat completion response +/// +public class ChatCompletionResponse { + public List Choices { get; set; } + public Dictionary NpcContext { get; set; } + public Dictionary MoodTransition { get; set; } + + public ChatCompletionResponse(Dictionary data) { + Choices = new List(); + + if (data?.ContainsKey("choices") == true) { + var choicesObj = data["choices"]; + if (choicesObj is JsonElement choicesJson) { + // Handle JsonElement array + foreach (var choiceElement in choicesJson.EnumerateArray()) { + var choiceDict = JsonSerializer.Deserialize>(choiceElement.GetRawText()); + Choices.Add(new ChatChoice(choiceDict)); + } + } else if (choicesObj is List choicesList) { + Choices = choicesList + ?.Select(c => new ChatChoice(c as Dictionary)) + .ToList() ?? new List(); + } + } + + if (data?.ContainsKey("npc_context") == true) { + NpcContext = data["npc_context"] as Dictionary; + } + + if (data?.ContainsKey("mood_transition") == true) { + MoodTransition = data["mood_transition"] as Dictionary; + } + } +} + +/// +/// Chat choice in completion response +/// +public class ChatChoice { + public ChatMessage Message { get; set; } + + public ChatChoice(Dictionary data) { + if (data?.ContainsKey("message") == true) { + var msgObj = data["message"]; + if (msgObj is JsonElement msgJson) { + var msgDict = JsonSerializer.Deserialize>(msgJson.GetRawText()); + Message = new ChatMessage { + Role = msgDict?.GetValueOrDefault("role")?.ToString() ?? "assistant", + Content = msgDict?.GetValueOrDefault("content")?.ToString() ?? "" + }; + } else if (msgObj is Dictionary msgData) { + Message = new ChatMessage { + Role = msgData.GetValueOrDefault("role")?.ToString() ?? "assistant", + Content = msgData.GetValueOrDefault("content")?.ToString() ?? "" + }; + } + } + } +} +} \ No newline at end of file diff --git a/NPC.cs b/NPC.cs new file mode 100644 index 0000000..34626b5 --- /dev/null +++ b/NPC.cs @@ -0,0 +1,508 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +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!" } }); Console.WriteLine($"{npc.Name}: +/// {response.Choices[0].Message.Content}"); +/// +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) + public long NpcId { get; set; } + public string Name { get; set; } = ""; + public string Profession { get; set; } = ""; + public int PersonalityType { get; set; } = 5; + public string UniverseId { get; set; } = ""; + public int Seed { get; set; } = 0; + public bool Cached { get; set; } = false; + public string CacheUrl { get; set; } = ""; + + // 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 exported - set when spawned) + private readonly 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; } + + 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") && npcData["spawned_npc"] is Dictionary spawnedData) { + npcData = spawnedData; + } + + // Handle npc_id conversion - API might return String or int + if (npcData.TryGetValue("npc_id", out var npcIdRaw)) { + if (npcIdRaw is JsonElement npcIdJson) { + NpcId = npcIdJson.GetInt64(); + } else { + NpcId = Convert.ToInt64(npcIdRaw); + } + } + + if (npcData.TryGetValue("name", out var name)) + Name = name.ToString(); + + if (npcData.TryGetValue("profession", out var profession)) + Profession = profession.ToString(); + + if (npcData.TryGetValue("personality_type", out var personalityType)) { + if (personalityType is JsonElement ptJson) { + PersonalityType = ptJson.GetInt32(); + } else { + PersonalityType = Convert.ToInt32(personalityType); + } + } + + if (npcData.TryGetValue("universe_id", out var universeId)) + UniverseId = universeId.ToString(); + + if (npcData.TryGetValue("seed", out var seed)) { + if (seed is JsonElement seedJson) { + Seed = seedJson.GetInt32(); + } else { + Seed = Convert.ToInt32(seed); + } + } + + if (npcData.TryGetValue("cached", out var cached)) { + if (cached is JsonElement cachedJson) { + Cached = cachedJson.GetBoolean(); + } else { + Cached = Convert.ToBoolean(cached); + } + } + + if (npcData.TryGetValue("cache_url", out var cacheUrl)) + CacheUrl = cacheUrl.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 JsonElement slJson) { + StressLevel = slJson.GetInt32(); + } 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 JsonSerializer.Serialize(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 JsonSerializer.Serialize(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 = JsonSerializer.Deserialize>(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 npcId) == true) { + long npcIdValue = 0; + if (npcId is JsonElement npcIdJson) { + npcIdValue = npcIdJson.GetInt64(); + } else { + npcIdValue = Convert.ToInt64(npcId); + } + if (npcIdValue == 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 npcId) == true) { + long npcIdValue = 0; + if (npcId is JsonElement npcIdJson) { + npcIdValue = npcIdJson.GetInt64(); + } else { + npcIdValue = Convert.ToInt64(npcId); + } + if (npcIdValue == 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 JsonElement slJson) { + StressLevel = slJson.GetInt32(); + } else { + StressLevel = Convert.ToInt32(stressLevel); + } + } + + // Store mood change metadata + if (moodTransition.TryGetValue("confidence", out var confidence)) { + if (confidence is JsonElement confJson) { + LastMoodChangeConfidence = confJson.GetDouble(); + } 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); + } + } +} +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff61f79 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# GumYum NPC SDK for .NET + +This is the core GumYum NPC SDK for .NET applications. It provides a clean, modern C# client for integrating AI-powered NPCs into your applications. + +## Features + +- **API Key Authentication**: Simple authentication using API keys from the GumYum dashboard +- **Automatic JWT Token Management**: Handles token exchange and refresh automatically +- **NPC Spawning**: Deterministic NPC spawning with personality types +- **OpenAI-Compatible Chat**: Chat completions API with streaming support +- **Retry Logic**: Built-in retry handling for temporary failures +- **Event-Driven Architecture**: Rich event system for dialogue and request lifecycle + +## Installation + +For non-Unity .NET projects, copy the following files to your project: +- `Client.cs` +- `Managers.cs` +- `NPC.cs` + +### Dependencies + +- .NET 6.0 or later +- System.Text.Json (built-in with .NET) +- Microsoft.Extensions.Logging (optional) + +## Unity Users + +For Unity projects, please use the Unity-specific package located in `Unity/Assets/GumYumNPC/` which includes: +- Unity-specific serialization +- Coroutine support +- MonoBehaviour integration +- Unity package manager support + +## Quick Start + +```csharp +using GumYum.NPC; + +// Create client +var client = new Client() { + ApiKey = "your-api-key", + ApiSecret = "your-api-secret" +}; + +// Spawn an NPC +var npc = await client.NPCs.SpawnAsync("universe-id", seed: 12345); + +// Chat with the NPC +var messages = new List { + new ChatMessage { Role = "user", Content = "Hello!" } +}; + +var response = await npc.Chat.CompletionsAsync(messages); +Console.WriteLine($"{npc.Name}: {response.Choices[0].Message.Content}"); +``` + +## Architecture + +This SDK uses modern C# features: +- Uses `System.Text.Json` for JSON serialization (Unity version uses Newtonsoft.Json) +- Supports `ILogger` for logging integration +- Clean async/await patterns throughout +- No Unity dependencies + +## License + +See LICENSE file in the repository root. \ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..b80ff97 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,79 @@ +# Running C# Tests on Fedora + +## Prerequisites + +### Install .NET SDK on Fedora + +```bash +# Install .NET 8 SDK (current LTS, recommended) +sudo dnf install dotnet-sdk-8.0 + +# Or for .NET 6.0 (older LTS, still supported) +sudo dnf install dotnet-sdk-6.0 + +# Or for .NET 9.0 (latest, STS) +sudo dnf install dotnet-sdk-9.0 + +# Verify installation +dotnet --version +``` + +### .NET Version Support +- **.NET 8.0** - Current LTS (Long Term Support), supported until November 2026 +- **.NET 6.0** - Previous LTS, supported until November 2024 +- **.NET 9.0** - Latest version (Standard Term Support) + +Our SDK targets .NET 6.0+ for maximum compatibility. + +## Running Tests + +### From the clients directory: +```bash +make test-csharp +``` + +### From the csharp directory: +```bash +cd csharp +dotnet test GumYum.NPC.Tests/GumYum.NPC.Tests.csproj +``` + +### Run with detailed output: +```bash +dotnet test GumYum.NPC.Tests/GumYum.NPC.Tests.csproj -v detailed +``` + +### Run with code coverage: +```bash +dotnet test GumYum.NPC.Tests/GumYum.NPC.Tests.csproj --collect:"XPlat Code Coverage" +``` + +## Troubleshooting + +### If .NET is not found: +```bash +# Check if .NET is installed +which dotnet + +# List available .NET packages +dnf search dotnet-sdk + +# Install specific version +sudo dnf install dotnet-sdk-6.0 +``` + +### If tests fail to build: +```bash +# Restore packages first +dotnet restore GumYum.NPC.Tests/GumYum.NPC.Tests.csproj + +# Then build +dotnet build GumYum.NPC.Tests/GumYum.NPC.Tests.csproj + +# Then test +dotnet test GumYum.NPC.Tests/GumYum.NPC.Tests.csproj +``` + +## Unity Tests + +Unity tests require the Unity Editor and cannot be run from command line on Fedora without Unity. They must be run within the Unity Test Runner in the Unity Editor. \ No newline at end of file