Initial commit of C# SDK for GumYum NPC client
This commit is contained in:
commit
bde434356c
7 changed files with 1924 additions and 0 deletions
628
Client.cs
Normal file
628
Client.cs
Normal 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
83
GumYum.NPC.SDK.csproj
Normal 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
202
LICENSE
Normal 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
356
Managers.cs
Normal 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
508
NPC.cs
Normal 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
68
README.md
Normal 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
79
TESTING.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue