Initial commit of C# SDK for GumYum NPC client

This commit is contained in:
Russell Ballestrini 2025-09-15 10:00:27 -04:00
commit bde434356c
7 changed files with 1924 additions and 0 deletions

628
Client.cs Normal file
View file

@ -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 {
/// <summary>
/// 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
/// </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)
_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<Client> _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<Client> 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);
}
/// <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-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);
}
}
/// <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 = 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<T>(responseContent, _jsonOptions);
RequestCompleted?.Invoke(
this, new RequestCompletedEventArgs { Endpoint = endpoint,
Data = result });
return result;
}
// Handle errors
var errorData = JsonSerializer.Deserialize<Dictionary<string, object>>(
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<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)
_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<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)
_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<Dictionary<string, object>>(
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<Dictionary<string, object>>(
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<Dictionary<string, object>>(
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<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 = 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<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)
_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<Dictionary<string, object>>(
dataStr, _jsonOptions);
// Handle different event types
if (eventData.ContainsKey("choices")) {
var choices =
JsonSerializer.Deserialize<List<Dictionary<string, object>>>(
eventData["choices"].ToString(), _jsonOptions);
if (choices?.Count > 0) {
var choice = choices[0];
if (choice.ContainsKey("delta")) {
var delta =
JsonSerializer.Deserialize<Dictionary<string, object>>(
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<Dictionary<string, object>>(
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<Dictionary<string, object>>(
eventData["npc_context"].ToString(), _jsonOptions);
}
if (eventData.ContainsKey("mood_transition")) {
moodTransition =
JsonSerializer.Deserialize<Dictionary<string, object>>(
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<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; }
}
}

83
GumYum.NPC.SDK.csproj Normal file
View file

@ -0,0 +1,83 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<!-- Package Information -->
<PackageId>GumYum.NPC.SDK</PackageId>
<Title>GumYum NPC SDK</Title>
<Version>0.1.0</Version>
<Authors>GumYum NPC API Team</Authors>
<Company>GumYum</Company>
<Description>C# SDK for GumYum NPC API - Cross-engine AI-powered dialogue system for Unity, Godot, and .NET games</Description>
<Copyright>Copyright © 2024 GumYum</Copyright>
<!-- Repository Information -->
<RepositoryUrl>https://github.com/gumyum/npc-api</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<ProjectUrl>https://docs.gumyum.com/sdk/csharp</ProjectUrl>
<PackageProjectUrl>https://docs.gumyum.com/sdk/csharp</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<!-- Package Tags -->
<PackageTags>gamedev;npc;ai;dialogue;unity;godot;csharp;sdk</PackageTags>
<PackageReleaseNotes>Initial release of GumYum NPC SDK for C#/.NET</PackageReleaseNotes>
<!-- Documentation -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<!-- Unity Compatibility -->
<DefineConstants Condition="'$(TargetFramework)' == 'netstandard2.0'">UNITY_COMPATIBLE</DefineConstants>
</PropertyGroup>
<ItemGroup>
<!-- Core Dependencies -->
<PackageReference Include="System.Text.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<!-- Unity Compatibility (netstandard2.0) -->
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4" Condition="'$(TargetFramework)' == 'netstandard2.0'" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" Condition="'$(TargetFramework)' == 'netstandard2.0'" />
</ItemGroup>
<ItemGroup>
<!-- Development Dependencies -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" PrivateAssets="All" />
<PackageReference Include="xunit" Version="2.6.1" PrivateAssets="All" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" PrivateAssets="All" />
<PackageReference Include="Moq" Version="4.20.69" PrivateAssets="All" />
<PackageReference Include="FluentAssertions" Version="6.12.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<!-- Unity Package Files -->
<None Include="Unity\package.json" Pack="true" PackagePath="Unity\" />
<None Include="Unity\README.md" Pack="true" PackagePath="Unity\" />
<None Include="Unity\CHANGELOG.md" Pack="true" PackagePath="Unity\" />
<None Include="Unity\Runtime\**\*.cs" Pack="true" PackagePath="Unity\Runtime\" />
<None Include="Unity\Runtime\**\*.asmdef" Pack="true" PackagePath="Unity\Runtime\" />
<None Include="Unity\Editor\**\*.cs" Pack="true" PackagePath="Unity\Editor\" />
<None Include="Unity\Editor\**\*.asmdef" Pack="true" PackagePath="Unity\Editor\" />
<None Include="Unity\Samples~\**\*" Pack="true" PackagePath="Unity\Samples~\" />
</ItemGroup>
<ItemGroup>
<!-- Example and Documentation Files -->
<None Include="Examples\**\*.cs" Pack="true" PackagePath="Examples\" />
<None Include="README.md" Pack="true" PackagePath="" />
<None Include="CHANGELOG.md" Pack="true" PackagePath="" />
</ItemGroup>
<Target Name="PrepareUnityPackage" BeforeTargets="Build">
<!-- Copy built DLLs to Unity package structure -->
<MakeDir Directories="Unity\Runtime\Plugins\" />
<Copy SourceFiles="$(OutputPath)$(AssemblyName).dll" DestinationFolder="Unity\Runtime\Plugins\" Condition="Exists('$(OutputPath)$(AssemblyName).dll')" />
</Target>
</Project>

202
LICENSE Normal file
View file

@ -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.

356
Managers.cs Normal file
View file

@ -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 {
/// <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 System.Text.Json
if (universesObj is System.Text.Json.JsonElement jsonElement) {
return System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(jsonElement.GetRawText());
} 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 System.Text.Json
if (universesObj is System.Text.Json.JsonElement jsonElement) {
return System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(jsonElement.GetRawText());
} 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) {
Choices = new List<ChatChoice>();
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<Dictionary<string, object>>(choiceElement.GetRawText());
Choices.Add(new ChatChoice(choiceDict));
}
} else if (choicesObj is List<object> choicesList) {
Choices = choicesList
?.Select(c => new ChatChoice(c as Dictionary<string, object>))
.ToList() ?? new List<ChatChoice>();
}
}
if (data?.ContainsKey("npc_context") == true) {
NpcContext = data["npc_context"] as Dictionary<string, object>;
}
if (data?.ContainsKey("mood_transition") == true) {
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 msgObj = data["message"];
if (msgObj is JsonElement msgJson) {
var msgDict = JsonSerializer.Deserialize<Dictionary<string, object>>(msgJson.GetRawText());
Message = new ChatMessage {
Role = msgDict?.GetValueOrDefault("role")?.ToString() ?? "assistant",
Content = msgDict?.GetValueOrDefault("content")?.ToString() ?? ""
};
} else if (msgObj is Dictionary<string, object> msgData) {
Message = new ChatMessage {
Role = msgData.GetValueOrDefault("role")?.ToString() ?? "assistant",
Content = msgData.GetValueOrDefault("content")?.ToString() ?? ""
};
}
}
}
}
}

508
NPC.cs Normal file
View file

@ -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 {
/// <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!" } }); Console.WriteLine($"{npc.Name}:
/// {response.Choices[0].Message.Content}");
/// </summary>
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)
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<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 exported - set when spawned)
private readonly 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; }
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") && npcData["spawned_npc"] is Dictionary<string, object> 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<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 JsonElement slJson) {
StressLevel = slJson.GetInt32();
} 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 JsonSerializer.Serialize(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 JsonSerializer.Serialize(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 = JsonSerializer.Deserialize<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 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);
}
}
}
/// <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 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();
}
}
/// <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);
}
}
}
}

68
README.md Normal file
View file

@ -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<ChatMessage> {
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<T>` for logging integration
- Clean async/await patterns throughout
- No Unity dependencies
## License
See LICENSE file in the repository root.

79
TESTING.md Normal file
View file

@ -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.