Initial commit of Unity SDK for GumYum NPC client

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

47
CHANGELOG.md Normal file
View file

@ -0,0 +1,47 @@
# Changelog
All notable changes to the GumYum NPC SDK for Unity will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2024-01-15
### Added
- Initial release of GumYum NPC SDK for Unity
- API key authentication support
- Deterministic NPC spawning with universe and seed parameters
- Filtered NPC spawning based on profession, location, personality type, and sex
- OpenAI-compatible chat completions API
- Real-time streaming chat responses
- Dynamic mood system with transitions
- Stress level tracking
- Conversation history management
- Unity coroutine integration
- Async/await support for modern C# workflows
- GumYumUnityClient singleton for easy scene integration
- Custom Editor window for API settings
- Example scenes demonstrating basic and advanced features
- Comprehensive documentation
### Dependencies
- Unity 2021.3 or higher
- Newtonsoft JSON Unity Package (com.unity.nuget.newtonsoft-json) 3.2.1
## [0.9.0-beta] - 2024-01-01
### Added
- Beta release for testing
- Core API functionality
- Basic NPC spawning and chat
### Known Issues
- Streaming responses may have occasional connection drops
- Mood transitions require fine-tuning
## [0.1.0-alpha] - 2023-12-15
### Added
- Alpha release
- Initial API integration
- Basic spawning functionality

7
CHANGELOG.md.meta Normal file
View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5e049940e066dda70aee71862ca14aea
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Documentation.meta Normal file
View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b99f4f06d9e0881f2ae9f362da2e3c8a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

229
Documentation/README.md Normal file
View file

@ -0,0 +1,229 @@
# GumYum NPC SDK for Unity
Welcome to the GumYum NPC SDK for Unity! This SDK allows you to integrate AI-powered NPCs with deterministic spawning and dynamic dialogue into your Unity games.
## Features
- **API Key Authentication**: Simple setup with API keys from the GumYum dashboard
- **Deterministic NPC Spawning**: Spawn the same NPCs with the same seed
- **Filtered Spawning**: Spawn NPCs based on profession, location, personality type, and more
- **OpenAI-Compatible Chat**: Use familiar chat completion APIs
- **Mood System**: NPCs have dynamic moods that change based on conversations
- **Streaming Support**: Real-time streaming of NPC responses
- **Unity Coroutine Support**: Seamless integration with Unity's async patterns
- **Conversation History**: Maintain context across multiple interactions
## Quick Start
### 1. Installation
1. Import the GumYum NPC package from the Unity Asset Store
2. The package will be installed in `Assets/GumYumNPC/`
### 2. Get API Credentials
1. Sign up at https://npc.gumyum.com
2. Navigate to your dashboard
3. Create a new API key pair
4. Copy your public and secret keys
### 3. Basic Setup
Add the GumYumUnityClient to your scene:
```csharp
using GumYum.NPC.Unity;
public class GameManager : MonoBehaviour
{
private GumYumUnityClient client;
void Start()
{
// Get the singleton instance
client = GumYumUnityClient.Instance;
// Set your API credentials
client.SetCredentials("your-public-key", "your-secret-key");
// Start spawning NPCs!
StartCoroutine(SpawnMyFirstNPC());
}
IEnumerator SpawnMyFirstNPC()
{
// Spawn a random NPC from the "blade-runner" universe
yield return client.SpawnNPCCoroutine("blade-runner", 42, null, npc =>
{
if (npc != null)
{
Debug.Log($"Spawned {npc.Name} the {npc.Profession}!");
}
});
}
}
```
### 4. Chat with NPCs
```csharp
IEnumerator ChatWithNPC(NPC npc, string message)
{
yield return client.SimpleChatCoroutine(npc, message, response =>
{
if (response != null)
{
Debug.Log($"{npc.Name}: {response}");
}
});
}
```
## Examples
The SDK includes several example scenes in the `Samples~` folder:
1. **Basic Chat**: Simple NPC spawning and chat interface
2. **Advanced Features**: Filtered spawning, mood transitions, and streaming
To import samples:
1. Open Package Manager (Window > Package Manager)
2. Find GumYum NPC SDK
3. Click on "Samples" and import the examples you want
## Advanced Usage
### Filtered NPC Spawning
```csharp
// Spawn a female warrior or knight from the barracks
var filters = new Dictionary<string, object>
{
["profession"] = new[] { "warrior", "knight" },
["location"] = new[] { "barracks" },
["sex"] = new[] { "female" }
};
yield return client.SpawnFilteredNPCCoroutine("kingdom", 12345, filters, npc =>
{
if (npc != null)
{
Debug.Log($"Spawned filtered NPC: {npc.GetInfo()}");
}
});
```
### Mood Transitions
NPCs have dynamic moods that change based on conversations:
```csharp
// Check mood before conversation
string previousMood = npc.GetMood();
// Have a conversation...
yield return ChatWithNPC(npc, "I have terrible news!");
// Check if mood changed
if (npc.HasMoodChanged())
{
Debug.Log($"Mood changed from {npc.GetPreviousMood()} to {npc.GetMood()}");
Debug.Log($"Reason: {npc.GetLastMoodChangeReason()}");
}
```
### Streaming Responses
For real-time chat experiences:
```csharp
var messages = new List<ChatMessage>
{
new ChatMessage { Role = "user", Content = "Tell me a story" }
};
// Enable streaming in the chat call
yield return client.ChatCoroutine(npc, messages, response =>
{
// Final response received
}, temperature: 0.8, maxTokens: 2000);
// Listen to streaming events
npc.DialogueChunkReceived += (sender, chunk) =>
{
// Update UI with each chunk as it arrives
chatText.text += chunk;
};
```
### Async/Await Support
The SDK also supports modern C# async patterns:
```csharp
async Task SpawnAndChatAsync()
{
var npc = await client.NPCs.SpawnAsync("blade-runner", 42);
var messages = new List<ChatMessage>
{
new ChatMessage { Role = "user", Content = "Hello!" }
};
var response = await npc.Chat.CompletionsAsync(messages);
Debug.Log(response.Choices[0].Message.Content);
}
```
## API Reference
### GumYumUnityClient
- `SetCredentials(apiKey, apiSecret)`: Set API credentials
- `SpawnNPCCoroutine(universeId, seed, npcId, callback)`: Spawn an NPC
- `SpawnFilteredNPCCoroutine(universeId, seed, filters, callback)`: Spawn filtered NPC
- `ChatCoroutine(npc, messages, callback, temperature, maxTokens)`: Chat with NPC
- `SimpleChatCoroutine(npc, message, callback, temperature, maxTokens)`: Simple chat helper
### NPC Class
- `Name`, `Profession`, `PersonalityType`: NPC properties
- `GetMood()`, `GetStressLevel()`: Current state
- `Chat.CompletionsAsync(messages)`: Chat with the NPC
- `ChatWithHistoryAsync(message)`: Chat with conversation history
- `SaveToServerAsync(customName)`: Save NPC for later retrieval
## Best Practices
1. **Singleton Usage**: Use `GumYumUnityClient.Instance` for a single client across your game
2. **Error Handling**: Always check if NPCs are null after spawning
3. **API Keys**: Never commit API keys to source control. Use environment variables or Unity's secret management
4. **Coroutines**: Use coroutines for better Unity integration, especially for UI updates
5. **Mood Awareness**: Design conversations considering NPC mood states
## Troubleshooting
### NPCs not spawning
- Check your API credentials are correct
- Ensure you have an active internet connection
- Check the Unity console for error messages
### Mood not changing
- Not all messages trigger mood changes
- Mood transitions depend on NPC personality and context
- Check `HasMoodChanged()` after conversations
### Performance issues
- Use appropriate max_tokens limits
- Consider implementing response caching
- Use streaming for long responses
## Support
- Documentation: https://docs.gumyum.com/sdk/unity
- Discord: https://discord.gg/gumyum
- Email: support@gumyum.com
## License
This SDK is licensed under the MIT License. See LICENSE file for details.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f0eec3f6399f17ed1b6a1640adaf003b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Editor.meta Normal file
View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 32634cb39ade82a63bc93736d2c6117d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,18 @@
{
"name": "GumYumNPC.Editor",
"rootNamespace": "GumYum.NPC.Editor",
"references": [
"GumYumNPC.Runtime"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View file

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

View file

@ -0,0 +1,139 @@
using UnityEngine;
using UnityEditor;
namespace GumYum.NPC.Editor {
public class GumYumSettingsWindow : EditorWindow {
private string apiKey = "";
private string apiSecret = "";
private bool showSecret = false;
[MenuItem("Window/GumYum NPC/Settings")]
public static void ShowWindow() {
var window = GetWindow<GumYumSettingsWindow>("GumYum NPC Settings");
window.minSize = new Vector2(400, 200);
}
private void OnEnable() {
// Load saved credentials
apiKey = EditorPrefs.GetString("GumYum_APIKey", "");
apiSecret = EditorPrefs.GetString("GumYum_APISecret", "");
}
private void OnGUI() {
GUILayout.Label("GumYum NPC SDK Settings", EditorStyles.boldLabel);
GUILayout.Space(10);
GUILayout.Label("API Credentials", EditorStyles.label);
GUILayout.Label("Get your API keys from https://npc.gumyum.com",
EditorStyles.miniLabel);
GUILayout.Space(5);
// API Key
GUILayout.BeginHorizontal();
GUILayout.Label("API Key:", GUILayout.Width(80));
apiKey = EditorGUILayout.TextField(apiKey);
GUILayout.EndHorizontal();
// API Secret
GUILayout.BeginHorizontal();
GUILayout.Label("API Secret:", GUILayout.Width(80));
if (showSecret) {
apiSecret = EditorGUILayout.TextField(apiSecret);
} else {
apiSecret = EditorGUILayout.PasswordField(apiSecret);
}
showSecret = GUILayout.Toggle(showSecret, "Show", GUILayout.Width(50));
GUILayout.EndHorizontal();
GUILayout.Space(10);
// Save button
if (GUILayout.Button("Save Settings")) {
SaveSettings();
}
GUILayout.Space(10);
// Help section
EditorGUILayout.HelpBox("These credentials will be saved in Editor " +
"preferences and are not included in builds. " +
"For production, set credentials at runtime " +
"or use environment variables.",
MessageType.Info);
GUILayout.FlexibleSpace();
// Links
if (GUILayout.Button("Open Documentation")) {
Application.OpenURL("https://docs.gumyum.com/sdk/unity");
}
if (GUILayout.Button("Get API Keys")) {
Application.OpenURL("https://npc.gumyum.com");
}
}
private void SaveSettings() {
EditorPrefs.SetString("GumYum_APIKey", apiKey);
EditorPrefs.SetString("GumYum_APISecret", apiSecret);
Debug.Log("GumYum NPC settings saved!");
// If there's an active client in play mode, update it
if (Application.isPlaying && Unity.GumYumUnityClient.Instance != null) {
Unity.GumYumUnityClient.Instance.SetCredentials(apiKey, apiSecret);
}
}
}
// Custom property drawer for NPC display in inspector
[CustomEditor(typeof(MonoBehaviour), true)]
public class NPCInspector : UnityEditor.Editor {
private bool hasNPCField = false;
private void OnEnable() {
// Check if this MonoBehaviour has any NPC fields
var fields =
target.GetType().GetFields(System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
foreach (var field in fields) {
if (field.FieldType == typeof(NPC)) {
hasNPCField = true;
break;
}
}
}
public override void OnInspectorGUI() {
base.OnInspectorGUI();
if (hasNPCField && Application.isPlaying) {
GUILayout.Space(10);
GUILayout.Label("NPC Information", EditorStyles.boldLabel);
var fields =
target.GetType().GetFields(System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
foreach (var field in fields) {
if (field.FieldType == typeof(NPC)) {
var npc = field.GetValue(target) as NPC;
if (npc != null) {
EditorGUILayout.LabelField("Name:", npc.Name);
EditorGUILayout.LabelField("Profession:", npc.Profession);
EditorGUILayout.LabelField("Personality Type:",
npc.PersonalityType.ToString());
EditorGUILayout.LabelField("Current Mood:", npc.GetMood());
EditorGUILayout.LabelField("Stress Level:",
npc.GetStressLevel().ToString());
EditorGUILayout.LabelField("Location:", npc.GetLocation());
}
}
}
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7880f7f75de6c99288830dccdf7a1361

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.

21
LICENSE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 GumYum
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

7
LICENSE.md.meta Normal file
View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c8ead819ea09dab5ba5f6f61706b36d8
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

7
LICENSE.meta Normal file
View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 821bb3e833eed6a23bb9bf74ddad7a8c
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Runtime.meta Normal file
View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 238d2623bfd132eb38548147c45c1e43
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

620
Runtime/Client.cs Normal file
View file

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

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

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

View file

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

View file

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

View file

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

View file

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

373
Runtime/Managers.cs Normal file
View file

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

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

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

542
Runtime/NPC.cs Normal file
View file

@ -0,0 +1,542 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace GumYum.NPC {
/// <summary>
/// GumYum NPC Class - Elegant chat interface similar to Python client
///
/// This class represents a spawned NPC and provides convenient methods
/// for chatting with them. Similar to Python's npc.chat.completions()
///
/// Usage:
/// var npc = await client.NPCs.SpawnAsync("universe-id", 12345, 123456789);
/// var response = await npc.Chat.CompletionsAsync(new[] { new ChatMessage {
/// Role = "user", Content = "Hello!" } }); Debug.Log($"{npc.Name}:
/// {response.Choices[0].Message.Content}");
/// </summary>
[System.Serializable]
public class NPC {
// Events
public event EventHandler<DialogueReceivedEventArgs> DialogueReceived;
public event EventHandler<string> DialogueChunkReceived;
public event EventHandler DialogueStreamStarted;
public event EventHandler<DialogueStreamEndedEventArgs> DialogueStreamEnded;
// NPC Data (from API response) - Serializable for Unity Inspector
[SerializeField]
private long npcId;
[SerializeField]
private string name = "";
[SerializeField]
private string profession = "";
[SerializeField]
private int personalityType = 5;
[SerializeField]
private string universeId = "";
[SerializeField]
private int seed = 0;
[SerializeField]
private bool cached = false;
[SerializeField]
private string cacheUrl = "";
// Properties for public access
public long NpcId => npcId;
public string Name => name;
public string Profession => profession;
public int PersonalityType => personalityType;
public string UniverseId => universeId;
public int Seed => seed;
public bool Cached => cached;
public string CacheUrl => cacheUrl;
// Spawned data (nested)
public Dictionary<string, object> Spawned { get; set; } =
new Dictionary<string, object>();
// Current mood state
public string CurrentMood { get; private set; } = "";
public string PreviousMood { get; private set; } = "";
public int StressLevel { get; private set; } = 5;
public string LastMoodChangeReason { get; private set; } = "";
public double LastMoodChangeConfidence { get; private set; } = 0.0;
// Client reference (not serialized)
[System.NonSerialized]
private Client _client;
// Chat history for this NPC (array of message dicts with role and content)
public List<ChatMessage> ChatHistory { get; } = new List<ChatMessage>();
// Internal chat manager for this NPC
public NPCChatManager Chat { get; private set; }
public NPC(Dictionary<string, object> npcData, Client client) {
_client = client;
SetupFromData(npcData);
Chat = new NPCChatManager(this);
ConnectClientSignals();
}
/// <summary>
/// Setup NPC from API response data
/// </summary>
private void SetupFromData(Dictionary<string, object> npcData) {
// Check if data is nested in "spawned_npc" first
if (npcData.ContainsKey("spawned_npc")) {
if (npcData["spawned_npc"] is Dictionary<string, object> spawnedData) {
npcData = spawnedData;
} else if (npcData["spawned_npc"] is Newtonsoft.Json.Linq.JObject jObj) {
npcData = jObj.ToObject<Dictionary<string, object>>();
}
}
// Handle npc_id conversion - API might return String or int
if (npcData.TryGetValue("npc_id", out var npcIdRaw)) {
if (npcIdRaw is Newtonsoft.Json.Linq.JValue jValue) {
npcId = jValue.ToObject<long>();
} else {
npcId = Convert.ToInt64(npcIdRaw);
}
}
if (npcData.TryGetValue("name", out var nameValue))
name = nameValue.ToString();
if (npcData.TryGetValue("profession", out var professionValue))
profession = professionValue.ToString();
if (npcData.TryGetValue("personality_type", out var personalityTypeValue)) {
if (personalityTypeValue is Newtonsoft.Json.Linq.JValue ptJValue) {
personalityType = ptJValue.ToObject<int>();
} else {
personalityType = Convert.ToInt32(personalityTypeValue);
}
}
if (npcData.TryGetValue("universe_id", out var universeIdValue))
universeId = universeIdValue.ToString();
// Try "world_seed" first (what Elixir API returns), then fall back to
// "seed"
if (npcData.TryGetValue("world_seed", out var seedValue)) {
if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) {
seed = seedJValue.ToObject<int>();
} else {
seed = Convert.ToInt32(seedValue);
}
} else if (npcData.TryGetValue("seed", out seedValue)) {
if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) {
seed = seedJValue.ToObject<int>();
} else {
seed = Convert.ToInt32(seedValue);
}
}
if (npcData.TryGetValue("cached", out var cachedValue)) {
if (cachedValue is Newtonsoft.Json.Linq.JValue cachedJValue) {
cached = cachedJValue.ToObject<bool>();
} else {
cached = Convert.ToBoolean(cachedValue);
}
}
if (npcData.TryGetValue("cache_url", out var cacheUrlValue))
cacheUrl = cacheUrlValue.ToString();
// Handle spawned data
if (npcData.TryGetValue("spawned", out var spawned) &&
spawned is Dictionary<string, object> spawnedDict) {
Spawned = spawnedDict;
// Initialize mood from spawned data
if (spawnedDict.TryGetValue("mood", out var mood))
CurrentMood = mood.ToString();
if (spawnedDict.TryGetValue("stress_level", out var stressLevel)) {
if (stressLevel is Newtonsoft.Json.Linq.JValue slJValue) {
StressLevel = slJValue.ToObject<int>();
} else {
StressLevel = Convert.ToInt32(stressLevel);
}
}
}
}
/// <summary>
/// Convenience method - direct completions alias (like Python
/// npc.completions())
/// </summary>
public Task<ChatCompletionResponse>
CompletionsAsync(List<ChatMessage> messages, double temperature = 0.8,
int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
return Chat.CompletionsAsync(messages, temperature, maxTokens, stream,
cancellationToken);
}
/// <summary>
/// Chat with conversation history - includes all previous messages in this
/// session
/// </summary>
public Task<ChatCompletionResponse>
ChatWithHistoryAsync(string message, double temperature = 0.8,
int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
return Chat.ChatWithHistoryAsync(message, temperature, maxTokens, stream,
cancellationToken);
}
/// <summary>
/// Clear conversation history
/// </summary>
public void ClearHistory() { ChatHistory.Clear(); }
/// <summary>
/// Convert chat history to JSON string for saving
/// </summary>
public string ChatHistoryToJson() {
return JsonConvert.SerializeObject(ChatHistory);
}
/// <summary>
/// Convert entire NPC data to dictionary for saving
/// </summary>
public Dictionary<string, object> ToDict() {
return new Dictionary<string,
object> { ["npc_id"] = npcId,
["name"] = name,
["profession"] = profession,
["personality_type"] = personalityType,
["universe_id"] = universeId,
["seed"] = seed,
["spawned"] = Spawned,
["cached"] = cached,
["cache_url"] = cacheUrl,
["chat_history"] = ChatHistory };
}
/// <summary>
/// Convert NPC data to JSON string for saving
/// </summary>
public string ToJson() { return JsonConvert.SerializeObject(ToDict()); }
/// <summary>
/// Load NPC data from dictionary (e.g., from saved file)
/// </summary>
public static NPC FromDict(Dictionary<string, object> data, Client client) {
return new NPC(data, client);
}
/// <summary>
/// Load NPC data from JSON string
/// </summary>
public static NPC FromJson(string jsonStr, Client client) {
var data =
JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonStr);
return FromDict(data, client);
}
/// <summary>
/// Get NPC's current location
/// </summary>
public string GetLocation() {
return Spawned.TryGetValue("location", out var location)
? location.ToString()
: "unknown";
}
/// <summary>
/// Get NPC's current mood
/// </summary>
public string GetMood() {
if (!string.IsNullOrEmpty(CurrentMood))
return CurrentMood;
return Spawned.TryGetValue("mood", out var mood) ? mood.ToString()
: "neutral";
}
/// <summary>
/// Get NPC's stress level
/// </summary>
public int GetStressLevel() { return StressLevel; }
/// <summary>
/// Get previous mood (before last change)
/// </summary>
public string GetPreviousMood() { return PreviousMood; }
/// <summary>
/// Get last mood change reason
/// </summary>
public string GetLastMoodChangeReason() { return LastMoodChangeReason; }
/// <summary>
/// Get last mood change confidence
/// </summary>
public double GetLastMoodChangeConfidence() {
return LastMoodChangeConfidence;
}
/// <summary>
/// Check if mood has changed
/// </summary>
public bool HasMoodChanged() {
return !string.IsNullOrEmpty(PreviousMood) && PreviousMood != CurrentMood;
}
/// <summary>
/// Get detailed info string
/// </summary>
public string GetInfo() {
return $"{name} the {profession} (Type {personalityType}) at {GetLocation()}, feeling {GetMood()}";
}
/// <summary>
/// Get Enneagram type description
/// </summary>
public string GetEnneagramDescription() {
return personalityType switch {
1 => "The Reformer",
2 => "The Helper",
3 => "The Achiever",
4 => "The Individualist",
5 => "The Investigator",
6 => "The Loyalist",
7 => "The Enthusiast",
8 => "The Challenger",
9 => "The Peacemaker",
_ => "Unknown Type"
};
}
/// <summary>
/// Convert NPC to dictionary for API params
/// </summary>
public Dictionary<string, object> ToDictionary() {
return new Dictionary<string, object> {
["npc_id"] = npcId,
["universe_id"] = universeId,
["seed"] = seed
};
}
/// <summary>
/// Save this NPC to the server (persistent storage)
/// </summary>
public async
Task SaveToServerAsync(string customName = "",
CancellationToken cancellationToken = default) {
var data = new Dictionary<string, object> { ["universe_id"] = universeId,
["world_seed"] = seed,
["npc_id"] = npcId };
if (!string.IsNullOrEmpty(customName))
data["custom_name"] = customName;
await _client.RequestAsync<Dictionary<string, object>>(
System.Net.Http.HttpMethod.Post, "npc/save", data, null,
cancellationToken);
}
/// <summary>
/// Connect to client's dialogue signals to forward them
/// </summary>
private void ConnectClientSignals() {
if (_client != null) {
_client.DialogueReceived += OnClientDialogueReceived;
_client.DialogueChunkReceived += OnClientDialogueChunkReceived;
_client.DialogueStreamStarted += OnClientDialogueStreamStarted;
_client.DialogueStreamEnded += OnClientDialogueStreamEnded;
}
}
private void OnClientDialogueReceived(object sender,
DialogueReceivedEventArgs e) {
// Only emit if this response is for our NPC
if (e.Context?.TryGetValue("npc_id", out var npcIdValue) == true) {
long npcIdLong = 0;
if (npcIdValue is Newtonsoft.Json.Linq.JValue jValue) {
npcIdLong = jValue.ToObject<long>();
} else {
npcIdLong = Convert.ToInt64(npcIdValue);
}
if (npcIdLong == npcId) {
// Update mood data if transition occurred
UpdateMoodFromTransition(e.MoodTransition);
DialogueReceived?.Invoke(this, e);
}
}
}
private void OnClientDialogueChunkReceived(object sender, string chunk) {
DialogueChunkReceived?.Invoke(this, chunk);
}
private void OnClientDialogueStreamStarted(object sender, EventArgs e) {
DialogueStreamStarted?.Invoke(this, e);
}
private void OnClientDialogueStreamEnded(object sender,
DialogueStreamEndedEventArgs e) {
// Only emit if this response is for our NPC
if (e.Context?.TryGetValue("npc_id", out var npcIdValue) == true) {
long npcIdLong = 0;
if (npcIdValue is Newtonsoft.Json.Linq.JValue jValue) {
npcIdLong = jValue.ToObject<long>();
} else {
npcIdLong = Convert.ToInt64(npcIdValue);
}
if (npcIdLong == npcId) {
// Update mood data if transition occurred
UpdateMoodFromTransition(e.MoodTransition);
DialogueStreamEnded?.Invoke(this, e);
}
}
}
/// <summary>
/// Update internal mood state from mood transition data
/// </summary>
private void
UpdateMoodFromTransition(Dictionary<string, object> moodTransition) {
if (moodTransition == null || moodTransition.Count == 0)
return;
// Store previous mood if we're changing
if (moodTransition.TryGetValue("new_mood", out var newMood) &&
newMood.ToString() != CurrentMood) {
PreviousMood = CurrentMood;
CurrentMood = newMood.ToString();
}
// Update stress level
if (moodTransition.TryGetValue("stress_level", out var stressLevel)) {
if (stressLevel is Newtonsoft.Json.Linq.JValue slJValue) {
StressLevel = slJValue.ToObject<int>();
} else {
StressLevel = Convert.ToInt32(stressLevel);
}
}
// Store mood change metadata
if (moodTransition.TryGetValue("confidence", out var confidence)) {
if (confidence is Newtonsoft.Json.Linq.JValue confJValue) {
LastMoodChangeConfidence = confJValue.ToObject<double>();
} else {
LastMoodChangeConfidence = Convert.ToDouble(confidence);
}
}
if (moodTransition.TryGetValue("reasoning", out var reasoning)) {
LastMoodChangeReason = reasoning.ToString();
}
}
/// <summary>
/// Chat manager class - provides the .Chat interface
/// </summary>
public class NPCChatManager {
private readonly NPC _npc;
public NPCChatManager(NPC npc) { _npc = npc; }
/// <summary>
/// Main chat completion method - matches Python API
/// </summary>
public async Task<ChatCompletionResponse>
CompletionsAsync(List<ChatMessage> messages, double temperature = 0.8,
int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
// Build npc_params with this NPC's context including current mood
var npcParams =
new Dictionary<string, object> { ["universe_id"] = _npc.universeId,
["world_seed"] = _npc.seed,
["npc_id"] = _npc.npcId,
["current_mood"] = _npc.GetMood(),
["stress_level"] =
_npc.GetStressLevel() };
ChatCompletionResponse response;
if (stream) {
// For streaming, we need to handle it differently
var tcs = new TaskCompletionSource<ChatCompletionResponse>();
string accumulatedContent = "";
Dictionary<string, object> lastContext = null;
Dictionary<string, object> lastMoodTransition = null;
await _npc._client.Chat.CompletionsStreamAsync(
messages, npcParams, chunk => {
if (chunk == null) // Stream ended
{
// Create a response object with the accumulated content
var streamResponse =
new ChatCompletionResponse(new Dictionary<string, object> {
["choices"] =
new List<object> { new Dictionary<string, object> {
["message"] = new Dictionary<
string, object> { ["role"] = "assistant",
["content"] =
accumulatedContent }
} },
["npc_context"] = lastContext,
["mood_transition"] = lastMoodTransition
});
tcs.SetResult(streamResponse);
} else {
accumulatedContent += chunk;
}
}, temperature, maxTokens, cancellationToken);
response = await tcs.Task;
} else {
response = await _npc._client.Chat.CompletionsAsync(
messages, npcParams, temperature, maxTokens, false,
cancellationToken);
}
// Update chat history
if (response?.Choices?.Count > 0) {
// Add user message(s) to history
if (messages.Count > 0) {
var lastUserMsg = messages[messages.Count - 1];
if (lastUserMsg.Role == "user") {
_npc.ChatHistory.Add(lastUserMsg);
}
}
// Add assistant response to history
var choice = response.Choices[0];
if (choice.Message != null) {
_npc.ChatHistory.Add(
new ChatMessage { Role = choice.Message.Role,
Content = choice.Message.Content });
}
}
return response;
}
/// <summary>
/// Chat with conversation history - includes all previous messages in this
/// session
/// </summary>
public async Task<ChatCompletionResponse>
ChatWithHistoryAsync(string message, double temperature = 0.8,
int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
// Build messages array with history plus new message
var messagesWithHistory = new List<ChatMessage>(_npc.ChatHistory);
messagesWithHistory.Add(
new ChatMessage { Role = "user", Content = message });
// Use regular completions which will also update history
return await CompletionsAsync(messagesWithHistory, temperature, maxTokens,
stream, cancellationToken);
}
}
}
}

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

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

View file

@ -0,0 +1,215 @@
#if UNITY_5_3_OR_NEWER
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using GumYum.NPC;
using GumYum.NPC.Unity;
namespace GumYum.NPC.Unity.Examples {
/// <summary>
/// Example showing filtered NPC spawning and multiple NPCs
/// </summary>
public class NPCFilteredSpawning : MonoBehaviour {
[Header("API Configuration")]
[SerializeField]
private string apiKey = "your-api-key";
[SerializeField]
private string apiSecret = "your-api-secret";
[SerializeField]
private string universeId = "kingdom";
[SerializeField]
private int worldSeed = 12345;
[Header("Spawn Settings")]
[SerializeField]
private string[] professionFilter = { "warrior", "knight", "guard" };
[SerializeField]
private string[] locationFilter = { "barracks", "castle",
"training_grounds" };
[SerializeField]
private int[] personalityTypeFilter = { 1, 8,
6 }; // Reformer, Challenger, Loyalist
[SerializeField]
private string sexFilter = ""; // "male", "female", or "" for any
private GumYumUnityClient client;
private List<NPC> spawnedNPCs = new List<NPC>();
void Start() {
// Initialize the client
client = GumYumUnityClient.Instance;
client.SetCredentials(apiKey, apiSecret);
// Example 1: Spawn filtered NPCs
StartCoroutine(SpawnFilteredNPCs());
}
private IEnumerator SpawnFilteredNPCs() {
Debug.Log("Spawning filtered NPCs...");
// Build filter object
var filters = new Dictionary<string, object>();
if (professionFilter.Length > 0)
filters["profession"] = professionFilter;
if (locationFilter.Length > 0)
filters["location"] = locationFilter;
if (personalityTypeFilter.Length > 0)
filters["personality_type"] = personalityTypeFilter;
if (!string.IsNullOrEmpty(sexFilter))
filters["sex"] = new[] { sexFilter };
// Spawn 3 NPCs with filters
for (int i = 0; i < 3; i++) {
yield return client.SpawnFilteredNPCCoroutine(
universeId, worldSeed + i, filters, npc => {
if (npc != null) {
spawnedNPCs.Add(npc);
Debug.Log($"Spawned: {npc.GetInfo()}");
// Demonstrate mood and stress info
Debug.Log(
$" - Current mood: {npc.GetMood()}, Stress level: {npc.GetStressLevel()}");
// Example of saving NPC for later
PlayerPrefs.SetString($"SavedNPC_{npc.NpcId}", npc.ToJson());
} else {
Debug.LogError("Failed to spawn filtered NPC");
}
});
yield return new WaitForSeconds(0.5f); // Small delay between spawns
}
// Example 2: Chat with all spawned NPCs
yield return new WaitForSeconds(1f);
yield return ChatWithAllNPCs(
"Hello everyone! How are you all doing today?");
// Example 3: Demonstrate mood transitions
yield return new WaitForSeconds(2f);
yield return DemonstrateMoodTransitions();
}
private IEnumerator ChatWithAllNPCs(string message) {
Debug.Log($"\nBroadcasting message to all NPCs: \"{message}\"");
foreach (var npc in spawnedNPCs) {
var messages =
new List<ChatMessage> { new ChatMessage { Role = "user",
Content = message } };
yield return client.ChatCoroutine(npc, messages, response => {
if (response?.Choices?.Count > 0) {
var content = response.Choices[0].Message.Content;
Debug.Log($"{npc.Name}: {content}");
// Check for mood changes
if (npc.HasMoodChanged()) {
Debug.Log(
$" [Mood transition: {npc.GetPreviousMood()} → {npc.GetMood()}]");
Debug.Log($" [Reason: {npc.GetLastMoodChangeReason()}]");
Debug.Log(
$" [Confidence: {npc.GetLastMoodChangeConfidence():F2}]");
}
}
});
yield return new WaitForSeconds(0.5f);
}
}
private IEnumerator DemonstrateMoodTransitions() {
Debug.Log("\nDemonstrating mood transitions with different messages...");
// Messages designed to potentially trigger different moods
var moodTriggeringMessages = new[] {
"I have terrible news. The kingdom is under attack!",
"Great news! We've won the battle and everyone is safe!",
"I'm not sure what to do. Everything seems so uncertain.",
"You've done an amazing job! The king wants to reward you personally!"
};
foreach (var triggerMessage in moodTriggeringMessages) {
Debug.Log($"\nTesting message: \"{triggerMessage}\"");
// Send to first NPC only for this demo
if (spawnedNPCs.Count > 0) {
var npc = spawnedNPCs[0];
var previousMood = npc.GetMood();
var previousStress = npc.GetStressLevel();
var messages = new List<ChatMessage> {
new ChatMessage { Role = "user", Content = triggerMessage }
};
yield return client.ChatCoroutine(npc, messages, response => {
if (response?.Choices?.Count > 0) {
var content = response.Choices[0].Message.Content;
Debug.Log($"{npc.Name}: {content}");
// Analyze mood change
if (npc.GetMood() != previousMood) {
Debug.Log($" ✓ Mood changed: {previousMood} → {npc.GetMood()}");
} else {
Debug.Log($" - Mood unchanged: {npc.GetMood()}");
}
if (npc.GetStressLevel() != previousStress) {
Debug.Log(
$" ✓ Stress changed: {previousStress} → {npc.GetStressLevel()}");
}
}
});
yield return new WaitForSeconds(2f);
}
}
}
// Example: Load a previously saved NPC
public IEnumerator LoadSavedNPC(int npcId) {
string savedJson = PlayerPrefs.GetString($"SavedNPC_{npcId}", "");
if (!string.IsNullOrEmpty(savedJson)) {
var npc = NPC.FromJson(savedJson, client.Client);
Debug.Log($"Loaded saved NPC: {npc.GetInfo()}");
// You can continue chatting with the loaded NPC
yield return ChatWithNPC(npc, "Do you remember our last conversation?");
}
}
private IEnumerator ChatWithNPC(NPC npc, string message) {
var messages =
new List<ChatMessage> { new ChatMessage { Role = "user",
Content = message } };
yield return client.ChatCoroutine(npc, messages, response => {
if (response?.Choices?.Count > 0) {
Debug.Log($"{npc.Name}: {response.Choices[0].Message.Content}");
}
});
}
// Example: Get all NPCs of a specific mood
public List<NPC> GetNPCsByMood(string mood) {
return spawnedNPCs.FindAll(npc => npc.GetMood() == mood);
}
// Example: Get stressed NPCs (stress > 7)
public List<NPC> GetStressedNPCs() {
return spawnedNPCs.FindAll(npc => npc.GetStressLevel() > 7);
}
private void OnDestroy() {
// Clean up saved NPCs if needed
foreach (var npc in spawnedNPCs) {
PlayerPrefs.DeleteKey($"SavedNPC_{npc.NpcId}");
}
}
}
}
#endif

View file

@ -0,0 +1,228 @@
#if UNITY_5_3_OR_NEWER
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using GumYum.NPC;
using GumYum.NPC.Unity;
namespace GumYum.NPC.Unity.Examples {
/// <summary>
/// Simple example showing NPC spawning and dialogue
/// Attach this to a GameObject in your scene
/// </summary>
public class QuickstartChat : MonoBehaviour {
[Header("API Configuration")]
[SerializeField]
private string apiKey = "your-api-key";
[SerializeField]
private string apiSecret = "your-api-secret";
[SerializeField]
private string universeId = "blade-runner";
[SerializeField]
private int worldSeed = 42;
[Header("UI References")]
[SerializeField]
private Text npcNameText;
[SerializeField]
private Text npcInfoText;
[SerializeField]
private InputField userInput;
[SerializeField]
private Button sendButton;
[SerializeField]
private Text chatOutput;
[SerializeField]
private ScrollRect scrollRect;
[Header("Chat Settings")]
[SerializeField]
private float temperature = 0.8f;
[SerializeField]
private int maxTokens = 2000;
[SerializeField]
private bool useStreamingChat = false;
private GumYumUnityClient client;
private NPC currentNPC;
private bool isProcessing = false;
void Start() {
// Initialize the client
client = GumYumUnityClient.Instance;
client.SetCredentials(apiKey, apiSecret);
// Setup UI
sendButton.onClick.AddListener(OnSendButtonClicked);
userInput.onEndEdit.AddListener(OnInputEndEdit);
// Subscribe to events
client.OnCharacterSpawned += OnCharacterSpawned;
client.OnDialogueReceived += OnDialogueReceived;
client.OnDialogueChunkReceived += OnDialogueChunkReceived;
client.OnRequestFailed += OnRequestFailed;
// Start by spawning an NPC
StartCoroutine(SpawnRandomNPC());
}
private IEnumerator SpawnRandomNPC() {
UpdateChatOutput("Spawning a random NPC...");
yield return client.SpawnNPCCoroutine(universeId, worldSeed, null, npc => {
if (npc != null) {
currentNPC = npc;
UpdateNPCInfo();
UpdateChatOutput($"\n{npc.Name} has entered the chat!\n");
UpdateChatOutput(
$"[{npc.GetMood()} mood, stress level: {npc.GetStressLevel()}]\n\n");
} else {
UpdateChatOutput(
"\nFailed to spawn NPC. Check your API credentials.\n");
}
});
}
private void OnCharacterSpawned(NPC npc) {
Debug.Log($"Character spawned: {npc.GetInfo()}");
}
private void OnDialogueReceived(string response,
Dictionary<string, object> context,
Dictionary<string, object> moodTransition) {
if (!useStreamingChat) {
UpdateChatOutput($"\n{currentNPC.Name}: {response}\n");
if (currentNPC.HasMoodChanged()) {
UpdateChatOutput(
$"[Mood changed from {currentNPC.GetPreviousMood()} to {currentNPC.GetMood()}]\n");
}
}
}
private void OnDialogueChunkReceived(string chunk) {
if (useStreamingChat) {
UpdateChatOutput(chunk);
}
}
private void OnRequestFailed(string endpoint, string error) {
Debug.LogError($"Request failed - {endpoint}: {error}");
UpdateChatOutput($"\n[Error: {error}]\n");
isProcessing = false;
}
private void OnSendButtonClicked() {
if (!isProcessing && !string.IsNullOrEmpty(userInput.text) &&
currentNPC != null) {
SendMessage(userInput.text);
}
}
private void OnInputEndEdit(string value) {
if (Input.GetKeyDown(KeyCode.Return) ||
Input.GetKeyDown(KeyCode.KeypadEnter)) {
OnSendButtonClicked();
}
}
private void SendMessage(string message) {
isProcessing = true;
// Display user message
UpdateChatOutput($"\nYou: {message}\n");
// Clear input
userInput.text = "";
userInput.ActivateInputField();
// Send to NPC
if (useStreamingChat) {
StartCoroutine(StreamChatWithNPC(message));
} else {
StartCoroutine(ChatWithNPC(message));
}
}
private IEnumerator ChatWithNPC(string message) {
yield return client.SimpleChatCoroutine(currentNPC, message, response => {
isProcessing = false;
if (response == null) {
UpdateChatOutput("\n[Failed to get response]\n");
}
UpdateNPCInfo();
}, temperature, maxTokens);
}
private IEnumerator StreamChatWithNPC(string message) {
UpdateChatOutput($"\n{currentNPC.Name}: ");
var messages =
new List<ChatMessage> { new ChatMessage { Role = "user",
Content = message } };
bool streamComplete = false;
string fullResponse = "";
// Use streaming
var task = currentNPC.Chat.CompletionsAsync(messages, temperature,
maxTokens, true);
// Set up streaming event handlers
void OnChunk(object sender, string chunk) {
fullResponse += chunk;
UpdateChatOutput(chunk);
}
void OnStreamEnd(object sender, DialogueStreamEndedEventArgs e) {
streamComplete = true;
if (currentNPC.HasMoodChanged()) {
UpdateChatOutput(
$"\n[Mood changed from {currentNPC.GetPreviousMood()} to {currentNPC.GetMood()}]\n");
}
}
currentNPC.DialogueChunkReceived += OnChunk;
currentNPC.DialogueStreamEnded += OnStreamEnd;
// Wait for stream to complete
yield return new WaitUntil(() => streamComplete || task.IsCompleted);
// Clean up event handlers
currentNPC.DialogueChunkReceived -= OnChunk;
currentNPC.DialogueStreamEnded -= OnStreamEnd;
UpdateChatOutput("\n");
UpdateNPCInfo();
isProcessing = false;
}
private void UpdateNPCInfo() {
if (currentNPC != null) {
npcNameText.text = currentNPC.Name;
npcInfoText.text =
$"{currentNPC.Profession} | Type {currentNPC.PersonalityType} | {currentNPC.GetLocation()}\n" +
$"Mood: {currentNPC.GetMood()} | Stress: {currentNPC.GetStressLevel()}";
}
}
private void UpdateChatOutput(string text) {
chatOutput.text += text;
// Auto-scroll to bottom
Canvas.ForceUpdateCanvases();
scrollRect.verticalNormalizedPosition = 0f;
}
private void OnDestroy() {
if (client != null) {
client.OnCharacterSpawned -= OnCharacterSpawned;
client.OnDialogueReceived -= OnDialogueReceived;
client.OnDialogueChunkReceived -= OnDialogueChunkReceived;
client.OnRequestFailed -= OnRequestFailed;
}
}
}
}
#endif

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "com.gumyum.npc",
"version": "1.0.0",
"displayName": "GumYum NPC SDK",
"description": "AI-powered NPC dialogue system for Unity. Create intelligent, personality-driven NPCs with deterministic spawning and OpenAI-compatible chat completions.",
"unity": "2021.3",
"unityRelease": "0f1",
"documentationUrl": "https://docs.gumyum.com/sdk/unity",
"changelogUrl": "https://docs.gumyum.com/sdk/unity/changelog",
"licensesUrl": "https://docs.gumyum.com/sdk/unity/licenses",
"keywords": [
"ai",
"npc",
"dialogue",
"chat",
"personality",
"gamedev"
],
"author": {
"name": "GumYum",
"email": "support@gumyum.com",
"url": "https://gumyum.com"
},
"type": "library",
"hideInEditor": false,
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.2.1"
},
"samples": [
{
"displayName": "Basic Chat",
"description": "Simple example showing NPC spawning and dialogue with UI",
"path": "Samples~/BasicChat"
},
{
"displayName": "Advanced Features",
"description": "Filtered spawning, mood transitions, and streaming responses",
"path": "Samples~/AdvancedFeatures"
}
]
}

7
package.json.meta Normal file
View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9e096617b865976428ea587ebffe6c77
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: