ModernUO/Projects/UOContent/Engines/ML Quests/MLQuestSystem.cs
Kamron Batman f4a87a8629
fix: Fixes adding items with ambiguous type lookup (#2307)
Fix [add command failing with ambiguous type names + refactor for performance

### Problem

[add blight would fail with "No type with that name was found" because multiple types contain "blight" (e.g., Server.Items.Blight, Server.Ethics.Evil.Blight, Server.Items.BlightGrippedLongbow, Server.Items.QuiverOfBlight). The old code only succeeded when exactly one type matched the search regardless of constructability and inheriting Mobi les/Items.

### Solution

Exact match takes priority: If a type's name exactly equals the search string (case-insensitive), use it directly. Otherwise, show the AddGump with all partial matches.

- [add blight → Creates Blight (exact name match)
- [add bligh → Shows gump with Blight, BlightGrippedLongbow, etc.

### Refactoring

- CommandEventArgs context: Added GetContext<T>/SetContext<T> to pass resolved type through the command chain without method signature changes
- Removed TrySetupTarget duplication: Validation now happens only in ValidateArgs, eliminating redundant code paths
- Split type matching:
  - ExactMatch(string) → Returns Type for exact name match (used by [add)
  - MatchEmptyCtor(string) → Returns ConstructorInfo[] for gump display (empty-callable constructors only)

### Memory & Performance Improvements

| Optimization                  | Benefit                                                                                      |
|-------------------------------|----------------------------------------------------------------------------------------------|
| _mobileItemTypes cache        | Filters Mobile/Item types once per assembly, reused on all subsequent searches               |
| ReadOnlySpan<string> for args | Avoids string[] heap allocations when slicing arguments                                      |
| ValueStringBuilder            | Stack-allocated string building, avoids StringBuilder heap allocation                        |
| Single type resolution        | Type resolved once in ValidateArgs, passed via context to Execute (was resolved 2-3x before) |
2026-01-06 21:21:29 -08:00

865 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using ModernUO.CodeGeneratedEvents;
using Server.Commands;
using Server.Commands.Generic;
using Server.Engines.MLQuests.Gumps;
using Server.Engines.MLQuests.Objectives;
using Server.Gumps;
using Server.Items;
using Server.Mobiles;
namespace Server.Engines.MLQuests
{
public static class MLQuestSystem
{
public const int MaxConcurrentQuests = 10;
public const int SpeechColor = 0x3B2;
public static readonly bool AutoGenerateNew = true;
public static readonly bool Debug = false;
public static readonly List<MLQuest> EmptyList = new();
private static readonly List<MLQuest> m_EligiblePool = new();
static MLQuestSystem()
{
Quests = new Dictionary<Type, MLQuest>();
QuestGivers = new Dictionary<Type, List<MLQuest>>();
Contexts = new Dictionary<PlayerMobile, MLQuestContext>();
var cfgPath = Path.Combine(Core.BaseDirectory, Path.Combine("Data", "MLQuests.cfg"));
var baseQuestType = typeof(MLQuest);
var baseQuesterType = typeof(IQuestGiver);
if (File.Exists(cfgPath))
{
using var sr = new StreamReader(cfgPath);
string line;
while ((line = sr.ReadLine()) != null)
{
line = line.Trim();
if (line.Length == 0 || line.StartsWithOrdinal("#"))
{
continue;
}
var split = line.Split('\t');
var type = AssemblyHandler.FindTypeByName(split[0]);
if (type == null || !baseQuestType.IsAssignableFrom(type))
{
if (Debug)
{
Console.WriteLine(
"Warning: {1} quest type '{0}'",
split[0],
type == null ? "Unknown" : "Invalid"
);
}
continue;
}
MLQuest quest = null;
try
{
quest = type.CreateInstance<MLQuest>();
}
catch
{
// ignored
}
if (quest == null)
{
continue;
}
Register(type, quest);
for (var i = 1; i < split.Length; ++i)
{
var questerType = AssemblyHandler.FindTypeByName(split[i]);
if (questerType == null || !baseQuesterType.IsAssignableFrom(questerType))
{
if (Debug)
{
Console.WriteLine(
"Warning: {1} quester type '{0}'",
split[i],
questerType == null ? "Unknown" : "Invalid"
);
}
continue;
}
RegisterQuestGiver(quest, questerType);
}
}
}
}
public static bool Enabled { get; private set; }
public static Dictionary<Type, MLQuest> Quests { get; }
public static Dictionary<Type, List<MLQuest>> QuestGivers { get; }
public static Dictionary<PlayerMobile, MLQuestContext> Contexts { get; }
private static void Register(Type type, MLQuest quest)
{
Quests[type] = quest;
}
private static void RegisterQuestGiver(MLQuest quest, Type questerType)
{
if (!QuestGivers.TryGetValue(questerType, out var questList))
{
QuestGivers[questerType] = questList = new List<MLQuest>();
}
questList.Add(quest);
}
public static void Register(MLQuest quest, params Type[] questerTypes)
{
Register(quest.GetType(), quest);
foreach (var questerType in questerTypes)
{
RegisterQuestGiver(quest, questerType);
}
}
public static void Configure()
{
Enabled = ServerConfiguration.GetOrUpdateSetting("questSystem.enableMLQuests", Core.ML);
CommandSystem.Register("MLQuestsInfo", AccessLevel.Administrator, MLQuestsInfo_OnCommand);
CommandSystem.Register("SaveQuest", AccessLevel.Administrator, SaveQuest_OnCommand);
CommandSystem.Register("SaveAllQuests", AccessLevel.Administrator, SaveAllQuests_OnCommand);
CommandSystem.Register("InvalidQuestItems", AccessLevel.Administrator, InvalidQuestItems_OnCommand);
TargetCommands.Register(new ViewQuestsCommand());
TargetCommands.Register(new ViewContextCommand());
}
public static void Initialize()
{
if (!Enabled)
{
return;
}
if (AutoGenerateNew)
{
foreach (var quest in Quests.Values)
{
if (quest?.Deserialized == false)
{
quest.Generate();
}
}
}
MLQuestPersistence.EnsureExistence();
}
[Usage("MLQuestsInfo"),
Description("Displays general information about the ML quest system, or a quest by type name.")]
public static void MLQuestsInfo_OnCommand(CommandEventArgs e)
{
var m = e.Mobile;
if (e.Length == 0)
{
m.SendMessage($"Quest table length: {Quests.Count}");
return;
}
var index = AssemblyHandler.FindTypeByName(e.GetString(0));
if (index == null || !Quests.TryGetValue(index, out var quest))
{
m.SendMessage("Invalid quest type name.");
return;
}
m.SendMessage($"Activated: {quest.Activated}");
m.SendMessage($"Number of objectives: {quest.Objectives.Count}");
m.SendMessage($"Objective type: {quest.ObjectiveType}");
m.SendMessage($"Number of active instances: {quest.Instances.Count}");
}
[Usage("SaveQuest <type> [saveEnabled=true]"),
Description("Allows serialization for a specific quest to be turned on or off.")]
public static void SaveQuest_OnCommand(CommandEventArgs e)
{
var m = e.Mobile;
if (e.Length is 0 or > 2)
{
m.SendMessage("Syntax: SaveQuest <id> [saveEnabled=true]");
return;
}
var index = AssemblyHandler.FindTypeByName(e.GetString(0));
if (index == null || !Quests.TryGetValue(index, out var quest))
{
m.SendMessage("Invalid quest type name.");
return;
}
var enable = e.Length == 2 ? e.GetBoolean(1) : true;
quest.SaveEnabled = enable;
if (enable)
{
m.SendMessage($"Serialization for quest {quest.GetType().Name} is now enabled.");
}
else
{
m.SendMessage($"Serialization for quest {quest.GetType().Name} is now disabled.");
}
if (AutoGenerateNew && !enable)
{
m.SendMessage(
"Please note that automatic generation of new quests is ON. This quest will be regenerated on the next server start."
);
}
}
[Usage("SaveAllQuests [saveEnabled=true]"),
Description("Allows serialization for all quests to be turned on or off.")]
public static void SaveAllQuests_OnCommand(CommandEventArgs e)
{
var m = e.Mobile;
if (e.Length > 1)
{
m.SendMessage("Syntax: SaveAllQuests [saveEnabled=true]");
return;
}
var enable = e.Length == 1 ? e.GetBoolean(0) : true;
foreach (var quest in Quests.Values)
{
quest.SaveEnabled = enable;
}
if (enable)
{
m.SendMessage("Serialization for all quests is now enabled.");
}
else
{
m.SendMessage("Serialization for all quests is now disabled.");
}
if (AutoGenerateNew && !enable)
{
m.SendMessage(
"Please note that automatic generation of new quests is ON. All quests will be regenerated on the next server start."
);
}
}
[Usage("InvalidQuestItems"),
Description("Provides an overview of all quest items not located in the top-level of a player's backpack.")]
public static void InvalidQuestItems_OnCommand(CommandEventArgs e)
{
var m = e.Mobile;
var found = new List<object>();
foreach (var item in World.Items.Values)
{
if (item.QuestItem)
{
if (item.Parent is Backpack pack)
{
if (pack.Parent is PlayerMobile player && player.Backpack == pack)
{
continue;
}
}
found.Add(item);
}
}
if (found.Count == 0)
{
m.SendMessage("No matching objects found.");
}
else
{
m.SendGump(new InterfaceGump(m, new[] { "Object" }, found, 0, null));
}
}
private static bool FindQuest(
IQuestGiver quester, PlayerMobile pm, MLQuestContext context, out MLQuest quest,
out MLQuestInstance entry
)
{
quest = null;
entry = null;
var quests = quester.MLQuests;
var questerType = quester.GetType();
// 1. Check quests in progress with this NPC (overriding deliveries is intended)
if (context != null)
{
foreach (var questEntry in quests)
{
var instance = context.FindInstance(questEntry);
if (instance != null && (instance.Quester == quester ||
!questEntry.IsEscort && instance.QuesterType == questerType))
{
entry = instance;
quest = questEntry;
return true;
}
}
}
// 2. Check deliveries (overriding chain offers is intended)
if ((entry = HandleDelivery(pm, quester, questerType)) != null)
{
quest = entry.Quest;
return true;
}
// 3. Check chain quest offers
if (context != null)
{
foreach (var questEntry in quests)
{
if (questEntry.IsChainTriggered && context.ChainOffers.Contains(questEntry))
{
quest = questEntry;
return true;
}
}
}
// 4. Random quest
quest = RandomStarterQuest(quester, pm, context);
return quest != null;
}
public static void OnDoubleClick(IQuestGiver quester, PlayerMobile pm)
{
if (quester.Deleted || !pm.Alive)
{
return;
}
var context = GetContext(pm);
if (!FindQuest(quester, pm, context, out var quest, out var entry))
{
Tell(quester, pm, 1080107); // I'm sorry, I have nothing for you at this time.
return;
}
if (entry != null)
{
TurnToFace(quester, pm);
if (entry.Failed)
{
return; // Note: OSI sends no gump at all for failed quests, they have to be cancelled in the quest overview
}
if (entry.ClaimReward)
{
entry.SendRewardOffer();
}
else if (entry.IsCompleted())
{
entry.SendReportBackGump();
}
else
{
entry.SendProgressGump();
}
}
else if (quest.CanOffer(quester, pm, context, true))
{
TurnToFace(quester, pm);
quest.SendOffer(quester, pm);
}
}
public static bool CanMarkQuestItem(PlayerMobile pm, Item item, Type type)
{
var context = GetContext(pm);
if (context != null)
{
foreach (var quest in context.QuestInstances)
{
if (!quest.ClaimReward && quest.AllowsQuestItem(item, type))
{
return true;
}
}
}
return false;
}
private static void OnMarkQuestItem(PlayerMobile pm, Item item, Type type)
{
var context = GetContext(pm);
if (context == null)
{
return;
}
var instances = context.QuestInstances;
// We don't foreach because CheckComplete() can potentially modify the MLQuests list
for (var i = instances.Count - 1; i >= 0; --i)
{
var instance = instances[i];
if (instance.ClaimReward)
{
continue;
}
foreach (var objective in instance.Objectives)
{
if (!objective.Expired && objective.AllowsQuestItem(item, type))
{
objective.CheckComplete(); // yes, this can happen multiple times (for multiple quests)
break;
}
}
}
}
public static bool MarkQuestItem(PlayerMobile pm, Item item)
{
var type = item.GetType();
if (CanMarkQuestItem(pm, item, type))
{
item.QuestItem = true;
OnMarkQuestItem(pm, item, type);
return true;
}
return false;
}
public static void HandleSkillGain(PlayerMobile pm, SkillName skill)
{
var context = GetContext(pm);
if (context == null)
{
return;
}
var instances = context.QuestInstances;
for (var i = instances.Count - 1; i >= 0; --i)
{
var instance = instances[i];
if (instance.ClaimReward)
{
continue;
}
foreach (var objective in instance.Objectives)
{
if (!objective.Expired && objective is GainSkillObjectiveInstance objectiveInstance &&
objectiveInstance.Handles(skill))
{
objectiveInstance.CheckComplete();
break;
}
}
}
}
public static void HandleKill(PlayerMobile pm, Mobile mob)
{
var context = GetContext(pm);
if (context == null)
{
return;
}
var instances = context.QuestInstances;
Type type = null;
for (var i = instances.Count - 1; i >= 0; --i)
{
var instance = instances[i];
if (instance.ClaimReward)
{
continue;
}
/* A kill only counts for a single objective within a quest,
* but it can count for multiple quests. This is something not
* currently observable on OSI, so it is assumed behavior.
*/
foreach (var objective in instance.Objectives)
{
if (!objective.Expired && objective is KillObjectiveInstance kill)
{
type ??= mob.GetType();
if (kill.AddKill(mob, type))
{
kill.CheckComplete();
break;
}
}
}
}
}
public static MLQuestInstance HandleDelivery(PlayerMobile pm, IQuestGiver quester, Type questerType)
{
var context = GetContext(pm);
if (context == null)
{
return null;
}
var instances = context.QuestInstances;
MLQuestInstance deliverInstance = null;
for (var i = instances.Count - 1; i >= 0; --i)
{
var instance = instances[i];
// Do NOT skip quests on ClaimReward, because the quester still needs the quest ref!
// if (instance.ClaimReward)
// continue;
foreach (var objective in instance.Objectives)
// Note: On OSI, expired deliveries can still be completed. Bug?
{
if (!objective.Expired && objective is DeliverObjectiveInstance deliver &&
deliver.IsDestination(quester, questerType))
{
if (!deliver.HasCompleted) // objective completes only once
{
deliver.HasCompleted = true;
deliver.CheckComplete();
// The quest is continued with this NPC (important for chains)
instance.Quester = quester;
}
deliverInstance ??= instance;
break; // don't return, we may have to complete more deliveries
}
}
}
return deliverInstance;
}
public static MLQuestContext GetContext(PlayerMobile pm)
{
Contexts.TryGetValue(pm, out var context);
return context;
}
public static MLQuestContext GetOrCreateContext(PlayerMobile pm)
{
if (!Contexts.TryGetValue(pm, out var context))
{
Contexts[pm] = context = new MLQuestContext(pm);
}
return context;
}
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
public static void HandleDeath(PlayerMobile pm)
{
var context = GetContext(pm);
context?.HandleDeath();
}
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
public static void HandleDeletion(PlayerMobile pm)
{
var context = GetContext(pm);
if (context != null)
{
context.HandleDeletion();
Contexts.Remove(pm);
}
}
public static void HandleDeletion(IQuestGiver quester)
{
foreach (var quest in quester.MLQuests)
{
var instances = quest.Instances;
for (var i = instances.Count - 1; i >= 0; --i)
{
var instance = instances[i];
if (instance.Quester == quester)
{
instance.OnQuesterDeleted();
}
}
}
}
public static void QuestGumpRequest(Mobile m)
{
if (!Enabled || m is not PlayerMobile pm)
{
return;
}
pm.SendGump(new QuestLogGump(pm));
}
public static MLQuest RandomStarterQuest(IQuestGiver quester, PlayerMobile pm, MLQuestContext context)
{
var quests = quester.MLQuests;
if (quests.Count == 0)
{
return null;
}
m_EligiblePool.Clear();
MLQuest fallback = null;
foreach (var quest in quests)
{
if (quest.IsChainTriggered || context?.IsDoingQuest(quest) == true)
{
continue;
}
/*
* Save first quest that reaches the CanOffer call.
* If no quests are valid at all, return this quest for displaying the CanOffer error message.
*/
fallback ??= quest;
if (quest.CanOffer(quester, pm, context, false))
{
m_EligiblePool.Add(quest);
}
}
return m_EligiblePool.Count == 0 ? fallback : m_EligiblePool.RandomElement();
}
public static void TurnToFace(IQuestGiver quester, Mobile mob)
{
if (quester is Mobile m)
{
m.Direction = m.GetDirectionTo(mob);
}
}
public static void Tell(IQuestGiver quester, PlayerMobile pm, int cliloc)
{
TurnToFace(quester, pm);
if (quester is Mobile mobile)
{
mobile.PrivateOverheadMessage(MessageType.Regular, SpeechColor, cliloc, pm.NetState);
}
else if (quester is Item item)
{
item.SendLocalizedMessageTo(pm, cliloc, SpeechColor);
}
else
{
pm.SendLocalizedMessage(cliloc);
}
}
public static void Tell(IQuestGiver quester, PlayerMobile pm, int cliloc, string args)
{
TurnToFace(quester, pm);
if (quester is Mobile mobile)
{
mobile.PrivateOverheadMessage(MessageType.Regular, SpeechColor, cliloc, args, pm.NetState);
}
else if (quester is Item item)
{
item.SendLocalizedMessageTo(pm, cliloc, args, SpeechColor);
}
else
{
pm.SendLocalizedMessage(cliloc, args);
}
}
public static void Tell(IQuestGiver quester, PlayerMobile pm, string message)
{
TurnToFace(quester, pm);
if (quester is Mobile mobile)
{
mobile.PrivateOverheadMessage(MessageType.Regular, SpeechColor, false, message, pm.NetState);
}
else if (quester is Item item)
{
item.SendMessageTo(pm, message, SpeechColor);
}
else
{
pm.SendMessage(SpeechColor, message);
}
}
public static void TellDef(IQuestGiver quester, PlayerMobile pm, TextDefinition def)
{
if (def == null)
{
return;
}
if (def.Number > 0)
{
Tell(quester, pm, def.Number);
}
else if (def.String != null)
{
Tell(quester, pm, def.String);
}
}
public static void WriteQuestRef(IGenericWriter writer, MLQuest quest)
{
writer.Write(quest?.SaveEnabled == true ? quest.GetType().FullName : null);
}
public static MLQuest ReadQuestRef(IGenericReader reader)
{
var typeName = reader.ReadString();
if (typeName == null)
{
return null; // not serialized
}
var questType = AssemblyHandler.FindTypeByFullName(typeName);
if (questType == null)
{
return null; // no longer a type
}
return FindQuest(questType);
}
public static MLQuest FindQuest(Type questType)
{
Quests.TryGetValue(questType, out var result);
return result;
}
public static List<MLQuest> FindQuestList(Type questerType) =>
QuestGivers.TryGetValue(questerType, out var result) ? result : EmptyList;
public class ViewQuestsCommand : BaseCommand
{
public ViewQuestsCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.Simple;
Commands = new[] { "ViewQuests" };
ObjectTypes = ObjectTypes.Mobiles;
Usage = "ViewQuests";
Description = "Displays a targeted mobile's quest overview.";
}
public override void Execute(CommandEventArgs e, object obj)
{
var from = e.Mobile;
if (obj is not PlayerMobile pm)
{
LogFailure("That is not a player.");
return;
}
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} viewing quest overview of {CommandLogging.Format(pm)}"
);
from.SendGump(new QuestLogGump(pm, false));
}
}
private class ViewContextCommand : BaseCommand
{
public ViewContextCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.Simple;
Commands = new[] { "ViewMLContext" };
ObjectTypes = ObjectTypes.Mobiles;
Usage = "ViewMLContext";
Description = "Opens the ML quest context for a targeted mobile.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is not PlayerMobile pm)
{
LogFailure("They have no ML quest context.");
}
else
{
e.Mobile.SendGump(new PropertiesGump(e.Mobile, GetOrCreateContext(pm)));
}
}
}
}
}