Initial commit of Unity SDK for GumYum NPC client
This commit is contained in:
commit
db46a7bf9d
29 changed files with 3073 additions and 0 deletions
47
CHANGELOG.md
Normal file
47
CHANGELOG.md
Normal 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
7
CHANGELOG.md.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5e049940e066dda70aee71862ca14aea
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Documentation.meta
Normal file
8
Documentation.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b99f4f06d9e0881f2ae9f362da2e3c8a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
229
Documentation/README.md
Normal file
229
Documentation/README.md
Normal 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.
|
||||||
7
Documentation/README.md.meta
Normal file
7
Documentation/README.md.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f0eec3f6399f17ed1b6a1640adaf003b
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Editor.meta
Normal file
8
Editor.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 32634cb39ade82a63bc93736d2c6117d
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
18
Editor/GumYumNPC.Editor.asmdef
Normal file
18
Editor/GumYumNPC.Editor.asmdef
Normal 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
|
||||||
|
}
|
||||||
7
Editor/GumYumNPC.Editor.asmdef.meta
Normal file
7
Editor/GumYumNPC.Editor.asmdef.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2cd2e7dc164d610e49a5f7f121d23534
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
139
Editor/GumYumSettingsWindow.cs
Normal file
139
Editor/GumYumSettingsWindow.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Editor/GumYumSettingsWindow.cs.meta
Normal file
2
Editor/GumYumSettingsWindow.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7880f7f75de6c99288830dccdf7a1361
|
||||||
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.
|
||||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal 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
7
LICENSE.md.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c8ead819ea09dab5ba5f6f61706b36d8
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
7
LICENSE.meta
Normal file
7
LICENSE.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 821bb3e833eed6a23bb9bf74ddad7a8c
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Runtime.meta
Normal file
8
Runtime.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 238d2623bfd132eb38548147c45c1e43
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
620
Runtime/Client.cs
Normal file
620
Runtime/Client.cs
Normal 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
2
Runtime/Client.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aa8266018932c06b7885a800ff63b7a3
|
||||||
22
Runtime/GumYumNPC.Runtime.asmdef
Normal file
22
Runtime/GumYumNPC.Runtime.asmdef
Normal 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
|
||||||
|
}
|
||||||
7
Runtime/GumYumNPC.Runtime.asmdef.meta
Normal file
7
Runtime/GumYumNPC.Runtime.asmdef.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 26a697b167a9ebcc689a6ae9368b98d7
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
293
Runtime/GumYumUnityClient.cs
Normal file
293
Runtime/GumYumUnityClient.cs
Normal 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
|
||||||
2
Runtime/GumYumUnityClient.cs.meta
Normal file
2
Runtime/GumYumUnityClient.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ac7e37da835b6259384d76e3c893d1e0
|
||||||
373
Runtime/Managers.cs
Normal file
373
Runtime/Managers.cs
Normal 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
2
Runtime/Managers.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 232c26c33ff3d4c95b32e8c9178b8bb7
|
||||||
542
Runtime/NPC.cs
Normal file
542
Runtime/NPC.cs
Normal 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
2
Runtime/NPC.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 66691e15e0986a4a4a18cd94e53c917f
|
||||||
215
Samples~/BasicChat/NPCFilteredSpawning.cs
Normal file
215
Samples~/BasicChat/NPCFilteredSpawning.cs
Normal 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
|
||||||
228
Samples~/BasicChat/QuickstartChat.cs
Normal file
228
Samples~/BasicChat/QuickstartChat.cs
Normal 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
41
package.json
Normal 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
7
package.json.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9e096617b865976428ea587ebffe6c77
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Loading…
Add table
Add a link
Reference in a new issue