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;
}
}
}
}