diff --git a/Distribution/Data/ai-personas.json b/Distribution/Data/ai-personas.json new file mode 100644 index 000000000..5ad3a6a55 --- /dev/null +++ b/Distribution/Data/ai-personas.json @@ -0,0 +1,26 @@ +[ + /* + * AI conversation persona templates. + * + * Each entry gives NPCs an identity for AI-backed conversations + * (see Projects/UOContent/Engines/AIConversation/README.md). + * + * Match by NPC class ("type") — applies to every NPC of that class and + * its subclasses — or by exact NPC name ("name"). Name matches win over + * type matches, and per-NPC personas set with [SetPersona win over both. + * + * Reload in game with [AIChat reload. + */ + { + "type": "Banker", + "persona": "A meticulous, slightly smug banker who guards the town's coin. You speak in clipped, businesslike sentences, complain fondly about paperwork and counterfeit gold, and believe there is no problem a sound ledger cannot solve. You take pride in the vault's security and gently remind adventurers to mind their spending." + }, + { + "type": "AnimalTrainer", + "persona": "A warm, weather-beaten animal trainer who has raised beasts since childhood. You speak plainly and kindly, often comparing people to animals you have known. You can chat about the temperament of horses, dogs, and stranger creatures, and you disapprove of anyone who mistreats their pets." + }, + { + "type": "TavernKeeper", + "persona": "A boisterous tavern keeper who has heard every tale in Britannia twice. You love gossip, exaggerate cheerfully, and always steer the talk back to food, drink, and rumors passing through your common room. You are friendly to strangers but never share a secret for free." + } +] diff --git a/Projects/UOContent.Tests/Tests/Engines/AIConversation/AnthropicClientTests.cs b/Projects/UOContent.Tests/Tests/Engines/AIConversation/AnthropicClientTests.cs new file mode 100644 index 000000000..8dde83a10 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AIConversation/AnthropicClientTests.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using Server.Engines.AIConversation; +using Xunit; + +namespace Server.Tests.Engines.AIConversation; + +public class AnthropicClientTests +{ + [Fact] + public void ParseResponse_ExtractsTextStopReasonAndUsage() + { + const string json = + """ + { + "id": "msg_01", + "type": "message", + "role": "assistant", + "content": [{ "type": "text", "text": "Well met, traveler." }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 512, "output_tokens": 42 } + } + """; + + var result = AnthropicClient.ParseResponse(json); + + Assert.True(result.Success); + Assert.Equal("Well met, traveler.", result.Text); + Assert.Equal("end_turn", result.StopReason); + Assert.Equal(512, result.InputTokens); + Assert.Equal(42, result.OutputTokens); + } + + [Fact] + public void ParseResponse_ConcatenatesMultipleTextBlocks() + { + const string json = + """ + { + "content": [ + { "type": "text", "text": "First. " }, + { "type": "thinking", "thinking": "ignored" }, + { "type": "text", "text": "Second." } + ], + "stop_reason": "end_turn", + "usage": { "input_tokens": 1, "output_tokens": 2 } + } + """; + + var result = AnthropicClient.ParseResponse(json); + + Assert.True(result.Success); + Assert.Equal("First. Second.", result.Text); + } + + [Fact] + public void ParseResponse_ReturnsApiErrorDetails() + { + const string json = + """ + { + "type": "error", + "error": { "type": "overloaded_error", "message": "Overloaded" } + } + """; + + var result = AnthropicClient.ParseResponse(json); + + Assert.False(result.Success); + Assert.Equal("overloaded_error: Overloaded", result.Error); + } + + [Fact] + public void ParseResponse_EmptyContentIsFailure() + { + const string json = + """ + { "content": [], "stop_reason": "max_tokens", "usage": { "input_tokens": 5, "output_tokens": 0 } } + """; + + var result = AnthropicClient.ParseResponse(json); + + Assert.False(result.Success); + Assert.Contains("max_tokens", result.Error); + } + + [Fact] + public void ParseResponse_MalformedJsonIsFailure() + { + var result = AnthropicClient.ParseResponse("not json at all"); + + Assert.False(result.Success); + Assert.Equal("Unexpected response shape", result.Error); + } + + [Fact] + public void BuildPayload_ProducesMessagesApiShape() + { + var request = new AnthropicRequest + { + Model = "claude-haiku-4-5", + MaxTokens = 200, + SystemPrompt = "You are a banker.", + Messages = new[] + { + new ChatTurn(ChatRole.User, "hello"), + new ChatTurn(ChatRole.Assistant, "well met"), + new ChatTurn(ChatRole.User, "who are you?") + } + }; + + using var doc = JsonDocument.Parse(AnthropicClient.BuildPayload(request)); + var root = doc.RootElement; + + Assert.Equal("claude-haiku-4-5", root.GetProperty("model").GetString()); + Assert.Equal(200, root.GetProperty("max_tokens").GetInt32()); + Assert.Equal("You are a banker.", root.GetProperty("system").GetString()); + + var messages = root.GetProperty("messages"); + Assert.Equal(3, messages.GetArrayLength()); + Assert.Equal("user", messages[0].GetProperty("role").GetString()); + Assert.Equal("hello", messages[0].GetProperty("content").GetString()); + Assert.Equal("assistant", messages[1].GetProperty("role").GetString()); + Assert.Equal("user", messages[2].GetProperty("role").GetString()); + } + + [Fact] + public void BuildPayload_OmitsEmptySystemPrompt() + { + var request = new AnthropicRequest + { + Model = "claude-haiku-4-5", + MaxTokens = 200, + Messages = new[] { new ChatTurn(ChatRole.User, "hello") } + }; + + using var doc = JsonDocument.Parse(AnthropicClient.BuildPayload(request)); + + Assert.False(doc.RootElement.TryGetProperty("system", out _)); + } + + [Fact] + public void BuildPayload_EscapesSpecialCharacters() + { + var request = new AnthropicRequest + { + Model = "m", + MaxTokens = 1, + Messages = new[] { new ChatTurn(ChatRole.User, "he said \"hi\"\nand left") } + }; + + using var doc = JsonDocument.Parse(AnthropicClient.BuildPayload(request)); + + Assert.Equal( + "he said \"hi\"\nand left", + doc.RootElement.GetProperty("messages")[0].GetProperty("content").GetString() + ); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AIConversation/ConversationHistoryTests.cs b/Projects/UOContent.Tests/Tests/Engines/AIConversation/ConversationHistoryTests.cs new file mode 100644 index 000000000..b856b2443 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AIConversation/ConversationHistoryTests.cs @@ -0,0 +1,65 @@ +using Server.Engines.AIConversation; +using Xunit; + +namespace Server.Tests.Engines.AIConversation; + +public class ConversationHistoryTests +{ + [Fact] + public void Add_KeepsTurnsInOrder() + { + var history = new ConversationHistory(20); + + history.Add(ChatRole.User, "hello"); + history.Add(ChatRole.Assistant, "well met"); + + Assert.Equal(2, history.Count); + Assert.Equal(new ChatTurn(ChatRole.User, "hello"), history.Turns[0]); + Assert.Equal(new ChatTurn(ChatRole.Assistant, "well met"), history.Turns[1]); + } + + [Fact] + public void Add_DropsOldestTurnsBeyondLimit() + { + var history = new ConversationHistory(4); + + for (var i = 0; i < 10; i++) + { + history.Add(ChatRole.User, $"question {i}"); + history.Add(ChatRole.Assistant, $"answer {i}"); + } + + Assert.Equal(4, history.Count); + Assert.Equal("question 8", history.Turns[0].Text); + Assert.Equal("answer 9", history.Turns[^1].Text); + } + + [Fact] + public void Trim_EnsuresFirstTurnIsUser() + { + var history = new ConversationHistory(3); + + history.Add(ChatRole.User, "q1"); + history.Add(ChatRole.Assistant, "a1"); + history.Add(ChatRole.User, "q2"); + history.Add(ChatRole.Assistant, "a2"); + + // Capacity 3 would leave [a1, q2, a2]; the head realigns to q2. + Assert.Equal(2, history.Count); + Assert.Equal(ChatRole.User, history.Turns[0].Role); + Assert.Equal("q2", history.Turns[0].Text); + } + + [Fact] + public void ToArray_ReturnsIndependentSnapshot() + { + var history = new ConversationHistory(10); + history.Add(ChatRole.User, "hello"); + + var snapshot = history.ToArray(); + history.Add(ChatRole.Assistant, "well met"); + + Assert.Single(snapshot); + Assert.Equal(2, history.Count); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AIConversation/ConversationTextTests.cs b/Projects/UOContent.Tests/Tests/Engines/AIConversation/ConversationTextTests.cs new file mode 100644 index 000000000..63991a621 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AIConversation/ConversationTextTests.cs @@ -0,0 +1,144 @@ +using System.Linq; +using Server.Engines.AIConversation; +using Xunit; + +namespace Server.Tests.Engines.AIConversation; + +public class ConversationTextTests +{ + [Theory] + [InlineData("hello", true)] + [InlineData("Hello there, friend", true)] + [InlineData("HAIL", true)] + [InlineData("well met, traveler", true)] + [InlineData("good day to you", true)] + [InlineData("greetings", true)] + [InlineData("hive of bees", false)] // "hi" must be a whole word + [InlineData("heyday", false)] + [InlineData("where is the bank", false)] + [InlineData("", false)] + public void IsGreeting_DetectsGreetingPhrases(string said, bool expected) + { + Assert.Equal(expected, ConversationText.IsGreeting(said)); + } + + [Theory] + [InlineData("bye", true)] + [InlineData("Goodbye!", true)] + [InlineData("farewell, sage", true)] + [InlineData("good bye", true)] + [InlineData("byline", false)] + [InlineData("later!", true)] + [InlineData("goodness me", false)] + public void IsFarewell_DetectsFarewellPhrases(string said, bool expected) + { + Assert.Equal(expected, ConversationText.IsFarewell(said)); + } + + [Theory] + [InlineData("hail sage elric!", "Sage Elric", true)] + [InlineData("ELRIC, a word please", "Sage Elric", true)] + [InlineData("what do you think, elric?", "Sage Elric", true)] + [InlineData("that belongs to elrics cousin", "Sage Elric", false)] // not a whole word + [InlineData("melric is here", "Sage Elric", false)] + [InlineData("hello there", "Sage Elric", false)] + [InlineData("talk to al", "Al Zim", false)] // parts under 3 chars never match + [InlineData("", "Sage Elric", false)] + [InlineData("hail elric", null, false)] + public void MentionsName_MatchesWholeWordsOnly(string said, string name, bool expected) + { + Assert.Equal(expected, ConversationText.MentionsName(said, name)); + } + + [Fact] + public void Sanitize_StripsControlCharactersAndCollapsesWhitespace() + { + var raw = "Well met,\n\ntraveler!\tI have\r\n many tales."; + + Assert.Equal("Well met, traveler! I have many tales.", ConversationText.Sanitize(raw, 600)); + } + + [Fact] + public void Sanitize_StripsWrappingQuotes() + { + Assert.Equal("Aye, that I can do.", ConversationText.Sanitize("\"Aye, that I can do.\"", 600)); + } + + [Fact] + public void Sanitize_KeepsInteriorQuotes() + { + Assert.Equal("He said \"nay\" to me.", ConversationText.Sanitize("He said \"nay\" to me.", 600)); + } + + [Fact] + public void Sanitize_TruncatesAtSentenceBoundary() + { + var text = "First sentence here. Second sentence is longer and will not fit at all."; + var result = ConversationText.Sanitize(text, 30); + + Assert.Equal("First sentence here.", result); + } + + [Fact] + public void Sanitize_HardTruncatesWhenNoSentenceBoundaryExists() + { + var text = new string('a', 700); + var result = ConversationText.Sanitize(text, 600); + + Assert.Equal(600, result.Length); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("\n\t \r")] + public void Sanitize_EmptyInputYieldsEmptyString(string text) + { + Assert.Equal("", ConversationText.Sanitize(text, 600)); + } + + [Fact] + public void SplitIntoChunks_ShortTextIsSingleChunk() + { + var chunks = ConversationText.SplitIntoChunks("A short reply.", 120); + + Assert.Single(chunks); + Assert.Equal("A short reply.", chunks[0]); + } + + [Fact] + public void SplitIntoChunks_PrefersSentenceBoundaries() + { + var chunks = ConversationText.SplitIntoChunks( + "The vault is quite secure. None shall breach it while I live.", + 60 + ); + + Assert.Equal(2, chunks.Count); + Assert.Equal("The vault is quite secure.", chunks[0]); + Assert.Equal("None shall breach it while I live.", chunks[1]); + Assert.All(chunks, c => Assert.True(c.Length <= 60)); + } + + [Fact] + public void SplitIntoChunks_FallsBackToWordBreaks() + { + var text = string.Join(' ', Enumerable.Repeat("word", 50)); + var chunks = ConversationText.SplitIntoChunks(text, 40); + + Assert.True(chunks.Count > 1); + Assert.All(chunks, c => Assert.True(c.Length <= 40)); + Assert.Equal(text, string.Join(' ', chunks)); + } + + [Fact] + public void SplitIntoChunks_HardSplitsUnbrokenText() + { + var text = new string('x', 250); + var chunks = ConversationText.SplitIntoChunks(text, 120); + + Assert.Equal(3, chunks.Count); + Assert.All(chunks, c => Assert.True(c.Length <= 120)); + Assert.Equal(250, chunks.Sum(c => c.Length)); + } +} diff --git a/Projects/UOContent/Engines/AIConversation/AIConversationSystem.cs b/Projects/UOContent/Engines/AIConversation/AIConversationSystem.cs new file mode 100644 index 000000000..6e73e50db --- /dev/null +++ b/Projects/UOContent/Engines/AIConversation/AIConversationSystem.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Server.Collections; +using Server.Logging; +using Server.Mobiles; +using Server.Text; + +namespace Server.Engines.AIConversation; + +/// +/// Lets players hold natural-language conversations with NPCs, backed by the +/// Anthropic Messages API. NPCs must have a persona (see PersonaManager) to +/// participate. +/// +/// A player speaks near a persona NPC -> the system builds the conversation +/// history and awaits an API request. The HTTP I/O runs off-thread and the +/// continuation is marshaled back to the game loop by the EventLoopContext, +/// so the game thread never blocks on the network. Speech is observed +/// passively — existing keyword systems (bank, guards, vendors, pets) are +/// unaffected. +/// +public static class AIConversationSystem +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(AIConversationSystem)); + + private const string _fallbackLine = "Hmm... forgive me, my thoughts wandered. What were we speaking of?"; + + // Configuration (see README.md in this directory for details) + private static bool _enabled; + private static string _apiUrl; + private static string _apiKeyEnvVar; + private static string _model; + private static int _maxTokens; + private static TimeSpan _requestTimeout; + private static int _engageRange; + private static TimeSpan _sessionIdleTimeout; + private static int _maxHistoryTurns; + private static int _maxPlayerMessageLength; + private static int _maxResponseLength; + private static TimeSpan _playerCooldown; + private static int _playerRequestsPerMinute; + private static int _maxConcurrentRequests; + private static string _extraInstructions; + + private static string _apiKey; + private static AnthropicClient _client; + private static bool _runtimeEnabled; + + private static readonly Dictionary _sessions = new(); + private static TimerExecutionToken _sweepTimerToken; + + private static int _activeRequests; + private static long _totalRequests; + private static long _totalFailures; + private static long _totalInputTokens; + private static long _totalOutputTokens; + + public static bool Running => _runtimeEnabled; + + public static void Configure() + { + LoadSettings(); + + CommandSystem.Register("AIChat", AccessLevel.Administrator, AIChat_OnCommand); + + EventSink.Speech += OnSpeech; + } + + public static void Initialize() + { + if (!_enabled) + { + return; + } + + if (string.IsNullOrEmpty(_apiKey)) + { + logger.Warning( + "Enabled in configuration but the {EnvVar} environment variable is not set. AI conversations are disabled.", + _apiKeyEnvVar + ); + return; + } + + EnableRuntime(); + + logger.Information("NPC conversations enabled (model: {Model})", _model); + } + + private static void LoadSettings() + { + _enabled = ServerConfiguration.GetOrUpdateSetting("aiConversation.enabled", false); + _apiUrl = ServerConfiguration.GetOrUpdateSetting("aiConversation.apiUrl", "https://api.anthropic.com/v1/messages"); + _apiKeyEnvVar = ServerConfiguration.GetOrUpdateSetting("aiConversation.apiKeyEnvVar", "ANTHROPIC_API_KEY"); + _model = ServerConfiguration.GetOrUpdateSetting("aiConversation.model", "claude-haiku-4-5"); + _maxTokens = ServerConfiguration.GetOrUpdateSetting("aiConversation.maxTokens", 200); + _requestTimeout = ServerConfiguration.GetOrUpdateSetting("aiConversation.requestTimeout", TimeSpan.FromSeconds(20)); + _engageRange = ServerConfiguration.GetOrUpdateSetting("aiConversation.engageRange", 6); + _sessionIdleTimeout = ServerConfiguration.GetOrUpdateSetting("aiConversation.sessionIdleTimeout", TimeSpan.FromMinutes(2)); + _maxHistoryTurns = ServerConfiguration.GetOrUpdateSetting("aiConversation.maxHistoryMessages", 20); + _maxPlayerMessageLength = ServerConfiguration.GetOrUpdateSetting("aiConversation.maxPlayerMessageLength", 240); + _maxResponseLength = ServerConfiguration.GetOrUpdateSetting("aiConversation.maxResponseLength", 600); + _playerCooldown = ServerConfiguration.GetOrUpdateSetting("aiConversation.playerCooldown", TimeSpan.FromSeconds(2)); + _playerRequestsPerMinute = ServerConfiguration.GetOrUpdateSetting("aiConversation.playerRequestsPerMinute", 8); + _maxConcurrentRequests = ServerConfiguration.GetOrUpdateSetting("aiConversation.maxConcurrentRequests", 4); + _extraInstructions = ServerConfiguration.GetOrUpdateSetting("aiConversation.extraInstructions", ""); + + _apiKey = string.IsNullOrEmpty(_apiKeyEnvVar) ? null : Environment.GetEnvironmentVariable(_apiKeyEnvVar); + } + + private static void EnableRuntime() + { + _client = new AnthropicClient(_apiUrl, _apiKey, _requestTimeout); + _runtimeEnabled = true; + + _sweepTimerToken.Cancel(); + Timer.StartTimer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), SweepSessions, out _sweepTimerToken); + } + + private static void DisableRuntime() + { + _runtimeEnabled = false; + _sweepTimerToken.Cancel(); + EndAllSessions(); + } + + private static void EndSession(ConversationSession session) + { + session.Ended = true; + + if (_sessions.TryGetValue(session.Player, out var current) && current == session) + { + _sessions.Remove(session.Player); + } + } + + private static int EndAllSessions() + { + var count = _sessions.Count; + + foreach (var session in _sessions.Values) + { + session.Ended = true; + } + + _sessions.Clear(); + return count; + } + + private static void SweepSessions() + { + if (_sessions.Count == 0) + { + return; + } + + using var expired = PooledRefList.Create(); + var now = Core.Now; + + foreach (var session in _sessions.Values) + { + if (session.Busy) + { + continue; // resolved when the in-flight reply lands + } + + var invalid = session.Npc.Deleted || session.Player.Deleted || session.Player.NetState == null || + !IsInConversationRange(session.Player, session.Npc); + + if (invalid || now - session.LastActivity > _sessionIdleTimeout) + { + expired.Add(session); + } + } + + for (var i = 0; i < expired.Count; i++) + { + EndSession(expired[i]); + } + } + + private static bool IsInConversationRange(Mobile player, BaseCreature npc) => + npc.Map == player.Map && player.InRange(npc.Location, _engageRange + 4); + + private static void OnSpeech(SpeechEventArgs e) + { + if (!_runtimeEnabled || e.Handled || e.Blocked || e.Type != MessageType.Regular) + { + return; + } + + var from = e.Mobile; + + if (from?.Player != true || !from.Alive || from.NetState == null) + { + return; + } + + var said = e.Speech?.Trim(); + + if (string.IsNullOrEmpty(said) || said.StartsWithOrdinal(CommandSystem.Prefix)) + { + return; + } + + _sessions.TryGetValue(from, out var session); + + // Addressing another persona NPC by name switches the conversation. + var addressed = FindAddressedNpc(from, said); + + if (addressed != null && addressed != session?.Npc) + { + if (session != null) + { + EndSession(session); + } + + session = new ConversationSession(from, addressed, _maxHistoryTurns); + _sessions[from] = session; + + SendToNpc(session, said); + return; + } + + if (session != null) + { + if (session.Npc.Deleted || !IsInConversationRange(from, session.Npc)) + { + EndSession(session); + return; + } + + if (ConversationText.IsFarewell(said)) + { + session.Npc.Direction = session.Npc.GetDirectionTo(from); + session.Npc.Say($"Farewell, {from.Name}."); + EndSession(session); + return; + } + + SendToNpc(session, said); + return; + } + + // No session and no NPC addressed by name: a plain greeting engages + // the nearest persona NPC. + if (ConversationText.IsGreeting(said)) + { + var nearest = FindNearestPersonaNpc(from); + + if (nearest != null) + { + session = new ConversationSession(from, nearest, _maxHistoryTurns); + _sessions[from] = session; + + SendToNpc(session, said); + } + } + } + + private static BaseCreature FindAddressedNpc(Mobile from, string said) + { + BaseCreature match = null; + var bestDistance = double.MaxValue; + + foreach (var npc in from.GetMobilesInRange(_engageRange)) + { + if (!IsEligibleNpc(from, npc) || !ConversationText.MentionsName(said, npc.Name)) + { + continue; + } + + var distance = from.GetDistanceToSqrt(npc); + + if (distance < bestDistance) + { + bestDistance = distance; + match = npc; + } + } + + return match; + } + + private static BaseCreature FindNearestPersonaNpc(Mobile from) + { + BaseCreature match = null; + var bestDistance = double.MaxValue; + + foreach (var npc in from.GetMobilesInRange(_engageRange)) + { + if (!IsEligibleNpc(from, npc)) + { + continue; + } + + var distance = from.GetDistanceToSqrt(npc); + + if (distance < bestDistance) + { + bestDistance = distance; + match = npc; + } + } + + return match; + } + + private static bool IsEligibleNpc(Mobile from, BaseCreature npc) => + !npc.Deleted && npc.Alive && !npc.Player && !npc.Controlled && !npc.Summoned && + from.CanSee(npc) && PersonaManager.HasPersona(npc); + + private static void SendToNpc(ConversationSession session, string said) + { + if (session.Busy) + { + return; // still waiting on the previous reply + } + + var now = Core.Now; + + if (now - session.LastRequest < _playerCooldown) + { + return; + } + + var recent = session.RecentRequests; + + while (recent.Count > 0 && now - recent.Peek() > TimeSpan.FromMinutes(1)) + { + recent.Dequeue(); + } + + if (recent.Count >= _playerRequestsPerMinute) + { + session.Player.SendMessage($"{session.Npc.Name} seems overwhelmed; give them a moment."); + return; + } + + if (_activeRequests >= _maxConcurrentRequests) + { + session.Player.SendMessage($"{session.Npc.Name} seems distracted at the moment."); + return; + } + + if (said.Length > _maxPlayerMessageLength) + { + said = said[.._maxPlayerMessageLength]; + } + + session.History.Add(ChatRole.User, said); + + session.Busy = true; + session.LastRequest = now; + session.LastActivity = now; + recent.Enqueue(now); + + session.Npc.Direction = session.Npc.GetDirectionTo(session.Player); + + var request = new AnthropicRequest + { + Model = _model, + MaxTokens = _maxTokens, + SystemPrompt = BuildSystemPrompt(session.Npc, session.Player), + Messages = session.History.ToArray() + }; + + _activeRequests++; + _totalRequests++; + + _ = ProcessRequestAsync(session, request); + } + + private static async Task ProcessRequestAsync(ConversationSession session, AnthropicRequest request) + { + AnthropicResult result; + + try + { + // CompleteAsync runs its I/O on background threads; this + // continuation resumes on the game thread via Core.LoopContext. + result = await _client.CompleteAsync(request); + } + catch (Exception ex) + { + result = new AnthropicResult { Success = false, Error = ex.Message }; + } + + try + { + _activeRequests--; + OnReply(session, result); + } + catch (Exception ex) + { + logger.Error(ex, "Unhandled error while delivering an NPC reply"); + } + } + + private static void OnReply(ConversationSession session, AnthropicResult result) + { + session.Busy = false; + + // The session may have ended (farewell, idle, [AIChat end), the NPC + // may be gone, or the player may have disconnected: drop the reply. + if (session.Ended || session.Npc.Deleted || session.Player.Deleted || session.Player.NetState == null) + { + return; + } + + if (!result.Success) + { + _totalFailures++; + + logger.Warning("Request failed for {Npc}: {Error}", session.Npc.Name, result.Error); + + session.Npc.Say(_fallbackLine); + return; + } + + _totalInputTokens += result.InputTokens; + _totalOutputTokens += result.OutputTokens; + + var text = ConversationText.Sanitize(result.Text, _maxResponseLength); + + if (text.Length == 0) + { + return; + } + + session.History.Add(ChatRole.Assistant, text); + session.LastActivity = Core.Now; + + DeliverSpeech(session.Npc, session.Player, text); + } + + private static void DeliverSpeech(BaseCreature npc, Mobile player, string text) + { + var chunks = ConversationText.SplitIntoChunks(text, 120); + + npc.Direction = npc.GetDirectionTo(player); + npc.Say(chunks[0]); + + for (var i = 1; i < chunks.Count; ++i) + { + Timer.DelayCall(TimeSpan.FromMilliseconds(900 * i), SayChunk, npc, chunks[i]); + } + } + + private static void SayChunk(BaseCreature npc, string chunk) + { + if (!npc.Deleted && npc.Map != null && npc.Map != Map.Internal) + { + npc.Say(chunk); + } + } + + private static string BuildSystemPrompt(BaseCreature npc, Mobile player) + { + using var sb = ValueStringBuilder.Create(1024); + + sb.Append("You are role-playing a character in the medieval fantasy world of Ultima Online (Britannia).\n\n"); + + sb.Append($"Your character: {npc.Name ?? "an NPC"}"); + + if (!string.IsNullOrEmpty(npc.Title)) + { + sb.Append(' '); + sb.Append(npc.Title); + } + + sb.Append('\n'); + + var persona = PersonaManager.GetPersona(npc); + + if (!string.IsNullOrEmpty(persona)) + { + sb.Append($"Identity: {persona}\n"); + } + + var regionName = npc.Region?.Name; + + if (!string.IsNullOrEmpty(regionName)) + { + sb.Append($"Current location: {regionName}"); + + if (npc.Map != null) + { + sb.Append($", on the {npc.Map.Name} facet"); + } + + sb.Append('\n'); + } + + sb.Append($"You are speaking out loud, in person, with {player.Name ?? "a traveler"}"); + + if (!string.IsNullOrEmpty(player.Title)) + { + sb.Append(' '); + sb.Append(player.Title); + } + + sb.Append(", an adventurer.\n\n"); + + sb.Append("Rules:\n"); + sb.Append("- Always stay in character. Never mention being an AI, a game, or anything outside Britannia.\n"); + sb.Append("- Keep replies short and conversational: one to three sentences of spoken dialogue.\n"); + sb.Append("- Reply with speech only. No stage directions, no asterisks, no quotation marks around your words.\n"); + sb.Append("- Use a medieval fantasy tone. No modern concepts, slang, or technology.\n"); + sb.Append("- You cannot give items, gold, quests, or services through this conversation, and you cannot change the world. You can only talk.\n"); + sb.Append("- Use plain text only: no markdown, no lists, no emoji.\n"); + + if (!string.IsNullOrEmpty(_extraInstructions)) + { + sb.Append(_extraInstructions); + sb.Append('\n'); + } + + return sb.ToString(); + } + + [Usage("AIChat ")] + [Description("Controls the AI NPC conversation system.")] + private static void AIChat_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + var arg = e.Length > 0 ? e.GetString(0) : "status"; + + switch (arg.ToLowerInvariant()) + { + case "on": + { + if (string.IsNullOrEmpty(_apiKey)) + { + from.SendMessage($"Cannot enable: the {_apiKeyEnvVar} environment variable is not set."); + } + else + { + EnableRuntime(); + from.SendMessage("AI conversations enabled."); + } + + break; + } + case "off": + { + DisableRuntime(); + from.SendMessage("AI conversations disabled."); + break; + } + case "reload": + { + PersonaManager.LoadTemplates(); + LoadSettings(); + + if (_runtimeEnabled) + { + EnableRuntime(); // rebuild the client with fresh settings + } + + from.SendMessage( + $"Persona templates and settings reloaded. ({PersonaManager.TemplateCount} templates, {PersonaManager.CustomCount} custom personas)" + ); + break; + } + case "end": + { + var count = EndAllSessions(); + from.SendMessage($"Ended {count} conversation(s)."); + break; + } + default: + { + if (_runtimeEnabled) + { + from.SendMessage($"AIChat status: running (model: {_model})"); + } + else + { + from.SendMessage($"AIChat status: stopped (model: {_model})"); + } + + from.SendMessage($"Sessions: {_sessions.Count} active, {_activeRequests} request(s) in flight."); + from.SendMessage($"Personas: {PersonaManager.TemplateCount} templates, {PersonaManager.CustomCount} custom."); + from.SendMessage( + $"Usage: {_totalRequests} requests ({_totalFailures} failed), {_totalInputTokens} input / {_totalOutputTokens} output tokens." + ); + break; + } + } + } +} diff --git a/Projects/UOContent/Engines/AIConversation/AnthropicClient.cs b/Projects/UOContent/Engines/AIConversation/AnthropicClient.cs new file mode 100644 index 000000000..371e7386a --- /dev/null +++ b/Projects/UOContent/Engines/AIConversation/AnthropicClient.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Server.Text; + +namespace Server.Engines.AIConversation; + +public enum ChatRole +{ + User, + Assistant +} + +public record struct ChatTurn(ChatRole Role, string Text); + +public class AnthropicResult +{ + public bool Success { get; init; } + public string Text { get; init; } + public string Error { get; init; } + public string StopReason { get; init; } + public long InputTokens { get; init; } + public long OutputTokens { get; init; } +} + +public class AnthropicRequest +{ + public string Model { get; init; } + public int MaxTokens { get; init; } + public string SystemPrompt { get; init; } + public IReadOnlyList Messages { get; init; } +} + +/// +/// Async client for the Anthropic Messages API (POST /v1/messages). +/// CompleteAsync never throws and never touches game state, so it is safe +/// to await from the game thread — the continuation is marshaled back +/// through the EventLoopContext. +/// +public class AnthropicClient +{ + // 429, 5xx and timeouts are worth a single retry after a short pause. + private static readonly TimeSpan _retryDelay = TimeSpan.FromMilliseconds(1500); + + private readonly HttpClient _httpClient; + private readonly string _apiUrl; + private readonly string _apiKey; + private readonly TimeSpan _timeout; + + public AnthropicClient(string apiUrl, string apiKey, TimeSpan timeout) + { + _apiUrl = apiUrl; + _apiKey = apiKey; + _timeout = timeout; + _httpClient = new HttpClient(); + } + + public async Task CompleteAsync(AnthropicRequest request) + { + var payload = BuildPayload(request); + var (result, retryable) = await SendAsync(payload).ConfigureAwait(false); + + if (!result.Success && retryable) + { + await Task.Delay(_retryDelay).ConfigureAwait(false); + (result, _) = await SendAsync(payload).ConfigureAwait(false); + } + + return result; + } + + private async Task<(AnthropicResult Result, bool Retryable)> SendAsync(string payload) + { + try + { + using var message = new HttpRequestMessage(HttpMethod.Post, _apiUrl); + message.Headers.Add("x-api-key", _apiKey); + message.Headers.Add("anthropic-version", "2023-06-01"); + message.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + using var cts = new CancellationTokenSource(_timeout); + using var response = await _httpClient.SendAsync(message, cts.Token).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var status = (int)response.StatusCode; + var error = $"HTTP {status} - {ExtractErrorMessage(body)}"; + return (new AnthropicResult { Success = false, Error = error }, status == 429 || status >= 500); + } + + return (ParseResponse(body), false); + } + catch (OperationCanceledException) + { + return (new AnthropicResult { Success = false, Error = "Request timed out" }, true); + } + catch (Exception ex) + { + return (new AnthropicResult { Success = false, Error = ex.Message }, false); + } + } + + public static string BuildPayload(AnthropicRequest request) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("model", request.Model); + writer.WriteNumber("max_tokens", request.MaxTokens); + + if (!string.IsNullOrEmpty(request.SystemPrompt)) + { + writer.WriteString("system", request.SystemPrompt); + } + + writer.WriteStartArray("messages"); + + foreach (var turn in request.Messages) + { + writer.WriteStartObject(); + writer.WriteString("role", turn.Role == ChatRole.User ? "user" : "assistant"); + writer.WriteString("content", turn.Text); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + public static AnthropicResult ParseResponse(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("type", out var type) && type.ValueEquals("error")) + { + return new AnthropicResult { Success = false, Error = ExtractErrorMessage(root) }; + } + + // May run on a thread-pool continuation, so use the multi-threaded pool. + using var text = ValueStringBuilder.CreateMT(); + + if (root.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) + { + foreach (var block in content.EnumerateArray()) + { + if (block.TryGetProperty("type", out var blockType) && blockType.ValueEquals("text") && + block.TryGetProperty("text", out var blockText)) + { + text.Append(blockText.GetString()); + } + } + } + + string stopReason = null; + + if (root.TryGetProperty("stop_reason", out var stop) && stop.ValueKind == JsonValueKind.String) + { + stopReason = stop.GetString(); + } + + long inputTokens = 0, outputTokens = 0; + + if (root.TryGetProperty("usage", out var usage)) + { + if (usage.TryGetProperty("input_tokens", out var input) && input.TryGetInt64(out var inputValue)) + { + inputTokens = inputValue; + } + + if (usage.TryGetProperty("output_tokens", out var output) && output.TryGetInt64(out var outputValue)) + { + outputTokens = outputValue; + } + } + + if (text.Length == 0) + { + return new AnthropicResult + { + Success = false, + Error = $"Empty response (stop_reason: {stopReason ?? "unknown"})", + StopReason = stopReason + }; + } + + return new AnthropicResult + { + Success = true, + Text = text.ToString(), + StopReason = stopReason, + InputTokens = inputTokens, + OutputTokens = outputTokens + }; + } + catch (JsonException) + { + return new AnthropicResult { Success = false, Error = "Unexpected response shape" }; + } + } + + private static string ExtractErrorMessage(string body) + { + if (string.IsNullOrEmpty(body)) + { + return "Unknown API error"; + } + + try + { + using var doc = JsonDocument.Parse(body); + return ExtractErrorMessage(doc.RootElement); + } + catch (JsonException) + { + return "Unknown API error"; + } + } + + private static string ExtractErrorMessage(JsonElement root) + { + if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("error", out var error) && + error.ValueKind == JsonValueKind.Object && error.TryGetProperty("message", out var message)) + { + var errorType = error.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String + ? t.GetString() + : "error"; + + return $"{errorType}: {message.GetString()}"; + } + + return "Unknown API error"; + } +} diff --git a/Projects/UOContent/Engines/AIConversation/ConversationHistory.cs b/Projects/UOContent/Engines/AIConversation/ConversationHistory.cs new file mode 100644 index 000000000..6fef5c129 --- /dev/null +++ b/Projects/UOContent/Engines/AIConversation/ConversationHistory.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace Server.Engines.AIConversation; + +/// +/// Bounded per-session message history. Oldest turns are dropped first, and +/// the head is re-aligned so the first message sent to the API is always a +/// user turn (the Messages API rejects histories that open with "assistant"). +/// +public class ConversationHistory +{ + private readonly List _turns = new(); + private readonly int _maxTurns; + + public ConversationHistory(int maxTurns) => _maxTurns = maxTurns; + + public IReadOnlyList Turns => _turns; + + public int Count => _turns.Count; + + public void Add(ChatRole role, string text) + { + _turns.Add(new ChatTurn(role, text)); + Trim(); + } + + private void Trim() + { + if (_turns.Count > _maxTurns) + { + _turns.RemoveRange(0, _turns.Count - _maxTurns); + } + + var firstUser = 0; + + while (firstUser < _turns.Count && _turns[firstUser].Role != ChatRole.User) + { + firstUser++; + } + + if (firstUser > 0) + { + _turns.RemoveRange(0, firstUser); + } + } + + /// Snapshot for handing to a background request. + public ChatTurn[] ToArray() => _turns.ToArray(); +} diff --git a/Projects/UOContent/Engines/AIConversation/ConversationSession.cs b/Projects/UOContent/Engines/AIConversation/ConversationSession.cs new file mode 100644 index 000000000..96a4e52bf --- /dev/null +++ b/Projects/UOContent/Engines/AIConversation/ConversationSession.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using Server.Mobiles; + +namespace Server.Engines.AIConversation; + +/// +/// One player's active conversation with a persona NPC. Only ever touched on +/// the game thread. +/// +public class ConversationSession +{ + public ConversationSession(Mobile player, BaseCreature npc, int maxHistoryTurns) + { + Player = player; + Npc = npc; + History = new ConversationHistory(maxHistoryTurns); + LastActivity = Core.Now; + } + + public Mobile Player { get; } + public BaseCreature Npc { get; } + public ConversationHistory History { get; } + + /// Timestamps of recent API requests, for the per-minute cap. + public Queue RecentRequests { get; } = new(); + + public DateTime LastActivity { get; set; } + public DateTime LastRequest { get; set; } + + /// An API request is in flight; further speech is ignored. + public bool Busy { get; set; } + + /// Set when the session ends so late replies are dropped. + public bool Ended { get; set; } +} diff --git a/Projects/UOContent/Engines/AIConversation/ConversationText.cs b/Projects/UOContent/Engines/AIConversation/ConversationText.cs new file mode 100644 index 000000000..987a4caa1 --- /dev/null +++ b/Projects/UOContent/Engines/AIConversation/ConversationText.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using Server.Text; + +namespace Server.Engines.AIConversation; + +/// +/// Pure text helpers for AI NPC conversations: engagement phrase detection, +/// NPC name matching, model-output sanitization and overhead-speech chunking. +/// No game-state dependencies so the logic is unit-testable. +/// +public static class ConversationText +{ + private static readonly string[] _greetings = { "hi", "hello", "hail", "hey", "greetings", "salutations", "good day", "well met" }; + private static readonly string[] _farewells = { "bye", "goodbye", "farewell", "later", "good bye" }; + + public static bool IsGreeting(ReadOnlySpan said) => StartsWithAny(said, _greetings); + + public static bool IsFarewell(ReadOnlySpan said) => StartsWithAny(said, _farewells); + + private static bool StartsWithAny(ReadOnlySpan said, string[] phrases) + { + foreach (var phrase in phrases) + { + if (said.StartsWith(phrase, StringComparison.OrdinalIgnoreCase) && + (said.Length == phrase.Length || !char.IsLetter(said[phrase.Length]))) + { + return true; + } + } + + return false; + } + + /// + /// True when the speech contains a whole-word mention of any part of the + /// NPC's name that is at least three characters long ("Elric" matches + /// "hail sage elric!", but "El" never matches and "elrics" does not). + /// + public static bool MentionsName(string said, string name) + { + if (string.IsNullOrEmpty(said) || string.IsNullOrEmpty(name)) + { + return false; + } + + foreach (var part in name.AsSpan().Split(' ')) + { + var word = name.AsSpan()[part]; + + if (word.Length < 3) + { + continue; + } + + var remaining = said.AsSpan(); + var offset = 0; + + while (true) + { + var index = remaining[offset..].IndexOf(word, StringComparison.OrdinalIgnoreCase); + + if (index < 0) + { + break; + } + + index += offset; + + var startOk = index == 0 || !char.IsLetter(remaining[index - 1]); + var end = index + word.Length; + var endOk = end >= remaining.Length || !char.IsLetter(remaining[end]); + + if (startOk && endOk) + { + return true; + } + + offset = index + 1; + } + } + + return false; + } + + /// + /// Cleans model output for overhead speech: control characters and + /// newlines become spaces, runs of whitespace collapse, wrapping quotes + /// are stripped, and text longer than maxLength is cut at a sentence + /// boundary where a reasonable one exists. + /// + public static string Sanitize(string text, int maxLength) + { + if (string.IsNullOrEmpty(text)) + { + return ""; + } + + using var sb = ValueStringBuilder.CreateMT(text.Length); + var pendingSpace = false; + + foreach (var c in text) + { + if (char.IsWhiteSpace(c) || char.IsControl(c)) + { + pendingSpace = sb.Length > 0; + continue; + } + + if (pendingSpace) + { + sb.Append(' '); + pendingSpace = false; + } + + sb.Append(c); + } + + var result = sb.ToString(); + + if (result.Length >= 2 && result[0] == '"' && result[^1] == '"') + { + result = result[1..^1].Trim(); + } + + return result.Length > maxLength ? TruncateAtSentence(result, maxLength) : result; + } + + private static string TruncateAtSentence(string text, int maxLength) + { + var slice = text[..maxLength]; + + var lastSentence = Math.Max( + slice.LastIndexOf(". ", StringComparison.Ordinal), + Math.Max(slice.LastIndexOf("! ", StringComparison.Ordinal), slice.LastIndexOf("? ", StringComparison.Ordinal)) + ); + + return lastSentence > maxLength / 3 ? slice[..(lastSentence + 1)] : slice; + } + + /// + /// Splits sanitized text into chunks of at most maxLength characters for + /// overhead speech, preferring sentence boundaries, then word breaks. + /// + public static List SplitIntoChunks(string text, int maxLength) + { + var chunks = new List(); + var remaining = text.AsSpan().Trim(); + + while (remaining.Length > maxLength) + { + var split = -1; + + // Look for the last sentence end inside the window, but not so + // early that the chunk becomes a fragment. + for (var i = maxLength - 1; i > maxLength / 3; --i) + { + var c = remaining[i]; + + if (c is '.' or '!' or '?') + { + split = i + 1; + break; + } + } + + if (split < 0) + { + split = remaining[..(maxLength + 1)].LastIndexOf(' '); + + if (split < maxLength / 3) + { + split = maxLength; + } + } + + chunks.Add(remaining[..split].TrimEnd().ToString()); + remaining = remaining[split..].TrimStart(); + } + + if (remaining.Length > 0) + { + chunks.Add(remaining.ToString()); + } + + return chunks; + } +} diff --git a/Projects/UOContent/Engines/AIConversation/PersonaManager.cs b/Projects/UOContent/Engines/AIConversation/PersonaManager.cs new file mode 100644 index 000000000..8f9d3e878 --- /dev/null +++ b/Projects/UOContent/Engines/AIConversation/PersonaManager.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; +using Server.Logging; +using Server.Mobiles; +using Server.Prompts; +using Server.Targeting; + +namespace Server.Engines.AIConversation; + +/// +/// Resolves the identity used to role-play an NPC. Personas come from two +/// sources, in priority order: +/// 1. Per-NPC personas set in game by staff ([SetPersona) — keyed to the +/// specific NPC and persisted through world saves. +/// 2. Hand-authored templates in Data/ai-personas.json — matched by exact +/// NPC name, then by class name walking up the inheritance chain. +/// Only NPCs that resolve to a persona hold AI conversations. +/// +public class PersonaManager : GenericPersistence +{ + private const string _templatePath = "Data/ai-personas.json"; + + private static readonly ILogger logger = LogFactory.GetLogger(typeof(PersonaManager)); + + private static readonly Dictionary _customPersonas = new(); + private static readonly Dictionary _nameTemplates = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary _typeTemplates = new(StringComparer.OrdinalIgnoreCase); + + public static PersonaManager Instance { get; private set; } + + public static int CustomCount => _customPersonas.Count; + public static int TemplateCount => _nameTemplates.Count + _typeTemplates.Count; + + public static void Configure() + { + Instance = new PersonaManager(); + + CommandSystem.Register("SetPersona", AccessLevel.GameMaster, SetPersona_OnCommand); + CommandSystem.Register("RemovePersona", AccessLevel.GameMaster, RemovePersona_OnCommand); + CommandSystem.Register("PersonaInfo", AccessLevel.GameMaster, PersonaInfo_OnCommand); + + LoadTemplates(); + } + + public PersonaManager() : base("AIPersonas", 100) + { + } + + /// + /// Returns the persona text for a mobile, or null if it has none and + /// therefore should not hold AI conversations. + /// + public static string GetPersona(Mobile npc) + { + if (npc == null) + { + return null; + } + + if (_customPersonas.TryGetValue(npc, out var persona)) + { + return persona; + } + + if (!string.IsNullOrEmpty(npc.Name) && _nameTemplates.TryGetValue(npc.Name, out persona)) + { + return persona; + } + + var type = npc.GetType(); + + while (type != null && type != typeof(object)) + { + if (_typeTemplates.TryGetValue(type.Name, out persona)) + { + return persona; + } + + type = type.BaseType; + } + + return null; + } + + public static bool HasPersona(Mobile npc) => GetPersona(npc) != null; + + public static bool HasCustomPersona(Mobile npc) => _customPersonas.ContainsKey(npc); + + public static void SetCustomPersona(Mobile npc, string persona) => _customPersonas[npc] = persona; + + public static bool RemoveCustomPersona(Mobile npc) => _customPersonas.Remove(npc); + + public static void LoadTemplates() + { + _nameTemplates.Clear(); + _typeTemplates.Clear(); + + var path = Path.Combine(Core.BaseDirectory, _templatePath); + var templates = JsonConfig.Deserialize(path); + + if (templates == null) + { + return; + } + + foreach (var entry in templates) + { + var persona = entry.Persona?.Trim(); + + if (string.IsNullOrEmpty(persona)) + { + continue; + } + + if (!string.IsNullOrEmpty(entry.Name)) + { + _nameTemplates[entry.Name] = persona; + } + else if (!string.IsNullOrEmpty(entry.Type)) + { + _typeTemplates[entry.Type] = persona; + } + } + + logger.Information("Loaded {Count} persona template(s) from {Path}", TemplateCount, _templatePath); + } + + public override void Serialize(IGenericWriter writer) + { + writer.WriteEncodedInt(0); // version + + var count = 0; + + foreach (var (npc, _) in _customPersonas) + { + if (npc?.Deleted == false) + { + count++; + } + } + + writer.WriteEncodedInt(count); + + foreach (var (npc, persona) in _customPersonas) + { + if (npc?.Deleted == false) + { + writer.Write(npc); + writer.Write(persona); + } + } + } + + public override void Deserialize(IGenericReader reader) + { + reader.ReadEncodedInt(); // version + + var count = reader.ReadEncodedInt(); + + for (var i = 0; i < count; ++i) + { + var npc = reader.ReadEntity(); + var persona = reader.ReadString(); + + if (npc?.Deleted == false && !string.IsNullOrEmpty(persona)) + { + _customPersonas[npc] = persona; + } + } + } + + private static bool ValidateNpc(Mobile from, object targeted, out BaseCreature npc) + { + npc = targeted as BaseCreature; + + if (npc == null || npc.Player) + { + from.SendMessage("That is not an NPC."); + return false; + } + + if (npc.Controlled || npc.Summoned) + { + from.SendMessage("Pets and summons cannot be given personas."); + return false; + } + + return true; + } + + [Usage("SetPersona")] + [Description("Targets an NPC, then prompts for a persona description enabling AI conversation for that NPC.")] + private static void SetPersona_OnCommand(CommandEventArgs e) + { + e.Mobile.SendMessage("Target the NPC to give a persona."); + e.Mobile.Target = new SetPersonaTarget(); + } + + [Usage("RemovePersona")] + [Description("Targets an NPC and removes its custom AI persona.")] + private static void RemovePersona_OnCommand(CommandEventArgs e) + { + e.Mobile.SendMessage("Target the NPC to remove its persona."); + e.Mobile.Target = new RemovePersonaTarget(); + } + + [Usage("PersonaInfo")] + [Description("Targets an NPC and displays the AI persona that applies to it, if any.")] + private static void PersonaInfo_OnCommand(CommandEventArgs e) + { + e.Mobile.SendMessage("Target the NPC to inspect."); + e.Mobile.Target = new PersonaInfoTarget(); + } + + private class SetPersonaTarget : Target + { + public SetPersonaTarget() : base(-1, false, TargetFlags.None) + { + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (!ValidateNpc(from, targeted, out var npc)) + { + return; + } + + from.SendMessage($"Enter the persona for {npc.Name} (who they are, how they speak, what they know):"); + from.Prompt = new SetPersonaPrompt(npc); + } + } + + private class SetPersonaPrompt : Prompt + { + private readonly BaseCreature _npc; + + public SetPersonaPrompt(BaseCreature npc) => _npc = npc; + + public override void OnResponse(Mobile from, string text) + { + if (_npc?.Deleted != false) + { + from.SendMessage("That NPC no longer exists."); + return; + } + + text = text?.Trim(); + + if (string.IsNullOrEmpty(text)) + { + from.SendMessage("Persona unchanged."); + return; + } + + SetCustomPersona(_npc, text); + + if (AIConversationSystem.Running) + { + from.SendMessage($"Persona set for {_npc.Name}. Players may now converse with them."); + } + else + { + from.SendMessage($"Persona set for {_npc.Name}. Players may converse with them once AI chat is enabled."); + } + } + } + + private class RemovePersonaTarget : Target + { + public RemovePersonaTarget() : base(-1, false, TargetFlags.None) + { + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (!ValidateNpc(from, targeted, out var npc)) + { + return; + } + + if (RemoveCustomPersona(npc)) + { + from.SendMessage($"Custom persona removed from {npc.Name}."); + } + else + { + from.SendMessage($"{npc.Name} has no custom persona."); + } + } + } + + private class PersonaInfoTarget : Target + { + public PersonaInfoTarget() : base(-1, false, TargetFlags.None) + { + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is not Mobile npc) + { + from.SendMessage("That is not a mobile."); + return; + } + + var persona = GetPersona(npc); + + if (persona == null) + { + from.SendMessage($"{npc.Name} has no AI persona."); + return; + } + + if (HasCustomPersona(npc)) + { + from.SendMessage($"Persona for {npc.Name} (custom):"); + } + else + { + from.SendMessage($"Persona for {npc.Name} (template):"); + } + + if (persona.Length > 200) + { + from.SendMessage($"{persona[..200]}..."); + } + else + { + from.SendMessage(persona); + } + } + } + + public record PersonaTemplateEntry + { + [JsonPropertyName("name")] + public string Name { get; init; } + + [JsonPropertyName("type")] + public string Type { get; init; } + + [JsonPropertyName("persona")] + public string Persona { get; init; } + } +} diff --git a/Projects/UOContent/Engines/AIConversation/README.md b/Projects/UOContent/Engines/AIConversation/README.md new file mode 100644 index 000000000..e6a97cab3 --- /dev/null +++ b/Projects/UOContent/Engines/AIConversation/README.md @@ -0,0 +1,109 @@ +# AI NPC Conversations + +Gives NPCs unique identities and lets players talk to them in natural +language, backed by the Anthropic Messages API (default model: +`claude-haiku-4-5` — fast and inexpensive, well suited to short in-character +dialogue). + +## Setup + +1. Get an API key from https://platform.claude.com/ and export it on the + server host: + + ```sh + export ANTHROPIC_API_KEY=sk-ant-... + ``` + + The key is only ever read from the environment (variable name configurable + via `aiConversation.apiKeyEnvVar`) — never put it in a config file. + +2. In `Configuration/modernuo.json`, set `"aiConversation.enabled": "true"`. + The system is **off by default**. All settings appear in the file with + their defaults after the first boot: + + | Setting | Default | Description | + |---|---|---| + | `aiConversation.enabled` | `false` | Master switch | + | `aiConversation.model` | `claude-haiku-4-5` | Anthropic model id | + | `aiConversation.maxTokens` | `200` | Max tokens per reply | + | `aiConversation.requestTimeout` | `00:00:20` | HTTP timeout per attempt | + | `aiConversation.engageRange` | `6` | Tiles within which speech engages an NPC | + | `aiConversation.sessionIdleTimeout` | `00:02:00` | Idle time before a conversation ends | + | `aiConversation.maxHistoryMessages` | `20` | Bounded per-session history (oldest dropped) | + | `aiConversation.maxPlayerMessageLength` | `240` | Player text truncated beyond this | + | `aiConversation.maxResponseLength` | `600` | NPC reply truncated at a sentence boundary | + | `aiConversation.playerCooldown` | `00:00:02` | Minimum delay between requests per player | + | `aiConversation.playerRequestsPerMinute` | `8` | Per-player per-minute request cap | + | `aiConversation.maxConcurrentRequests` | `4` | Server-wide in-flight request cap | + | `aiConversation.extraInstructions` | *(empty)* | Extra text appended to the system prompt | + | `aiConversation.apiKeyEnvVar` | `ANTHROPIC_API_KEY` | Environment variable holding the key | + | `aiConversation.apiUrl` | `https://api.anthropic.com/v1/messages` | API endpoint | + + If the system is enabled but no key is present, a warning is logged at + startup and the system stays disabled. + +3. Give NPCs personas. Only NPCs with a persona will converse: + - **Templates** — edit `Data/ai-personas.json` to match NPCs by class + (`"type": "Banker"`, applies to subclasses too) or exact name + (`"name": "Sage Elric"`). Ships with Banker, AnimalTrainer and + TavernKeeper examples. + - **In game** — a GameMaster uses `[SetPersona`, targets an NPC, and types + a description. Per-NPC personas persist through world saves (module + save `AIPersonas`) and take priority over templates. + +4. Restart the server (or use `[AIChat reload` after editing the JSON). + +## How players talk to NPCs + +- Say **hello** (hi/hail/greetings/well met...) near a persona NPC, or say + its **name**, to start a conversation. +- Keep talking normally — everything said nearby continues the conversation. +- Say **farewell** (bye/goodbye...), walk away, or go quiet for two minutes + to end it. Farewells are answered with a canned line — no API call. +- Speaking another persona NPC's name switches the conversation to them. + +Existing keyword behaviors (bank, guards, vendor buy, escort destinations, +pet commands) are untouched — the system observes `EventSink.Speech` +passively and never sets `Handled` or `Blocked`. Avoid giving personas to +NPCs whose keywords overlap with normal chat, or they may answer twice. + +## Commands + +| Command | Access | Description | +|---|---|---| +| `[SetPersona` | GameMaster | Target an NPC, then enter its persona text | +| `[RemovePersona` | GameMaster | Remove a custom persona | +| `[PersonaInfo` | GameMaster | Show the persona that applies to an NPC | +| `[AIChat status` | Administrator | Show state, sessions, in-flight requests and token usage | +| `[AIChat on/off` | Administrator | Toggle at runtime | +| `[AIChat reload` | Administrator | Reload persona templates and settings | +| `[AIChat end` | Administrator | End all active conversations | + +## Design notes + +- **Threading** — ModernUO game logic is single-threaded, and the game loop + installs an `EventLoopContext` as the thread's `SynchronizationContext`. + Requests are dispatched with `async`/`await`: the HTTP I/O runs on the + thread pool, and the continuation resumes on the game thread before any + game object is touched. The game loop never blocks on the network. + (`AnthropicClient` uses `ConfigureAwait(false)` internally because it + never touches game state; the top-level await in `AIConversationSystem` + deliberately does not, so it marshals back.) +- **Session safety** — replies are dropped if the session ended, the NPC was + deleted, or the player disconnected while the request was in flight. + Speech from a player whose request is still in flight is ignored. +- **Cost control** — per-player cooldown and per-minute caps, a server-wide + concurrent request cap, bounded history, bounded input/output lengths, and + a cheap model by default. `[AIChat status` reports cumulative token usage + (Haiku 4.5: $1 per million input tokens, $5 per million output tokens). +- **Failures** — one retry after ~1.5s on HTTP 429/5xx/timeout; other errors + are final. On failure the error is logged and the NPC speaks a canned + in-character fallback line. +- **Safety** — the system prompt instructs the model that the NPC cannot give + items, gold or quests and can only talk; replies are sanitized (control + characters stripped, whitespace collapsed, wrapping quotes removed) and + length-limited before being spoken, then split into ~120-character chunks + at sentence boundaries, spoken ~900 ms apart. +- **Persistence** — per-NPC personas are stored by a `GenericPersistence` + module (`AIPersonas`), written as part of the normal world save. Deleted + NPCs are skipped on save and null-guarded on load.