commit db46a7bf9d606a8faa3c7b479fd25f54facae11b Author: Russell Ballestrini Date: Mon Sep 15 09:59:21 2025 -0400 Initial commit of Unity SDK for GumYum NPC client diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..61bdeaa --- /dev/null +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..f70e7bc --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5e049940e066dda70aee71862ca14aea +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Documentation.meta b/Documentation.meta new file mode 100644 index 0000000..dc92463 --- /dev/null +++ b/Documentation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b99f4f06d9e0881f2ae9f362da2e3c8a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Documentation/README.md b/Documentation/README.md new file mode 100644 index 0000000..2f2e026 --- /dev/null +++ b/Documentation/README.md @@ -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 +{ + ["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 +{ + 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 + { + 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. \ No newline at end of file diff --git a/Documentation/README.md.meta b/Documentation/README.md.meta new file mode 100644 index 0000000..e418cd5 --- /dev/null +++ b/Documentation/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f0eec3f6399f17ed1b6a1640adaf003b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..6e8c73e --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 32634cb39ade82a63bc93736d2c6117d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/GumYumNPC.Editor.asmdef b/Editor/GumYumNPC.Editor.asmdef new file mode 100644 index 0000000..9bc3d29 --- /dev/null +++ b/Editor/GumYumNPC.Editor.asmdef @@ -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 +} \ No newline at end of file diff --git a/Editor/GumYumNPC.Editor.asmdef.meta b/Editor/GumYumNPC.Editor.asmdef.meta new file mode 100644 index 0000000..8ea7c18 --- /dev/null +++ b/Editor/GumYumNPC.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2cd2e7dc164d610e49a5f7f121d23534 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/GumYumSettingsWindow.cs b/Editor/GumYumSettingsWindow.cs new file mode 100644 index 0000000..2f8975d --- /dev/null +++ b/Editor/GumYumSettingsWindow.cs @@ -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("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()); + } + } + } + } + } +} +} \ No newline at end of file diff --git a/Editor/GumYumSettingsWindow.cs.meta b/Editor/GumYumSettingsWindow.cs.meta new file mode 100644 index 0000000..4b3029d --- /dev/null +++ b/Editor/GumYumSettingsWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7880f7f75de6c99288830dccdf7a1361 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94cad6b --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ +Copyright 2025 GumYum Author TimeHexOn timehexon@gumyum.com | + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 GumYum Author TimeHexOn timehexon@gumyum.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6769868 --- /dev/null +++ b/LICENSE.md @@ -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. \ No newline at end of file diff --git a/LICENSE.md.meta b/LICENSE.md.meta new file mode 100644 index 0000000..37bf7e7 --- /dev/null +++ b/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c8ead819ea09dab5ba5f6f61706b36d8 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/LICENSE.meta b/LICENSE.meta new file mode 100644 index 0000000..ec949cb --- /dev/null +++ b/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 821bb3e833eed6a23bb9bf74ddad7a8c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..5a1deab --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 238d2623bfd132eb38548147c45c1e43 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Client.cs b/Runtime/Client.cs new file mode 100644 index 0000000..7ffd359 --- /dev/null +++ b/Runtime/Client.cs @@ -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 { +/// +/// 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 +/// +public class Client : IDisposable { + // Events + public event EventHandler CharacterSpawned; + public event EventHandler DialogueReceived; + public event EventHandler DialogueChunkReceived; + public event EventHandler DialogueStreamStarted; + public event EventHandler DialogueStreamEnded; + public event EventHandler RequestCompleted; + public event EventHandler RequestFailed; + + // Configuration + public string BaseUrl { get; set; } = "https://npc.gumyum.com"; + public string ApiVersion { get; set; } = "v1"; + public TimeSpan Timeout { + get; set; + } = TimeSpan.FromSeconds(75); // Higher than server's 65s timeout + public int MaxRetries { get; set; } = 3; + public bool DebugMode { get; set; } = false; + + // API Credentials + private string _apiKey = ""; + private string _apiSecret = ""; + private string _jwtToken = ""; + private string _refreshToken = ""; + private DateTime _tokenExpiresAt = DateTime.MinValue; + + public string ApiKey { + get => _apiKey; + set { + _apiKey = value; + if (DebugMode) + 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); + } + + /// + /// Check if authenticated + /// + public bool IsReady() => + !string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow < _tokenExpiresAt; + + /// + /// Check if token needs refresh (within 60 seconds of expiry) + /// + private bool NeedsRefresh() => + !string.IsNullOrEmpty(_jwtToken) && DateTime.UtcNow + > _tokenExpiresAt.AddSeconds(-60); + + /// + /// Get full API URL + /// + private string GetApiUrl(string endpoint) => + $"{BaseUrl}/{ApiVersion}/{endpoint.TrimStart('/')}"; + + /// + /// Get request headers + /// + private void SetRequestHeaders(HttpRequestMessage request) { + request.Headers.Clear(); + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.ParseAdd("GumYum-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); + } + } + + /// + /// Make API request + /// + public async Task + RequestAsync(HttpMethod method, string endpoint, object data = null, + Dictionary queryParams = null, + CancellationToken cancellationToken = default) { + // Ensure we're authenticated + if (!string.IsNullOrEmpty(_apiKey) && !string.IsNullOrEmpty(_apiSecret) && + !IsReady()) { + await ExchangeApiKeyForJwtAsync(cancellationToken); + } + + // Check if we need to refresh token + if (NeedsRefresh() && !string.IsNullOrEmpty(_refreshToken)) { + await RefreshJwtTokenAsync(cancellationToken); + } + + return await ExecuteRequestAsync(method, endpoint, data, queryParams, 0, + cancellationToken); + } + + private async Task + ExecuteRequestAsync(HttpMethod method, string endpoint, object data, + Dictionary queryParams, int retryCount, + CancellationToken cancellationToken) { + var url = GetApiUrl(endpoint); + + // Add query parameters + if (queryParams?.Count > 0) { + var queryString = string.Join( + "&", queryParams.Select( + kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); + url += "?" + queryString; + } + + using var request = new HttpRequestMessage(method, url); + SetRequestHeaders(request); + + if (data != null && + (method == HttpMethod.Post || method == HttpMethod.Put)) { + var json = 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(responseContent, _jsonSettings); + RequestCompleted?.Invoke( + this, new RequestCompletedEventArgs { Endpoint = endpoint, + Data = result }); + return result; + } + + // Handle errors + var errorData = JsonConvert.DeserializeObject>( + 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(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(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>( + 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(); + } 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>( + 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>( + 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(); + } 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 queryParams, + Action onChunk, + CancellationToken cancellationToken) { + if (!IsReady()) { + throw new InvalidOperationException("Not authenticated"); + } + + var url = GetApiUrl(endpoint); + + // Add query parameters + if (queryParams?.Count > 0) { + var queryString = string.Join( + "&", queryParams.Select( + kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); + url += "?" + queryString; + } + + using var request = new HttpRequestMessage(method, url); + SetRequestHeaders(request); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue("text/event-stream")); + + if (data != null) { + var json = 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(); + var moodTransition = new Dictionary(); + + while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) { + var line = await reader.ReadLineAsync(); + if (line == null) + break; + + line = line.Trim(); + if (!line.StartsWith("data: ")) + continue; + + var dataStr = line.Substring(6); + if (DebugMode) + 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>( + dataStr, _jsonSettings); + + // Handle different event types + if (eventData.ContainsKey("choices")) { + var choices = + JsonConvert.DeserializeObject>>( + eventData["choices"].ToString(), _jsonSettings); + + if (choices?.Count > 0) { + var choice = choices[0]; + if (choice.ContainsKey("delta")) { + var delta = + JsonConvert.DeserializeObject>( + 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>( + 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>( + eventData["npc_context"].ToString(), _jsonSettings); + } + if (eventData.ContainsKey("mood_transition")) { + moodTransition = + JsonConvert.DeserializeObject>( + 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 Context { get; set; } + public Dictionary MoodTransition { get; set; } +} + +public class DialogueStreamEndedEventArgs : EventArgs { + public string FullResponse { get; set; } + public Dictionary Context { get; set; } + public Dictionary MoodTransition { get; set; } +} + +public class RequestCompletedEventArgs : EventArgs { + public string Endpoint { get; set; } + public object Data { get; set; } +} + +public class RequestFailedEventArgs : EventArgs { + public string Endpoint { get; set; } + public string Error { get; set; } +} +} \ No newline at end of file diff --git a/Runtime/Client.cs.meta b/Runtime/Client.cs.meta new file mode 100644 index 0000000..8fcf0c9 --- /dev/null +++ b/Runtime/Client.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aa8266018932c06b7885a800ff63b7a3 \ No newline at end of file diff --git a/Runtime/GumYumNPC.Runtime.asmdef b/Runtime/GumYumNPC.Runtime.asmdef new file mode 100644 index 0000000..50fbc83 --- /dev/null +++ b/Runtime/GumYumNPC.Runtime.asmdef @@ -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 +} \ No newline at end of file diff --git a/Runtime/GumYumNPC.Runtime.asmdef.meta b/Runtime/GumYumNPC.Runtime.asmdef.meta new file mode 100644 index 0000000..53d5ff3 --- /dev/null +++ b/Runtime/GumYumNPC.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 26a697b167a9ebcc689a6ae9368b98d7 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GumYumUnityClient.cs b/Runtime/GumYumUnityClient.cs new file mode 100644 index 0000000..9df9c2c --- /dev/null +++ b/Runtime/GumYumUnityClient.cs @@ -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 { +/// +/// Unity-specific wrapper for GumYum Client that integrates with Unity's +/// coroutine system This provides a MonoBehaviour-based interface for easier +/// Unity integration +/// +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 OnCharacterSpawned; + public event Action, + Dictionary> OnDialogueReceived; + public event Action OnDialogueChunkReceived; + public event Action OnDialogueStreamStarted; + public event Action, + Dictionary> OnDialogueStreamEnded; + public event Action OnRequestCompleted; + public event Action OnRequestFailed; + + // Singleton pattern (optional) + private static GumYumUnityClient _instance; + public static GumYumUnityClient Instance { + get { + if (_instance == null) { + _instance = FindFirstObjectByType(); + if (_instance == null) { + GameObject go = new GameObject("GumYumUnityClient"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + } + return _instance; + } + } + + /// + /// Access to the underlying client + /// + public Client Client => _client; + + /// + /// Manager accessors for convenience + /// + 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); + } + + /// + /// Update API credentials at runtime + /// + public void SetCredentials(string apiKey, string apiSecret) { + this.apiKey = apiKey; + this.apiSecret = apiSecret; + + if (_client != null) { + _client.ApiKey = apiKey; + _client.ApiSecret = apiSecret; + } + } + + /// + /// Coroutine-based NPC spawning + /// + public IEnumerator SpawnNPCCoroutine(string universeId, int seed, long? npcId, + Action 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); + } + } + + /// + /// Coroutine-based filtered NPC spawning + /// + public IEnumerator SpawnFilteredNPCCoroutine(string universeId, int seed, + object filters, + Action 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); + } + } + + /// + /// Coroutine-based chat completion + /// + public IEnumerator ChatCoroutine(NPC npc, List messages, + Action 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); + } + } + + /// + /// Simple chat helper that creates the message structure + /// + public IEnumerator SimpleChatCoroutine(NPC npc, string userMessage, + Action callback, + double temperature = 0.8, + int maxTokens = 2000) { + var messages = + new List { 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); + } + + /// + /// List public universes coroutine + /// + public IEnumerator ListPublicUniversesCoroutine( + Action>> 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); + } + } + + /// + /// List user's universes coroutine + /// + public IEnumerator + ListUniversesCoroutine(Action>> 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); + } + } + + /// + /// Helper to convert async operations to coroutines + /// + public static IEnumerator ToCoroutine(Task task) { + while (!task.IsCompleted) { + yield return null; + } + + if (task.IsFaulted) { + throw task.Exception.GetBaseException(); + } + } + + /// + /// Helper to convert async operations to coroutines with result + /// + public static IEnumerator ToCoroutine(Task task, + Action 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(); } +} + +/// +/// Extension methods for easier Unity integration +/// +public static class GumYumUnityExtensions { + /// + /// Convert Task to Coroutine + /// + public static IEnumerator AsCoroutine(this Task task) { + return GumYumUnityClient.ToCoroutine(task); + } + + /// + /// Convert Task to Coroutine + /// + public static IEnumerator AsCoroutine(this Task task, + Action resultCallback) { + return GumYumUnityClient.ToCoroutine(task, resultCallback); + } +} +} +#endif \ No newline at end of file diff --git a/Runtime/GumYumUnityClient.cs.meta b/Runtime/GumYumUnityClient.cs.meta new file mode 100644 index 0000000..8f54f81 --- /dev/null +++ b/Runtime/GumYumUnityClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ac7e37da835b6259384d76e3c893d1e0 \ No newline at end of file diff --git a/Runtime/Managers.cs b/Runtime/Managers.cs new file mode 100644 index 0000000..97ecf10 --- /dev/null +++ b/Runtime/Managers.cs @@ -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 { +/// +/// Manager for NPC-related operations +/// +public class NPCManager { + private readonly Client _client; + + public NPCManager(Client client) { _client = client; } + + /// + /// Spawn a character - returns NPC object + /// Pass null for npcId to spawn a random NPC + /// + public async Task + SpawnAsync(string universeId, int seed, long? npcId = null, + CancellationToken cancellationToken = default) { + var queryParams = + new Dictionary { ["universe_id"] = universeId, + ["seed"] = seed.ToString() }; + + string endpoint; + if (npcId.HasValue) { + // Spawn specific NPC + queryParams["npc_id"] = npcId.Value.ToString(); + endpoint = "npc"; + } else { + // Spawn random NPC - let API handle the randomness + endpoint = "npc/spawn"; + } + + var response = await _client.RequestAsync>( + HttpMethod.Get, endpoint, null, queryParams, cancellationToken); + + // Check if this is a redirect response + if (response.ContainsKey("redirect_url")) { + // Follow the redirect to get actual NPC data + var redirectPath = response["redirect_url"].ToString(); + // Strip the /v1/ prefix since client adds it + if (redirectPath.StartsWith("/v1/")) { + redirectPath = redirectPath.Substring(4); + } + + // Make the redirect request + var npcData = await _client.RequestAsync>( + HttpMethod.Get, redirectPath, null, null, cancellationToken); + + var npc = new NPC(npcData, _client); + _client.RaiseCharacterSpawned(npc); + return npc; + } else { + // Direct response (shouldn't happen with /npc/spawn) + var npc = new NPC(response, _client); + _client.RaiseCharacterSpawned(npc); + return npc; + } + } + + /// + /// Spawn with advanced filters - supports any NPC field + /// Examples: + /// SpawnFilteredAsync("kingdom", 12345, new { profession = new[] { + /// "warrior", "knight" }, location = new[] { "barracks", "castle" } }) + /// SpawnFilteredAsync("blade-runner", 67890, new { personality_type = new[] + /// { 8 }, sex = new[] { "female" } }) + /// + public async Task + SpawnFilteredAsync(string universeId, int seed, object filters = null, + CancellationToken cancellationToken = default) { + var data = new { universe_id = universeId, world_seed = seed, + filters = filters ?? new {} }; + + var endpoint = "npc/spawn_filtered"; + + // spawn_filtered uses POST with data in body + var response = await _client.RequestAsync>( + HttpMethod.Post, endpoint, data, null, cancellationToken); + + // Check if this is a redirect response + if (response.ContainsKey("redirect_url")) { + // Follow the redirect to get actual NPC data + var redirectPath = response["redirect_url"].ToString(); + // Strip the /v1/ prefix since client adds it + if (redirectPath.StartsWith("/v1/")) { + redirectPath = redirectPath.Substring(4); + } + + // Make the redirect request + var npcData = await _client.RequestAsync>( + HttpMethod.Get, redirectPath, null, null, cancellationToken); + + var npc = new NPC(npcData, _client); + _client.RaiseCharacterSpawned(npc); + return npc; + } else { + // Direct response (shouldn't happen with /npc/spawn_filtered) + var npc = new NPC(response, _client); + _client.RaiseCharacterSpawned(npc); + return npc; + } + } +} + +/// +/// Manager for Universe-related operations +/// +public class UniverseManager { + private readonly Client _client; + + public UniverseManager(Client client) { _client = client; } + + /// + /// List public universes (works with API keys) + /// + public async Task>> + ListPublicAsync(CancellationToken cancellationToken = default) { + // API returns {"public_universes": [...]} + var response = await _client.RequestAsync>( + HttpMethod.Get, "public/universes", null, null, cancellationToken); + + if (response != null && + response.TryGetValue("public_universes", out var universesObj)) { + // Handle Newtonsoft.Json types + if (universesObj is Newtonsoft.Json.Linq.JArray jArray) { + return jArray.ToObject>>(); + } else if (universesObj is List> list) { + return list; + } + } + + return new List>(); + } + + /// + /// List user's universes (works with JWT from API key exchange) + /// + public async Task>> + ListAsync(CancellationToken cancellationToken = default) { + // API returns {"universes": [...]} + var response = await _client.RequestAsync>( + HttpMethod.Get, "universes", null, null, cancellationToken); + + if (response != null && + response.TryGetValue("universes", out var universesObj)) { + // Handle Newtonsoft.Json types + if (universesObj is Newtonsoft.Json.Linq.JArray jArray) { + return jArray.ToObject>>(); + } else if (universesObj is List> list) { + return list; + } + } + + return new List>(); + } + + /// + /// Copy a universe (requires user authentication) + /// + public Task> + CopyAsync(string publicUniverseId, string customName = "", + CancellationToken cancellationToken = default) { + throw new NotSupportedException( + "[GumYum] Copy() requires user authentication. Use API keys with a " + + "known universe ID instead."); + } + + /// + /// Get universe details + /// + public async Task> + GetUniverseAsync(string universeId, + CancellationToken cancellationToken = default) { + return await _client.RequestAsync>( + HttpMethod.Get, $"universes/{universeId}", null, null, + cancellationToken); + } +} + +/// +/// Manager for Chat-related operations +/// +public class ChatManager { + private readonly Client _client; + + public ChatManager(Client client) { _client = client; } + + /// + /// Generate dialogue (OpenAI-compatible chat.completions) + /// + public async Task CompletionsAsync( + List messages, Dictionary npcParams, + double temperature = 0.8, int maxTokens = 2000, bool stream = false, + CancellationToken cancellationToken = default) { + var data = new Dictionary { ["model"] = "gumyum-npc", + ["temperature"] = temperature, + ["messages"] = messages, + ["stream"] = stream, + ["npc_params"] = npcParams, + ["max_tokens"] = maxTokens }; + + // Extract npc_id to top level if it exists in npc_params + if (npcParams.ContainsKey("npc_id")) { + data["npc_id"] = npcParams["npc_id"]; + } + + if (stream) { + throw new NotImplementedException( + "Streaming is handled via CompletionsStreamAsync"); + } else { + var response = await _client.RequestAsync>( + HttpMethod.Post, "chat/completions", data, null, cancellationToken); + + var completionResponse = new ChatCompletionResponse(response); + + if (completionResponse.Choices?.Count > 0) { + var firstChoice = completionResponse.Choices[0]; + if (firstChoice?.Message == null) { + return completionResponse; + } + var content = firstChoice.Message.Content; + var context = completionResponse.NpcContext; + var moodTransition = completionResponse.MoodTransition; + + _client.RaiseDialogueReceived(new DialogueReceivedEventArgs { + Response = content, Context = context, MoodTransition = moodTransition + }); + } + + return completionResponse; + } + } + + /// + /// Generate dialogue with streaming support + /// + public async Task CompletionsStreamAsync( + List messages, Dictionary npcParams, + Action onChunk, double temperature = 0.8, int maxTokens = 2000, + CancellationToken cancellationToken = default) { + var data = new Dictionary { ["model"] = "gumyum-npc", + ["temperature"] = temperature, + ["messages"] = messages, + ["stream"] = true, + ["npc_params"] = npcParams, + ["max_tokens"] = maxTokens }; + + // Extract npc_id to top level if it exists in npc_params + if (npcParams.ContainsKey("npc_id")) { + data["npc_id"] = npcParams["npc_id"]; + } + + await _client.RequestStreamAsync(HttpMethod.Post, "chat/completions", data, + null, onChunk, cancellationToken); + } + + /// + /// Generate dialogue (legacy method name for backwards compatibility) + /// + public Task + ChatAsync(List messages, Dictionary npcParams, + double temperature = 0.8, int maxTokens = 2000, + CancellationToken cancellationToken = default) { + return CompletionsAsync(messages, npcParams, temperature, maxTokens, false, + cancellationToken); + } + + /// + /// Generate dialogue with streaming support (legacy method) + /// + public Task ChatStreamAsync(List messages, + Dictionary npcParams, + Action onChunk, double temperature = 0.8, + int maxTokens = 2000, + CancellationToken cancellationToken = default) { + return CompletionsStreamAsync(messages, npcParams, onChunk, temperature, + maxTokens, cancellationToken); + } +} + +/// +/// Chat message structure +/// +public class ChatMessage { + public string Role { get; set; } + public string Content { get; set; } +} + +/// +/// Chat completion response +/// +public class ChatCompletionResponse { + public List Choices { get; set; } + public Dictionary NpcContext { get; set; } + public Dictionary MoodTransition { get; set; } + + public ChatCompletionResponse(Dictionary data) { + if (data == null) { + Debug.LogError( + "[ChatCompletionResponse] Constructor received null data!"); + Choices = new List(); + return; + } + + if (data.ContainsKey("choices")) { + var choicesData = data["choices"] as List; + if (choicesData == null && data["choices"] != null) { + // Try to handle different array types + if (data["choices"] is Newtonsoft.Json.Linq.JArray jArray) { + choicesData = jArray.ToObject>(); + } + } + Choices = new List(); + if (choicesData != null) { + foreach (var choice in choicesData) { + Dictionary choiceDict = null; + if (choice is Dictionary dict) { + choiceDict = dict; + } else if (choice is Newtonsoft.Json.Linq.JObject jObj) { + choiceDict = jObj.ToObject>(); + } + + if (choiceDict != null) { + Choices.Add(new ChatChoice(choiceDict)); + } + // Silently skip unparseable choices + } + } + } else { + Choices = new List(); + } + + if (data.ContainsKey("npc_context")) { + NpcContext = data["npc_context"] as Dictionary; + } + + if (data.ContainsKey("mood_transition")) { + MoodTransition = data["mood_transition"] as Dictionary; + } + } +} + +/// +/// Chat choice in completion response +/// +public class ChatChoice { + public ChatMessage Message { get; set; } + + public ChatChoice(Dictionary data) { + if (data?.ContainsKey("message") == true) { + var msgData = data["message"] as Dictionary; + if (msgData == null && + data["message"] is Newtonsoft.Json.Linq.JObject jObj) { + msgData = jObj.ToObject>(); + } + + if (msgData != null) { + Message = new ChatMessage { + Role = msgData.GetValueOrDefault("role")?.ToString() ?? "assistant", + Content = msgData.GetValueOrDefault("content")?.ToString() ?? "" + }; + } + // Silently handle missing message data + } + } +} +} \ No newline at end of file diff --git a/Runtime/Managers.cs.meta b/Runtime/Managers.cs.meta new file mode 100644 index 0000000..9f8892b --- /dev/null +++ b/Runtime/Managers.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 232c26c33ff3d4c95b32e8c9178b8bb7 \ No newline at end of file diff --git a/Runtime/NPC.cs b/Runtime/NPC.cs new file mode 100644 index 0000000..2a0572e --- /dev/null +++ b/Runtime/NPC.cs @@ -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 { +/// +/// 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}"); +/// +[System.Serializable] +public class NPC { + // Events + public event EventHandler DialogueReceived; + public event EventHandler DialogueChunkReceived; + public event EventHandler DialogueStreamStarted; + public event EventHandler DialogueStreamEnded; + + // NPC Data (from API response) - 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 Spawned { get; set; } = + new Dictionary(); + + // Current mood state + public string CurrentMood { get; private set; } = ""; + public string PreviousMood { get; private set; } = ""; + public int StressLevel { get; private set; } = 5; + public string LastMoodChangeReason { get; private set; } = ""; + public double LastMoodChangeConfidence { get; private set; } = 0.0; + + // Client reference (not serialized) + [System.NonSerialized] + private Client _client; + + // Chat history for this NPC (array of message dicts with role and content) + public List ChatHistory { get; } = new List(); + + // Internal chat manager for this NPC + public NPCChatManager Chat { get; private set; } + + public NPC(Dictionary npcData, Client client) { + _client = client; + SetupFromData(npcData); + Chat = new NPCChatManager(this); + ConnectClientSignals(); + } + + /// + /// Setup NPC from API response data + /// + private void SetupFromData(Dictionary npcData) { + // Check if data is nested in "spawned_npc" first + if (npcData.ContainsKey("spawned_npc")) { + if (npcData["spawned_npc"] is Dictionary spawnedData) { + npcData = spawnedData; + } else if (npcData["spawned_npc"] is Newtonsoft.Json.Linq.JObject jObj) { + npcData = jObj.ToObject>(); + } + } + + // 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(); + } 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(); + } 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(); + } else { + seed = Convert.ToInt32(seedValue); + } + } else if (npcData.TryGetValue("seed", out seedValue)) { + if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) { + seed = seedJValue.ToObject(); + } else { + seed = Convert.ToInt32(seedValue); + } + } + + if (npcData.TryGetValue("cached", out var cachedValue)) { + if (cachedValue is Newtonsoft.Json.Linq.JValue cachedJValue) { + cached = cachedJValue.ToObject(); + } 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 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(); + } else { + StressLevel = Convert.ToInt32(stressLevel); + } + } + } + } + + /// + /// Convenience method - direct completions alias (like Python + /// npc.completions()) + /// + public Task + CompletionsAsync(List messages, double temperature = 0.8, + int maxTokens = 2000, bool stream = false, + CancellationToken cancellationToken = default) { + return Chat.CompletionsAsync(messages, temperature, maxTokens, stream, + cancellationToken); + } + + /// + /// Chat with conversation history - includes all previous messages in this + /// session + /// + public Task + ChatWithHistoryAsync(string message, double temperature = 0.8, + int maxTokens = 2000, bool stream = false, + CancellationToken cancellationToken = default) { + return Chat.ChatWithHistoryAsync(message, temperature, maxTokens, stream, + cancellationToken); + } + + /// + /// Clear conversation history + /// + public void ClearHistory() { ChatHistory.Clear(); } + + /// + /// Convert chat history to JSON string for saving + /// + public string ChatHistoryToJson() { + return JsonConvert.SerializeObject(ChatHistory); + } + + /// + /// Convert entire NPC data to dictionary for saving + /// + public Dictionary ToDict() { + return new Dictionary { ["npc_id"] = npcId, + ["name"] = name, + ["profession"] = profession, + ["personality_type"] = personalityType, + ["universe_id"] = universeId, + ["seed"] = seed, + ["spawned"] = Spawned, + ["cached"] = cached, + ["cache_url"] = cacheUrl, + ["chat_history"] = ChatHistory }; + } + + /// + /// Convert NPC data to JSON string for saving + /// + public string ToJson() { return JsonConvert.SerializeObject(ToDict()); } + + /// + /// Load NPC data from dictionary (e.g., from saved file) + /// + public static NPC FromDict(Dictionary data, Client client) { + return new NPC(data, client); + } + + /// + /// Load NPC data from JSON string + /// + public static NPC FromJson(string jsonStr, Client client) { + var data = + JsonConvert.DeserializeObject>(jsonStr); + return FromDict(data, client); + } + + /// + /// Get NPC's current location + /// + public string GetLocation() { + return Spawned.TryGetValue("location", out var location) + ? location.ToString() + : "unknown"; + } + + /// + /// Get NPC's current mood + /// + public string GetMood() { + if (!string.IsNullOrEmpty(CurrentMood)) + return CurrentMood; + return Spawned.TryGetValue("mood", out var mood) ? mood.ToString() + : "neutral"; + } + + /// + /// Get NPC's stress level + /// + public int GetStressLevel() { return StressLevel; } + + /// + /// Get previous mood (before last change) + /// + public string GetPreviousMood() { return PreviousMood; } + + /// + /// Get last mood change reason + /// + public string GetLastMoodChangeReason() { return LastMoodChangeReason; } + + /// + /// Get last mood change confidence + /// + public double GetLastMoodChangeConfidence() { + return LastMoodChangeConfidence; + } + + /// + /// Check if mood has changed + /// + public bool HasMoodChanged() { + return !string.IsNullOrEmpty(PreviousMood) && PreviousMood != CurrentMood; + } + + /// + /// Get detailed info string + /// + public string GetInfo() { + return $"{name} the {profession} (Type {personalityType}) at {GetLocation()}, feeling {GetMood()}"; + } + + /// + /// Get Enneagram type description + /// + public string GetEnneagramDescription() { + return personalityType switch { + 1 => "The Reformer", + 2 => "The Helper", + 3 => "The Achiever", + 4 => "The Individualist", + 5 => "The Investigator", + 6 => "The Loyalist", + 7 => "The Enthusiast", + 8 => "The Challenger", + 9 => "The Peacemaker", + _ => "Unknown Type" + }; + } + + /// + /// Convert NPC to dictionary for API params + /// + public Dictionary ToDictionary() { + return new Dictionary { + ["npc_id"] = npcId, + ["universe_id"] = universeId, + ["seed"] = seed + }; + } + + /// + /// Save this NPC to the server (persistent storage) + /// + public async + Task SaveToServerAsync(string customName = "", + CancellationToken cancellationToken = default) { + var data = new Dictionary { ["universe_id"] = universeId, + ["world_seed"] = seed, + ["npc_id"] = npcId }; + + if (!string.IsNullOrEmpty(customName)) + data["custom_name"] = customName; + + await _client.RequestAsync>( + System.Net.Http.HttpMethod.Post, "npc/save", data, null, + cancellationToken); + } + + /// + /// Connect to client's dialogue signals to forward them + /// + private void ConnectClientSignals() { + if (_client != null) { + _client.DialogueReceived += OnClientDialogueReceived; + _client.DialogueChunkReceived += OnClientDialogueChunkReceived; + _client.DialogueStreamStarted += OnClientDialogueStreamStarted; + _client.DialogueStreamEnded += OnClientDialogueStreamEnded; + } + } + + private void OnClientDialogueReceived(object sender, + DialogueReceivedEventArgs e) { + // Only emit if this response is for our NPC + if (e.Context?.TryGetValue("npc_id", out var npcIdValue) == true) { + long npcIdLong = 0; + if (npcIdValue is Newtonsoft.Json.Linq.JValue jValue) { + npcIdLong = jValue.ToObject(); + } 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(); + } else { + npcIdLong = Convert.ToInt64(npcIdValue); + } + if (npcIdLong == npcId) { + // Update mood data if transition occurred + UpdateMoodFromTransition(e.MoodTransition); + DialogueStreamEnded?.Invoke(this, e); + } + } + } + + /// + /// Update internal mood state from mood transition data + /// + private void + UpdateMoodFromTransition(Dictionary moodTransition) { + if (moodTransition == null || moodTransition.Count == 0) + return; + + // Store previous mood if we're changing + if (moodTransition.TryGetValue("new_mood", out var newMood) && + newMood.ToString() != CurrentMood) { + PreviousMood = CurrentMood; + CurrentMood = newMood.ToString(); + } + + // Update stress level + if (moodTransition.TryGetValue("stress_level", out var stressLevel)) { + if (stressLevel is Newtonsoft.Json.Linq.JValue slJValue) { + StressLevel = slJValue.ToObject(); + } 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(); + } else { + LastMoodChangeConfidence = Convert.ToDouble(confidence); + } + } + if (moodTransition.TryGetValue("reasoning", out var reasoning)) { + LastMoodChangeReason = reasoning.ToString(); + } + } + + /// + /// Chat manager class - provides the .Chat interface + /// + public class NPCChatManager { + private readonly NPC _npc; + + public NPCChatManager(NPC npc) { _npc = npc; } + + /// + /// Main chat completion method - matches Python API + /// + public async Task + CompletionsAsync(List messages, double temperature = 0.8, + int maxTokens = 2000, bool stream = false, + CancellationToken cancellationToken = default) { + // Build npc_params with this NPC's context including current mood + var npcParams = + new Dictionary { ["universe_id"] = _npc.universeId, + ["world_seed"] = _npc.seed, + ["npc_id"] = _npc.npcId, + ["current_mood"] = _npc.GetMood(), + ["stress_level"] = + _npc.GetStressLevel() }; + + ChatCompletionResponse response; + + if (stream) { + // For streaming, we need to handle it differently + var tcs = new TaskCompletionSource(); + string accumulatedContent = ""; + Dictionary lastContext = null; + Dictionary lastMoodTransition = null; + + await _npc._client.Chat.CompletionsStreamAsync( + messages, npcParams, chunk => { + if (chunk == null) // Stream ended + { + // Create a response object with the accumulated content + var streamResponse = + new ChatCompletionResponse(new Dictionary { + ["choices"] = + new List { new Dictionary { + ["message"] = new Dictionary< + string, object> { ["role"] = "assistant", + ["content"] = + accumulatedContent } + } }, + ["npc_context"] = lastContext, + ["mood_transition"] = lastMoodTransition + }); + tcs.SetResult(streamResponse); + } else { + accumulatedContent += chunk; + } + }, temperature, maxTokens, cancellationToken); + + response = await tcs.Task; + } else { + response = await _npc._client.Chat.CompletionsAsync( + messages, npcParams, temperature, maxTokens, false, + cancellationToken); + } + + // Update chat history + if (response?.Choices?.Count > 0) { + // Add user message(s) to history + if (messages.Count > 0) { + var lastUserMsg = messages[messages.Count - 1]; + if (lastUserMsg.Role == "user") { + _npc.ChatHistory.Add(lastUserMsg); + } + } + + // Add assistant response to history + var choice = response.Choices[0]; + if (choice.Message != null) { + _npc.ChatHistory.Add( + new ChatMessage { Role = choice.Message.Role, + Content = choice.Message.Content }); + } + } + + return response; + } + + /// + /// Chat with conversation history - includes all previous messages in this + /// session + /// + public async Task + ChatWithHistoryAsync(string message, double temperature = 0.8, + int maxTokens = 2000, bool stream = false, + CancellationToken cancellationToken = default) { + // Build messages array with history plus new message + var messagesWithHistory = new List(_npc.ChatHistory); + messagesWithHistory.Add( + new ChatMessage { Role = "user", Content = message }); + + // Use regular completions which will also update history + return await CompletionsAsync(messagesWithHistory, temperature, maxTokens, + stream, cancellationToken); + } + } +} +} \ No newline at end of file diff --git a/Runtime/NPC.cs.meta b/Runtime/NPC.cs.meta new file mode 100644 index 0000000..d29d3b5 --- /dev/null +++ b/Runtime/NPC.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 66691e15e0986a4a4a18cd94e53c917f \ No newline at end of file diff --git a/Samples~/BasicChat/NPCFilteredSpawning.cs b/Samples~/BasicChat/NPCFilteredSpawning.cs new file mode 100644 index 0000000..4dfc748 --- /dev/null +++ b/Samples~/BasicChat/NPCFilteredSpawning.cs @@ -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 { +/// +/// Example showing filtered NPC spawning and multiple NPCs +/// +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 spawnedNPCs = new List(); + + 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(); + + 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 { 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 { + 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 { 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 GetNPCsByMood(string mood) { + return spawnedNPCs.FindAll(npc => npc.GetMood() == mood); + } + + // Example: Get stressed NPCs (stress > 7) + public List 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 \ No newline at end of file diff --git a/Samples~/BasicChat/QuickstartChat.cs b/Samples~/BasicChat/QuickstartChat.cs new file mode 100644 index 0000000..d433195 --- /dev/null +++ b/Samples~/BasicChat/QuickstartChat.cs @@ -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 { +/// +/// Simple example showing NPC spawning and dialogue +/// Attach this to a GameObject in your scene +/// +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 context, + Dictionary 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 { 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 \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..776364b --- /dev/null +++ b/package.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..8df82a3 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e096617b865976428ea587ebffe6c77 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: