Skeleton: plugin root, backend socket, /mm router, settings, test harness
The socket connects with the shared-secret header, registers first, reassembles incoming frames of any size (the original dropped anything over 4 KiB) and reconnects two seconds after a failure. Incoming share_* frames go to the vital-sharing handlers; any other frame is a chat-box command for this character, run one per tick through the chat bar as if typed. Frames are written with Newtonsoft.Json defaults, the original's serializer, so the backend sees the same bytes. The secret lives in the character's settings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5dbb294a96
31 changed files with 3442 additions and 0 deletions
936
tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
Normal file
936
tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
Normal file
|
|
@ -0,0 +1,936 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// A settable automation surface. Every child defaults to an empty fake, so a
|
||||
/// test fills in only the state it exercises.
|
||||
/// </summary>
|
||||
public sealed class FakeAutomation : IAutomationSurface
|
||||
{
|
||||
public bool IsAvailable { get; set; }
|
||||
|
||||
public FakeCharacter Character { get; } = new();
|
||||
|
||||
public RecordingChat Chat { get; } = new();
|
||||
|
||||
public FakeSpellCatalog Spells { get; } = new();
|
||||
|
||||
public FakeMagic Magic { get; } = new();
|
||||
|
||||
public FakeItems Items { get; } = new();
|
||||
|
||||
public FakeLoot Loot { get; } = new();
|
||||
|
||||
public FakeObjects Objects { get; } = new();
|
||||
|
||||
public FakeNavigation Navigation { get; } = new();
|
||||
|
||||
public FakeFellowship Fellowship { get; } = new();
|
||||
|
||||
public FakeCombat Combat { get; } = new();
|
||||
|
||||
public FakeEquipment Equipment { get; } = new();
|
||||
|
||||
public FakeDialogAutomation Dialogs { get; } = new();
|
||||
|
||||
public FakeTrade Trade { get; } = new();
|
||||
|
||||
public FakeVendor Vendor { get; } = new();
|
||||
|
||||
public FakeLogin Login { get; } = new();
|
||||
|
||||
ICharacterInfo IAutomationSurface.Character => Character;
|
||||
ISpellCatalog IAutomationSurface.Spells => Spells;
|
||||
IMagicCommands IAutomationSurface.Magic => Magic;
|
||||
IPluginChat IAutomationSurface.Chat => Chat;
|
||||
IItemAutomation IAutomationSurface.Items => Items;
|
||||
ILootAutomation IAutomationSurface.Loot => Loot;
|
||||
IWorldObjectAutomation IAutomationSurface.Objects => Objects;
|
||||
INavigationAutomation IAutomationSurface.Navigation => Navigation;
|
||||
IFellowshipAutomation IAutomationSurface.Fellowship => Fellowship;
|
||||
ICombatAutomation IAutomationSurface.Combat => Combat;
|
||||
IEquipmentAutomation IAutomationSurface.Equipment => Equipment;
|
||||
IDialogAutomation IAutomationSurface.Dialogs => Dialogs;
|
||||
ITradeAutomation IAutomationSurface.Trade => Trade;
|
||||
IVendorAutomation IAutomationSurface.Vendor => Vendor;
|
||||
ILoginAutomation IAutomationSurface.Login => Login;
|
||||
}
|
||||
|
||||
/// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary>
|
||||
public sealed class FakeTrade : ITradeAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public bool IsOpen { get; set; }
|
||||
public uint PartnerObjectId { get; set; }
|
||||
public string PartnerName { get; set; } = string.Empty;
|
||||
public List<uint> MyItems { get; } = [];
|
||||
public List<uint> PartnerItems { get; } = [];
|
||||
public bool MyAccepted { get; set; }
|
||||
public bool PartnerAccepted { get; set; }
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
/// <summary>The status <see cref="Add"/>/<see cref="Accept"/>/<see cref="Decline"/>/<see cref="Reset"/>/<see cref="End"/> answer with.</summary>
|
||||
public PluginTradeCommandStatus NextStatus { get; set; } = PluginTradeCommandStatus.Sent;
|
||||
|
||||
IReadOnlyList<uint> ITradeAutomation.MyItems => MyItems;
|
||||
IReadOnlyList<uint> ITradeAutomation.PartnerItems => PartnerItems;
|
||||
|
||||
public event Action<PluginTradeOpened>? Opened;
|
||||
public event Action? Closed;
|
||||
public event Action<uint>? PartnerTradeAccepted;
|
||||
public event Action<PluginTradeItemAdded>? ItemAdded;
|
||||
|
||||
public void RaiseOpened(uint initiatorObjectId, uint partnerObjectId)
|
||||
{
|
||||
IsOpen = true;
|
||||
PartnerObjectId = partnerObjectId;
|
||||
Opened?.Invoke(new PluginTradeOpened(initiatorObjectId, partnerObjectId));
|
||||
}
|
||||
|
||||
public void RaiseClosed()
|
||||
{
|
||||
IsOpen = false;
|
||||
Closed?.Invoke();
|
||||
}
|
||||
|
||||
public void RaisePartnerAccepted(uint partnerObjectId)
|
||||
=> PartnerTradeAccepted?.Invoke(partnerObjectId);
|
||||
|
||||
public void RaiseItemAdded(uint itemObjectId, bool mine)
|
||||
=> ItemAdded?.Invoke(new PluginTradeItemAdded(itemObjectId, mine));
|
||||
|
||||
public PluginTradeCommandResult Add(uint itemObjectId)
|
||||
{
|
||||
Calls.Add("add:" + itemObjectId);
|
||||
return new PluginTradeCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginTradeCommandResult Accept()
|
||||
{
|
||||
Calls.Add("accept");
|
||||
return new PluginTradeCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginTradeCommandResult Decline()
|
||||
{
|
||||
Calls.Add("decline");
|
||||
return new PluginTradeCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginTradeCommandResult Reset()
|
||||
{
|
||||
Calls.Add("reset");
|
||||
return new PluginTradeCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginTradeCommandResult End()
|
||||
{
|
||||
Calls.Add("end");
|
||||
return new PluginTradeCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
event Action<PluginTradeOpened> ITradeAutomation.Opened
|
||||
{
|
||||
add => Opened += value;
|
||||
remove => Opened -= value;
|
||||
}
|
||||
|
||||
event Action ITradeAutomation.Closed
|
||||
{
|
||||
add => Closed += value;
|
||||
remove => Closed -= value;
|
||||
}
|
||||
|
||||
event Action<uint> ITradeAutomation.PartnerTradeAccepted
|
||||
{
|
||||
add => PartnerTradeAccepted += value;
|
||||
remove => PartnerTradeAccepted -= value;
|
||||
}
|
||||
|
||||
event Action<PluginTradeItemAdded> ITradeAutomation.ItemAdded
|
||||
{
|
||||
add => ItemAdded += value;
|
||||
remove => ItemAdded -= value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A settable <see cref="IVendorAutomation"/>.</summary>
|
||||
public sealed class FakeVendor : IVendorAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public bool IsOpen { get; set; }
|
||||
public uint VendorObjectId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public List<PluginVendorItem> Items { get; } = [];
|
||||
public bool IsBusy { get; set; }
|
||||
|
||||
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
|
||||
|
||||
public List<(uint TemplateObjectId, int Count)> BuyList { get; } = [];
|
||||
public List<uint> SellList { get; } = [];
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public PluginVendorCommandStatus NextStatus { get; set; } = PluginVendorCommandStatus.Sent;
|
||||
|
||||
IReadOnlyList<PluginVendorItem> IVendorAutomation.Items => Items;
|
||||
IReadOnlyList<(uint TemplateObjectId, int Count)> IVendorAutomation.BuyList => BuyList;
|
||||
IReadOnlyList<uint> IVendorAutomation.SellList => SellList;
|
||||
|
||||
public event Action<uint>? Opened;
|
||||
public event Action? Closed;
|
||||
public event Action<PluginVendorTransaction>? TransactionCompleted;
|
||||
|
||||
public void RaiseOpened(uint vendorObjectId)
|
||||
{
|
||||
IsOpen = true;
|
||||
VendorObjectId = vendorObjectId;
|
||||
Opened?.Invoke(vendorObjectId);
|
||||
}
|
||||
|
||||
public void RaiseClosed()
|
||||
{
|
||||
IsOpen = false;
|
||||
Closed?.Invoke();
|
||||
}
|
||||
|
||||
public void RaiseTransactionCompleted(PluginVendorTransactionKind kind, bool success = true, string? notice = null)
|
||||
=> TransactionCompleted?.Invoke(new PluginVendorTransaction(kind, success, notice));
|
||||
|
||||
public bool TryCaptureProperties(uint templateObjectId, out PluginItemProperties properties)
|
||||
=> Properties.TryGetValue(templateObjectId, out properties);
|
||||
|
||||
public PluginVendorCommandResult AddToBuyList(uint templateObjectId, int count)
|
||||
{
|
||||
Calls.Add("addbuy:" + templateObjectId + ":" + count);
|
||||
BuyList.Add((templateObjectId, count));
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginVendorCommandResult AddToSellList(uint itemObjectId)
|
||||
{
|
||||
Calls.Add("addsell:" + itemObjectId);
|
||||
SellList.Add(itemObjectId);
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginVendorCommandResult RemoveFromBuyList(uint templateObjectId)
|
||||
{
|
||||
Calls.Add("removebuy:" + templateObjectId);
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginVendorCommandResult RemoveFromSellList(uint itemObjectId)
|
||||
{
|
||||
Calls.Add("removesell:" + itemObjectId);
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginVendorCommandResult ClearBuyList()
|
||||
{
|
||||
Calls.Add("clearbuy");
|
||||
BuyList.Clear();
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginVendorCommandResult ClearSellList()
|
||||
{
|
||||
Calls.Add("clearsell");
|
||||
SellList.Clear();
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginVendorCommandResult BuyAll()
|
||||
{
|
||||
Calls.Add("buyall");
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
public PluginVendorCommandResult SellAll()
|
||||
{
|
||||
Calls.Add("sellall");
|
||||
return new PluginVendorCommandResult(NextStatus);
|
||||
}
|
||||
|
||||
event Action<uint> IVendorAutomation.Opened
|
||||
{
|
||||
add => Opened += value;
|
||||
remove => Opened -= value;
|
||||
}
|
||||
|
||||
event Action IVendorAutomation.Closed
|
||||
{
|
||||
add => Closed += value;
|
||||
remove => Closed -= value;
|
||||
}
|
||||
|
||||
event Action<PluginVendorTransaction> IVendorAutomation.TransactionCompleted
|
||||
{
|
||||
add => TransactionCompleted += value;
|
||||
remove => TransactionCompleted -= value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A settable <see cref="ILoginAutomation"/>.</summary>
|
||||
public sealed class FakeLogin : ILoginAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public bool LogoutResult { get; set; } = true;
|
||||
public int LogoutCalls { get; private set; }
|
||||
|
||||
public bool Logout()
|
||||
{
|
||||
LogoutCalls++;
|
||||
return LogoutResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every <see cref="Answer"/> call; a test can pre-arm which context ids it accepts.</summary>
|
||||
public sealed class FakeDialogAutomation : IDialogAutomation
|
||||
{
|
||||
public List<(uint ContextId, bool Accept)> Answers { get; } = [];
|
||||
|
||||
/// <summary>Context ids <see cref="Answer"/> reports as outstanding (returns true for).</summary>
|
||||
public HashSet<uint> OutstandingContextIds { get; } = [];
|
||||
|
||||
public bool Answer(uint contextId, bool accept)
|
||||
{
|
||||
Answers.Add((contextId, accept));
|
||||
return OutstandingContextIds.Contains(contextId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeCharacter : ICharacterInfo
|
||||
{
|
||||
public bool IsInWorld { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string WorldName { get; set; } = string.Empty;
|
||||
public int ServerPopulation { get; set; } = -1;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public int MainPackFreeSlots { get; set; }
|
||||
public uint ObjectId { get; set; }
|
||||
public uint CurrentHealth { get; set; }
|
||||
public uint MaxHealth { get; set; }
|
||||
public uint CurrentStamina { get; set; }
|
||||
public uint MaxStamina { get; set; }
|
||||
public uint CurrentMana { get; set; }
|
||||
public uint MaxMana { get; set; }
|
||||
|
||||
public List<PluginSkillInfo> Skills { get; } = [];
|
||||
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
|
||||
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; set; } = [];
|
||||
|
||||
IReadOnlyList<PluginSkillInfo> ICharacterInfo.Skills => Skills;
|
||||
|
||||
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
|
||||
{
|
||||
foreach (PluginSkillInfo candidate in Skills)
|
||||
{
|
||||
if (candidate.SkillId != skillId)
|
||||
continue;
|
||||
skill = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
skill = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records every posted line and every submitted command, and can raise
|
||||
/// <see cref="Received"/> and drive registered filters the way the real host
|
||||
/// does — before a line is added to <see cref="Posted"/> or delivered to
|
||||
/// <see cref="Received"/>, every registered filter gets a chance to eat it.
|
||||
/// </summary>
|
||||
public sealed class RecordingChat : IPluginChat
|
||||
{
|
||||
private readonly List<Func<PluginChatMessage, bool>> _filters = [];
|
||||
private ulong _nextSequence = 1;
|
||||
|
||||
public List<string> Posted { get; } = [];
|
||||
|
||||
public List<string> Submitted { get; } = [];
|
||||
|
||||
public List<(string Text, int LogTextType)> PostedTyped { get; } = [];
|
||||
|
||||
/// <summary>Every message offered to <see cref="Deliver"/>, filtered or not.</summary>
|
||||
public List<PluginChatMessage> Offered { get; } = [];
|
||||
|
||||
/// <summary>Messages that survived every filter and reached <see cref="Received"/>.</summary>
|
||||
public List<PluginChatMessage> Delivered { get; } = [];
|
||||
|
||||
public IReadOnlyList<PluginChatMessage> Messages { get; set; } = [];
|
||||
|
||||
public IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence)
|
||||
=> [.. Messages.Where(message => message.Sequence > afterSequence)];
|
||||
|
||||
public event Action<PluginChatMessage>? Received;
|
||||
|
||||
/// <summary>
|
||||
/// How many distinct handlers are currently subscribed to
|
||||
/// <see cref="Received"/> — used to prove a shared fan-out
|
||||
/// (<c>ChatClassificationDispatcher</c>) adds exactly one subscription
|
||||
/// to the raw feed no matter how many of ITS OWN consumers subscribe to
|
||||
/// it in turn.
|
||||
/// </summary>
|
||||
public int ReceivedSubscriberCount => Received?.GetInvocationList().Length ?? 0;
|
||||
|
||||
public IDisposable RegisterFilter(Func<PluginChatMessage, bool> suppress)
|
||||
{
|
||||
_filters.Add(suppress);
|
||||
return new FilterRegistration(this, suppress);
|
||||
}
|
||||
|
||||
/// <summary>Builds and offers a message the way the real client would, honoring filters.</summary>
|
||||
public PluginChatMessage Deliver(
|
||||
string text,
|
||||
int kind = 0,
|
||||
string sender = "",
|
||||
string channelName = "",
|
||||
int logTextType = 0,
|
||||
int combatKind = 0,
|
||||
uint senderObjectId = 0u)
|
||||
{
|
||||
var message = new PluginChatMessage(
|
||||
_nextSequence++, senderObjectId, kind, sender, text, channelName)
|
||||
{
|
||||
LogTextType = logTextType,
|
||||
CombatKind = combatKind,
|
||||
Received = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
Offered.Add(message);
|
||||
|
||||
foreach (Func<PluginChatMessage, bool> filter in _filters)
|
||||
{
|
||||
if (filter(message))
|
||||
return message;
|
||||
}
|
||||
|
||||
Delivered.Add(message);
|
||||
Received?.Invoke(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
public void PostSystemMessage(string text) => Posted.Add(text);
|
||||
|
||||
public void PostMessage(string text, int logTextType)
|
||||
{
|
||||
Posted.Add(text);
|
||||
PostedTyped.Add((text, logTextType));
|
||||
}
|
||||
|
||||
public bool Submit(string text)
|
||||
{
|
||||
Submitted.Add(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class FilterRegistration(
|
||||
RecordingChat owner,
|
||||
Func<PluginChatMessage, bool> filter) : IDisposable
|
||||
{
|
||||
public void Dispose() => owner._filters.Remove(filter);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeSpellCatalog : ISpellCatalog
|
||||
{
|
||||
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; set; } = [];
|
||||
public IReadOnlyList<PluginSpellInfo> KnownAttackSpells { get; set; } = [];
|
||||
public IReadOnlyList<PluginSpellInfo> KnownCombatSpells { get; set; } = [];
|
||||
|
||||
/// <summary>The full spell table, independent of what the character knows.</summary>
|
||||
public IReadOnlyList<PluginSpellInfo> All { get; set; } = [];
|
||||
|
||||
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
||||
{
|
||||
foreach (PluginSpellInfo spell in KnownSelfBuffs
|
||||
.Concat(KnownAttackSpells).Concat(KnownCombatSpells).Concat(All))
|
||||
{
|
||||
if (spell.SpellId != spellId)
|
||||
continue;
|
||||
info = spell;
|
||||
return true;
|
||||
}
|
||||
|
||||
info = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryFindByName(string name, bool partialMatch, out PluginSpellInfo spell)
|
||||
{
|
||||
spell = default;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return false;
|
||||
|
||||
foreach (PluginSpellInfo candidate in All)
|
||||
{
|
||||
if (string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
spell = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!partialMatch)
|
||||
return false;
|
||||
|
||||
foreach (PluginSpellInfo candidate in All)
|
||||
{
|
||||
if (candidate.Name.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
spell = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal spell record for a test.</summary>
|
||||
public static PluginSpellInfo Spell(uint spellId, string name)
|
||||
=> new(spellId, name, 0u, 1, 0, 0, 0f, 0u, string.Empty, true, true);
|
||||
}
|
||||
|
||||
public sealed class FakeMagic : IMagicCommands
|
||||
{
|
||||
public bool IsCasting { get; set; }
|
||||
|
||||
public List<(uint SpellId, uint TargetObjectId)> Casts { get; } = [];
|
||||
|
||||
/// <summary>What <see cref="RequestCast(uint)"/>/<see cref="RequestCast(uint,uint)"/> answer -- a test sets this to simulate a refusal that isn't a plain Cast()==false.</summary>
|
||||
public PluginCastRequestResult RequestResult { get; set; } = PluginCastRequestResult.Sent;
|
||||
|
||||
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready;
|
||||
|
||||
public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId)
|
||||
=> PluginCastGate.Ready;
|
||||
|
||||
public bool Cast(uint spellId)
|
||||
{
|
||||
Casts.Add((spellId, 0u));
|
||||
return RequestResult == PluginCastRequestResult.Sent;
|
||||
}
|
||||
|
||||
public bool Cast(uint spellId, uint targetObjectId)
|
||||
{
|
||||
Casts.Add((spellId, targetObjectId));
|
||||
return RequestResult == PluginCastRequestResult.Sent;
|
||||
}
|
||||
|
||||
public PluginCastRequestResult RequestCast(uint spellId)
|
||||
{
|
||||
Casts.Add((spellId, 0u));
|
||||
return RequestResult;
|
||||
}
|
||||
|
||||
public PluginCastRequestResult RequestCast(uint spellId, uint targetObjectId)
|
||||
{
|
||||
Casts.Add((spellId, targetObjectId));
|
||||
return RequestResult;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeItems : IItemAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public bool IsBusy { get; set; }
|
||||
|
||||
/// <summary>What <see cref="Use"/> returns -- a test sets this to simulate the host rejecting the command.</summary>
|
||||
public PluginItemCommandResult UseResult { get; set; } =
|
||||
new(PluginItemCommandStatus.Started);
|
||||
|
||||
public List<PluginInventoryItem> Owned { get; } = [];
|
||||
|
||||
public List<(string Command, uint ObjectId, uint TargetObjectId)> Calls { get; } = [];
|
||||
|
||||
/// <summary>Keyed by object id; set by a test to make <see cref="TryCaptureProperties"/> answer.</summary>
|
||||
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// How many times <see cref="CaptureOwnedItems"/> has been called — used
|
||||
/// to prove a coalescing host (H1/H3) captures at most once per tick no
|
||||
/// matter how many <c>ObjectChanged</c> events arrived inside it.
|
||||
/// </summary>
|
||||
public int CaptureCount { get; private set; }
|
||||
|
||||
/// <summary>Same counter as <see cref="CaptureCount"/> under the name the HUD-gate tests use.</summary>
|
||||
public int CaptureOwnedItemsCalls => CaptureCount;
|
||||
|
||||
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems()
|
||||
{
|
||||
CaptureCount++;
|
||||
// LOW-C (P10 re-review): the real host's CaptureOwnedItems
|
||||
// (AppAutomationSurface.cs:2573's `built`) allocates a fresh list
|
||||
// every call rather than exposing a live view -- return a snapshot
|
||||
// here too, so a caller that stores the result (InventoryLogger's
|
||||
// _lastOwnedSnapshot) is not silently aliased to this mutable list
|
||||
// and later corrupted by an unrelated Owned.Add/Clear.
|
||||
return Owned.ToList();
|
||||
}
|
||||
|
||||
public bool TryCaptureProperties(uint objectId, out PluginItemProperties properties)
|
||||
=> Properties.TryGetValue(objectId, out properties);
|
||||
|
||||
public PluginItemCommandResult Use(uint objectId)
|
||||
{
|
||||
Calls.Add(("use", objectId, 0u));
|
||||
return UseResult;
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="Apply"/> returns -- a test sets this to simulate the host rejecting the command.</summary>
|
||||
public PluginItemCommandResult ApplyResult { get; set; } =
|
||||
new(PluginItemCommandStatus.Started);
|
||||
|
||||
public PluginItemCommandResult Apply(uint objectId, uint targetObjectId)
|
||||
{
|
||||
Calls.Add(("apply", objectId, targetObjectId));
|
||||
return ApplyResult;
|
||||
}
|
||||
|
||||
public PluginItemCommandResult Drop(uint objectId, uint amount = 0u)
|
||||
{
|
||||
Calls.Add(("drop", objectId, 0u));
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
||||
}
|
||||
|
||||
public PluginItemCommandResult Give(
|
||||
uint objectId,
|
||||
uint targetObjectId,
|
||||
uint amount = 0u)
|
||||
{
|
||||
Calls.Add(("give", objectId, targetObjectId));
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
||||
}
|
||||
|
||||
/// <summary>Every <see cref="MoveToContainer"/> call, in order.</summary>
|
||||
public List<(uint ObjectId, uint ContainerObjectId, uint Amount, int Placement)> Moves { get; } = [];
|
||||
|
||||
/// <summary>Every <see cref="Merge"/> call, in order.</summary>
|
||||
public List<(uint SourceObjectId, uint TargetObjectId, uint Amount)> Merges { get; } = [];
|
||||
|
||||
public PluginItemCommandResult MoveToContainer(
|
||||
uint objectId,
|
||||
uint containerObjectId,
|
||||
uint amount = 0u,
|
||||
int placement = 0)
|
||||
{
|
||||
Calls.Add(("move", objectId, containerObjectId));
|
||||
Moves.Add((objectId, containerObjectId, amount, placement));
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
||||
}
|
||||
|
||||
public PluginItemCommandResult Merge(
|
||||
uint sourceObjectId,
|
||||
uint targetObjectId,
|
||||
uint amount = 0u)
|
||||
{
|
||||
Calls.Add(("merge", sourceObjectId, targetObjectId));
|
||||
Merges.Add((sourceObjectId, targetObjectId, amount));
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal owned item for a test.</summary>
|
||||
public static PluginInventoryItem Item(
|
||||
uint objectId,
|
||||
string name,
|
||||
uint equippedLocation = 0u)
|
||||
=> new(
|
||||
objectId, 0u, name, 0u, 0u, 0u, 0u, equippedLocation, 0u, 0u, 0u,
|
||||
1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0);
|
||||
}
|
||||
|
||||
public sealed class FakeLoot : ILootAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
public bool IsBusy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The shared appraisal slot's state (MEDIUM-2, P14 review) -- a test
|
||||
/// sets <c>AwaitingObjectId</c> to a DIFFERENT nonzero id than what it
|
||||
/// asked <see cref="AcDream.Plugin.Abstractions.IWorldObjectAutomation.Identify"/>
|
||||
/// for to simulate that request having been displaced by a later one.
|
||||
/// </summary>
|
||||
public PluginAppraisalState Appraisal { get; set; }
|
||||
|
||||
public List<PluginInventoryItem> Contents { get; } = [];
|
||||
|
||||
public List<uint> PickedUp { get; } = [];
|
||||
|
||||
public List<uint> Opened { get; } = [];
|
||||
|
||||
public List<uint> IdentifyRequests { get; } = [];
|
||||
|
||||
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
|
||||
|
||||
public IReadOnlyList<PluginInventoryItem> CaptureCurrentContents() => Contents;
|
||||
|
||||
public bool TryCaptureProperties(uint objectId, out PluginItemProperties properties)
|
||||
=> Properties.TryGetValue(objectId, out properties);
|
||||
|
||||
public PluginItemCommandResult Open(uint containerObjectId)
|
||||
{
|
||||
Opened.Add(containerObjectId);
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
||||
}
|
||||
|
||||
public PluginItemCommandResult Identify(uint objectId)
|
||||
{
|
||||
IdentifyRequests.Add(objectId);
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
||||
}
|
||||
|
||||
public PluginItemCommandResult Pickup(uint objectId, bool mainPack = false)
|
||||
{
|
||||
PickedUp.Add(objectId);
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeObjects : IWorldObjectAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
public uint OpenContainerObjectId { get; set; }
|
||||
|
||||
public List<PluginWorldObject> Objects { get; } = [];
|
||||
|
||||
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
|
||||
|
||||
public List<uint> IdentifyRequests { get; } = [];
|
||||
|
||||
/// <summary>What <see cref="Identify"/> returns by default for any object id not covered by <see cref="IdentifyResultOverrides"/>.</summary>
|
||||
public PluginItemCommandStatus IdentifyResult { get; set; } = PluginItemCommandStatus.Started;
|
||||
|
||||
/// <summary>Per-object-id override for <see cref="Identify"/>'s returned status, checked before <see cref="IdentifyResult"/>.</summary>
|
||||
public Dictionary<uint, PluginItemCommandStatus> IdentifyResultOverrides { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Per-object-id remaining count of consecutive <c>Busy</c> responses
|
||||
/// <see cref="Identify"/> should return before falling through to
|
||||
/// <see cref="IdentifyResultOverrides"/>/<see cref="IdentifyResult"/> --
|
||||
/// simulates the host's single-in-flight-request gate
|
||||
/// (<c>InventoryTransactionState.CanBeginRequest</c>) staying busy for a
|
||||
/// bounded number of retries. Checked before, and decremented ahead of,
|
||||
/// <see cref="IdentifyResultOverrides"/>.
|
||||
/// </summary>
|
||||
public Dictionary<uint, int> IdentifyBusyForCalls { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// How many times <see cref="CaptureObjects"/> has been called — used to
|
||||
/// prove a headless host's HUD updater (a UI-only presenter) never runs
|
||||
/// its 1 Hz full-landscape scan when <see cref="FakeHost.HasUi"/> is
|
||||
/// false.
|
||||
/// </summary>
|
||||
public int CaptureObjectsCalls { get; private set; }
|
||||
|
||||
public IReadOnlyList<PluginWorldObject> CaptureObjects()
|
||||
{
|
||||
CaptureObjectsCalls++;
|
||||
return Objects;
|
||||
}
|
||||
|
||||
public bool TryGet(uint objectId, out PluginWorldObject value)
|
||||
{
|
||||
foreach (PluginWorldObject candidate in Objects)
|
||||
{
|
||||
if (candidate.ObjectId != objectId)
|
||||
continue;
|
||||
value = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryCaptureProperties(uint objectId, out PluginItemProperties properties)
|
||||
=> Properties.TryGetValue(objectId, out properties);
|
||||
|
||||
public PluginItemCommandResult Identify(uint objectId)
|
||||
{
|
||||
IdentifyRequests.Add(objectId);
|
||||
|
||||
if (IdentifyBusyForCalls.TryGetValue(objectId, out int remaining) && remaining > 0)
|
||||
{
|
||||
IdentifyBusyForCalls[objectId] = remaining - 1;
|
||||
return new PluginItemCommandResult(PluginItemCommandStatus.Busy);
|
||||
}
|
||||
|
||||
PluginItemCommandStatus status = IdentifyResultOverrides.TryGetValue(objectId, out PluginItemCommandStatus overridden)
|
||||
? overridden
|
||||
: IdentifyResult;
|
||||
return new PluginItemCommandResult(status);
|
||||
}
|
||||
|
||||
/// <summary>Replaces a tracked object's snapshot (e.g. to flip <c>HasAppraisalData</c>).</summary>
|
||||
public void Replace(PluginWorldObject value)
|
||||
{
|
||||
for (int index = 0; index < Objects.Count; index++)
|
||||
{
|
||||
if (Objects[index].ObjectId != value.ObjectId)
|
||||
continue;
|
||||
Objects[index] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
Objects.Add(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a landscape object at <paramref name="metres"/> east of the
|
||||
/// origin the fake navigation reports.
|
||||
/// </summary>
|
||||
public static PluginWorldObject Landscape(
|
||||
uint objectId,
|
||||
string name,
|
||||
PluginObjectClass objectClass,
|
||||
double metres)
|
||||
=> new(objectId, 0u, name, objectClass, 0u, 0u, 0u)
|
||||
{
|
||||
IsLandscape = true,
|
||||
HasPosition = true,
|
||||
Position = new PluginNavigationPosition(
|
||||
1u, metres / 240d, 0d, 0d, 0f, true),
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class FakeNavigation : INavigationAutomation
|
||||
{
|
||||
public PluginNavigationSnapshot Snapshot { get; set; } =
|
||||
new(true, false, 1u, default, false, false);
|
||||
|
||||
public List<float> Headings { get; } = [];
|
||||
|
||||
/// <summary>Backs <see cref="CaptureObjects"/> for tests that need a nearby landscape object (e.g. a chest).</summary>
|
||||
public List<PluginNavigationObject> Objects { get; } = [];
|
||||
|
||||
public IReadOnlyList<PluginNavigationObject> CaptureObjects() => Objects;
|
||||
|
||||
public PluginNavigationCommandStatus FaceHeading(float headingDegrees)
|
||||
{
|
||||
Headings.Add(headingDegrees);
|
||||
return PluginNavigationCommandStatus.Accepted;
|
||||
}
|
||||
|
||||
public bool TryGetObject(uint objectId, out PluginNavigationObject value)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public PluginNavigationCommandStatus SetMovementIntent(
|
||||
in PluginMovementIntent intent)
|
||||
=> PluginNavigationCommandStatus.Accepted;
|
||||
|
||||
public PluginNavigationCommandStatus ClearMovementIntent()
|
||||
=> PluginNavigationCommandStatus.Accepted;
|
||||
}
|
||||
|
||||
public sealed class FakeFellowship : IFellowshipAutomation
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public bool IsInFellowship { get; set; }
|
||||
|
||||
/// <summary>What every command below answers -- a test sets this to simulate a server refusal.</summary>
|
||||
public PluginFellowshipCommandResult Result { get; set; } =
|
||||
new(PluginFellowshipCommandStatus.Accepted);
|
||||
|
||||
public PluginFellowshipCommandResult Create(string name, bool shareExperience)
|
||||
{
|
||||
Calls.Add("create:" + name + ":" + shareExperience);
|
||||
return Result;
|
||||
}
|
||||
|
||||
public PluginFellowshipCommandResult SetOpen(bool isOpen)
|
||||
{
|
||||
Calls.Add("setopen:" + isOpen);
|
||||
return Result;
|
||||
}
|
||||
|
||||
public PluginFellowshipCommandResult Quit(bool disband)
|
||||
{
|
||||
Calls.Add("quit:" + disband);
|
||||
return Result;
|
||||
}
|
||||
|
||||
public PluginFellowshipCommandResult Recruit(uint targetObjectId)
|
||||
{
|
||||
Calls.Add("recruit:" + targetObjectId);
|
||||
return Result;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeCombat : ICombatAutomation
|
||||
{
|
||||
public PluginCombatSnapshot Snapshot { get; set; } =
|
||||
new(0u, PluginCombatMode.Peace, PluginAttackHeight.Medium, 1f, 0f,
|
||||
false, false, false, false);
|
||||
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
/// <summary>Every BeginPhysicalAttack call, with the height/power it used.</summary>
|
||||
public List<(uint TargetObjectId, PluginAttackHeight Height, float Power)>
|
||||
BeginAttacks { get; } = [];
|
||||
|
||||
public PluginCombatCommandResult EnterDefaultMode()
|
||||
{
|
||||
Calls.Add("mode:default");
|
||||
return new PluginCombatCommandResult(
|
||||
PluginCombatCommandStatus.ModeChangeSent);
|
||||
}
|
||||
|
||||
public PluginCombatCommandResult EnterMode(PluginCombatMode mode)
|
||||
{
|
||||
Calls.Add("mode:" + mode);
|
||||
return new PluginCombatCommandResult(
|
||||
PluginCombatCommandStatus.ModeChangeSent);
|
||||
}
|
||||
|
||||
public PluginCombatCommandResult BeginPhysicalAttack(
|
||||
uint targetObjectId,
|
||||
PluginAttackHeight height,
|
||||
float power)
|
||||
{
|
||||
Calls.Add("attack:" + targetObjectId);
|
||||
BeginAttacks.Add((targetObjectId, height, power));
|
||||
return new PluginCombatCommandResult(PluginCombatCommandStatus.Started);
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginCombatTarget> CaptureHostileTargets(
|
||||
float maximumDistance) => [];
|
||||
|
||||
public PluginCombatCommandResult ReleasePhysicalAttack()
|
||||
{
|
||||
Calls.Add("release");
|
||||
return new PluginCombatCommandResult(PluginCombatCommandStatus.Released);
|
||||
}
|
||||
|
||||
public PluginCombatCommandResult AbortPhysicalAttack()
|
||||
{
|
||||
Calls.Add("abort");
|
||||
return new PluginCombatCommandResult(PluginCombatCommandStatus.Stopped);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeEquipment : IEquipmentAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
public List<uint> Equipped { get; } = [];
|
||||
|
||||
public PluginEquipmentCommandResult Equip(
|
||||
uint objectId,
|
||||
uint requestedLocation = 0u)
|
||||
{
|
||||
Equipped.Add(objectId);
|
||||
return new PluginEquipmentCommandResult(
|
||||
PluginEquipmentCommandStatus.Started);
|
||||
}
|
||||
}
|
||||
575
tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
Normal file
575
tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// The one fake host every test uses. Each surface is settable so a test names
|
||||
/// only the state it cares about.
|
||||
/// </summary>
|
||||
public sealed class FakeHost : IPluginHost
|
||||
{
|
||||
public bool HasUi { get; set; }
|
||||
|
||||
public IPluginLogger Log { get; } = new RecordingLogger();
|
||||
|
||||
public IGameState State { get; } = new FakeGameState();
|
||||
|
||||
public FakeEvents Events { get; } = new();
|
||||
|
||||
public FakeSelection Selection { get; } = new();
|
||||
|
||||
public FakeUiRegistry Ui { get; } = new();
|
||||
|
||||
public FakeCommandRegistry Commands { get; } = new();
|
||||
|
||||
public MemoryStorage Storage { get; } = new();
|
||||
|
||||
public FakeAutomation Automation { get; } = new();
|
||||
|
||||
public FakeLootClassifierRegistry LootClassifiers { get; } = new();
|
||||
|
||||
public FakeClipboard Clipboard { get; } = new();
|
||||
|
||||
public FakeHotkeyRegistry Hotkeys { get; } = new();
|
||||
|
||||
public FakeHostWindow Window { get; } = new();
|
||||
|
||||
IEvents IPluginHost.Events => Events;
|
||||
|
||||
ISelectionService IPluginHost.Selection => Selection;
|
||||
|
||||
IUiRegistry IPluginHost.Ui => Ui;
|
||||
|
||||
IPluginCommandRegistry IPluginHost.Commands => Commands;
|
||||
|
||||
IPluginStorage IPluginHost.Storage => Storage;
|
||||
|
||||
IAutomationSurface IPluginHost.Automation => Automation;
|
||||
|
||||
IPluginLootClassifierRegistry IPluginHost.LootClassifiers => LootClassifiers;
|
||||
|
||||
IPluginClipboard IPluginHost.Clipboard => Clipboard;
|
||||
|
||||
IHotkeyRegistry IPluginHost.Hotkeys => Hotkeys;
|
||||
|
||||
IHostWindow IPluginHost.Window => Window;
|
||||
|
||||
/// <summary>The chat lines the plugin posted, in order.</summary>
|
||||
public IReadOnlyList<string> ChatLines => Automation.Chat.Posted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A settable <see cref="IHostWindow"/>: records every call, defaults to
|
||||
/// <see cref="HostWindowStatus.Done"/> like a graphical host with a
|
||||
/// responsive window, and a test can flip <see cref="MinimizeResult"/>/
|
||||
/// <see cref="RestoreResult"/>/<see cref="RequestCloseResult"/> to simulate a
|
||||
/// refusal (a headless host, or an unconfirmed platform outcome such as
|
||||
/// Wayland's always-Unavailable Minimize).
|
||||
/// </summary>
|
||||
public sealed class FakeHostWindow : IHostWindow
|
||||
{
|
||||
public int MinimizeCalls { get; private set; }
|
||||
|
||||
public int RestoreCalls { get; private set; }
|
||||
|
||||
public int RequestCloseCalls { get; private set; }
|
||||
|
||||
public bool IsMinimized { get; set; }
|
||||
|
||||
public HostWindowResult MinimizeResult { get; set; } = new(HostWindowStatus.Done);
|
||||
|
||||
public HostWindowResult RestoreResult { get; set; } = new(HostWindowStatus.Done);
|
||||
|
||||
public HostWindowResult RequestCloseResult { get; set; } = new(HostWindowStatus.Done);
|
||||
|
||||
public HostWindowResult Minimize()
|
||||
{
|
||||
MinimizeCalls++;
|
||||
if (MinimizeResult.Succeeded)
|
||||
IsMinimized = true;
|
||||
return MinimizeResult;
|
||||
}
|
||||
|
||||
public HostWindowResult Restore()
|
||||
{
|
||||
RestoreCalls++;
|
||||
if (RestoreResult.Succeeded)
|
||||
IsMinimized = false;
|
||||
return RestoreResult;
|
||||
}
|
||||
|
||||
public HostWindowResult RequestClose()
|
||||
{
|
||||
RequestCloseCalls++;
|
||||
return RequestCloseResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A settable <see cref="IPluginClipboard"/>: records every write, can fail on demand.</summary>
|
||||
public sealed class FakeClipboard : IPluginClipboard
|
||||
{
|
||||
public List<string> Written { get; } = [];
|
||||
|
||||
public bool Available { get; set; } = true;
|
||||
|
||||
public bool TrySetText(string text)
|
||||
{
|
||||
if (!Available)
|
||||
return false;
|
||||
Written.Add(text);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A settable <see cref="IPluginLootClassifierRegistry"/>: one test-controlled
|
||||
/// classifier answers <see cref="TryClassify"/>/<see cref="TryNeedsIdentification"/>/
|
||||
/// <see cref="TryClassifyWithProfile"/>, or the registry reports no classifier
|
||||
/// registered at all (the "VTank isn't loaded" degradation path) when
|
||||
/// <see cref="Available"/> is left empty.
|
||||
/// </summary>
|
||||
public sealed class FakeLootClassifierRegistry : IPluginLootClassifierRegistry
|
||||
{
|
||||
public List<PluginLootClassifierInfo> Available { get; } = [];
|
||||
|
||||
/// <summary>What <see cref="TryClassify"/> returns for any classifier id.</summary>
|
||||
public PluginLootClassification? ClassificationResult { get; set; }
|
||||
|
||||
/// <summary>What <see cref="TryNeedsIdentification"/> returns for any classifier id.</summary>
|
||||
public bool NeedsIdentificationResult { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, overrides <see cref="NeedsIdentificationResult"/> with a
|
||||
/// per-context verdict -- for a test where only SOME items in a
|
||||
/// container still need identification.
|
||||
/// </summary>
|
||||
public Func<PluginLootClassificationContext, bool>? NeedsIdentificationHandler { get; set; }
|
||||
|
||||
/// <summary>What <see cref="TryClassifyWithProfile"/> returns for any classifier id.</summary>
|
||||
public PluginLootClassification? ProfileClassificationResult { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, overrides <see cref="ClassificationResult"/> with a
|
||||
/// per-context verdict — for a test that needs different items to
|
||||
/// classify differently. Returning null from the function means "no
|
||||
/// match" (mirrors the live registry's <c>false</c> return).
|
||||
/// </summary>
|
||||
public Func<PluginLootClassificationContext, PluginLootClassification?>? ClassifyHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, overrides <see cref="ProfileClassificationResult"/> with a
|
||||
/// per-profile, per-context verdict. Returning null means "no profile,
|
||||
/// or no match" (both collapse to the registry's <c>false</c>).
|
||||
/// </summary>
|
||||
public Func<string, PluginLootClassificationContext, PluginLootClassification?>? ProfileClassifyHandler { get; set; }
|
||||
|
||||
/// <summary>Every profile name ever probed/classified via <see cref="TryClassifyWithProfile"/>.</summary>
|
||||
public List<string> ProfileNamesSeen { get; } = [];
|
||||
|
||||
public List<string> ClassifyCalls { get; } = [];
|
||||
|
||||
IReadOnlyList<PluginLootClassifierInfo> IPluginLootClassifierRegistry.Available => Available;
|
||||
|
||||
public bool TryClassify(
|
||||
string classifierId,
|
||||
in PluginLootClassificationContext context,
|
||||
out PluginLootClassification classification)
|
||||
{
|
||||
ClassifyCalls.Add(classifierId);
|
||||
|
||||
if (ClassifyHandler is { } handler)
|
||||
{
|
||||
PluginLootClassification? result = handler(context);
|
||||
if (result is { } found)
|
||||
{
|
||||
classification = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
classification = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ClassificationResult is { } fixedResult)
|
||||
{
|
||||
classification = fixedResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
classification = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryNeedsIdentification(
|
||||
string classifierId,
|
||||
in PluginLootClassificationContext context)
|
||||
=> NeedsIdentificationHandler is { } handler ? handler(context) : NeedsIdentificationResult;
|
||||
|
||||
public bool TryClassifyWithProfile(
|
||||
string classifierId,
|
||||
string profileName,
|
||||
in PluginLootClassificationContext context,
|
||||
out PluginLootClassification classification)
|
||||
{
|
||||
ProfileNamesSeen.Add(profileName);
|
||||
|
||||
if (ProfileClassifyHandler is { } handler)
|
||||
{
|
||||
PluginLootClassification? result = handler(profileName, context);
|
||||
if (result is { } found)
|
||||
{
|
||||
classification = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
classification = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ProfileClassificationResult is { } fixedResult)
|
||||
{
|
||||
classification = fixedResult;
|
||||
return true;
|
||||
}
|
||||
|
||||
classification = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every registration and lets a test fire the handler or flip <see cref="IPluginHotkeyRegistration.IsBound"/>.</summary>
|
||||
public sealed class FakeHotkeyRegistry : IHotkeyRegistry
|
||||
{
|
||||
public List<(string Id, string DisplayName, PluginKeyChord DefaultChord, Action Handler)> Registrations { get; } = [];
|
||||
|
||||
/// <summary>The most recently returned handle for each id, so a test can assert on its Disposed/IsBound state.</summary>
|
||||
public Dictionary<string, FakeHotkeyRegistration> Handles { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public IPluginHotkeyRegistration Register(
|
||||
string id,
|
||||
string displayName,
|
||||
PluginKeyChord defaultChord,
|
||||
Action handler)
|
||||
{
|
||||
Registrations.Add((id, displayName, defaultChord, handler));
|
||||
var registration = new FakeHotkeyRegistration(defaultChord);
|
||||
Handles[id] = registration;
|
||||
return registration;
|
||||
}
|
||||
|
||||
/// <summary>Invokes the handler registered under <paramref name="id"/>, as if the chord had just fired.</summary>
|
||||
public void Fire(string id)
|
||||
{
|
||||
foreach ((string registeredId, _, _, Action handler) in Registrations)
|
||||
{
|
||||
if (string.Equals(registeredId, id, StringComparison.Ordinal))
|
||||
{
|
||||
handler();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeHotkeyRegistration(PluginKeyChord chord) : IPluginHotkeyRegistration
|
||||
{
|
||||
public bool IsBound { get; set; } = true;
|
||||
public PluginKeyChord EffectiveChord { get; private set; } = chord;
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public void Rebind(PluginKeyChord newChord) => EffectiveChord = newChord;
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
public sealed class RecordingLogger : IPluginLogger
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public void Info(string message) => Messages.Add("info: " + message);
|
||||
|
||||
public void Warn(string message) => Messages.Add("warn: " + message);
|
||||
|
||||
public void Error(string message, Exception? exception = null)
|
||||
=> Messages.Add("error: " + message);
|
||||
}
|
||||
|
||||
public sealed class FakeGameState : IGameState
|
||||
{
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Drives <see cref="IEvents"/> from a test.</summary>
|
||||
public sealed class FakeEvents : IEvents
|
||||
{
|
||||
public event Action<WorldEntitySnapshot>? EntitySpawned;
|
||||
|
||||
public event Action<double>? Tick;
|
||||
|
||||
public event Action? LoginComplete;
|
||||
|
||||
public event Action? Logoff;
|
||||
|
||||
public event Action<string>? LocalPlayerDied;
|
||||
|
||||
public event Action<PluginObjectChange>? ObjectChanged;
|
||||
|
||||
public event Action<uint>? ContainerOpened;
|
||||
|
||||
public event Action<uint>? ContainerClosed;
|
||||
|
||||
public event Action<PluginConfirmation>? ConfirmationRequested;
|
||||
|
||||
public int TickSubscriberCount => Tick?.GetInvocationList().Length ?? 0;
|
||||
|
||||
public int LoginCompleteSubscriberCount =>
|
||||
LoginComplete?.GetInvocationList().Length ?? 0;
|
||||
|
||||
public int LogoffSubscriberCount => Logoff?.GetInvocationList().Length ?? 0;
|
||||
|
||||
public void RaiseTick(double elapsedSeconds) => Tick?.Invoke(elapsedSeconds);
|
||||
|
||||
public void RaiseEntitySpawned(WorldEntitySnapshot snapshot)
|
||||
=> EntitySpawned?.Invoke(snapshot);
|
||||
|
||||
public void RaiseLoginComplete() => LoginComplete?.Invoke();
|
||||
|
||||
public void RaiseLogoff() => Logoff?.Invoke();
|
||||
|
||||
public void RaiseLocalPlayerDied(string deathMessage)
|
||||
=> LocalPlayerDied?.Invoke(deathMessage);
|
||||
|
||||
public void RaiseObjectChanged(PluginObjectChange change) => ObjectChanged?.Invoke(change);
|
||||
|
||||
public void RaiseObjectChanged(uint objectId, PluginObjectChangeKind kind)
|
||||
=> ObjectChanged?.Invoke(new PluginObjectChange(objectId, kind));
|
||||
|
||||
public void RaiseContainerOpened(uint containerObjectId) => ContainerOpened?.Invoke(containerObjectId);
|
||||
|
||||
public void RaiseContainerClosed(uint containerObjectId) => ContainerClosed?.Invoke(containerObjectId);
|
||||
|
||||
public void RaiseConfirmationRequested(PluginConfirmation confirmation)
|
||||
=> ConfirmationRequested?.Invoke(confirmation);
|
||||
|
||||
event Action<PluginConfirmation> IEvents.ConfirmationRequested
|
||||
{
|
||||
add => ConfirmationRequested += value;
|
||||
remove => ConfirmationRequested -= value;
|
||||
}
|
||||
|
||||
event Action<PluginObjectChange> IEvents.ObjectChanged
|
||||
{
|
||||
add => ObjectChanged += value;
|
||||
remove => ObjectChanged -= value;
|
||||
}
|
||||
|
||||
event Action<uint> IEvents.ContainerOpened
|
||||
{
|
||||
add => ContainerOpened += value;
|
||||
remove => ContainerOpened -= value;
|
||||
}
|
||||
|
||||
event Action<uint> IEvents.ContainerClosed
|
||||
{
|
||||
add => ContainerClosed += value;
|
||||
remove => ContainerClosed -= value;
|
||||
}
|
||||
|
||||
event Action<WorldEntitySnapshot> IEvents.EntitySpawned
|
||||
{
|
||||
add => EntitySpawned += value;
|
||||
remove => EntitySpawned -= value;
|
||||
}
|
||||
|
||||
event Action<double> IEvents.Tick
|
||||
{
|
||||
add => Tick += value;
|
||||
remove => Tick -= value;
|
||||
}
|
||||
|
||||
event Action IEvents.LoginComplete
|
||||
{
|
||||
add => LoginComplete += value;
|
||||
remove => LoginComplete -= value;
|
||||
}
|
||||
|
||||
event Action IEvents.Logoff
|
||||
{
|
||||
add => Logoff += value;
|
||||
remove => Logoff -= value;
|
||||
}
|
||||
|
||||
event Action<string> IEvents.LocalPlayerDied
|
||||
{
|
||||
add => LocalPlayerDied += value;
|
||||
remove => LocalPlayerDied -= value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeSelection : ISelectionService
|
||||
{
|
||||
public uint? SelectedObjectId { get; private set; }
|
||||
|
||||
public uint? PreviousObjectId { get; private set; }
|
||||
|
||||
public event Action<SelectionChangedEvent>? Changed;
|
||||
|
||||
public bool Select(uint objectId)
|
||||
{
|
||||
PreviousObjectId = SelectedObjectId;
|
||||
SelectedObjectId = objectId;
|
||||
Changed?.Invoke(new SelectionChangedEvent(PreviousObjectId, objectId));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Clear()
|
||||
{
|
||||
PreviousObjectId = SelectedObjectId;
|
||||
SelectedObjectId = null;
|
||||
Changed?.Invoke(new SelectionChangedEvent(PreviousObjectId, null));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RegisteredPanel(
|
||||
PluginPanelDescriptor Descriptor,
|
||||
string MarkupPath,
|
||||
object Binding);
|
||||
|
||||
public sealed class FakeUiRegistry : IUiRegistry
|
||||
{
|
||||
public List<RegisteredPanel> Panels { get; } = [];
|
||||
|
||||
/// <summary>Every <see cref="ShowClientWindow"/> call, in order -- a test sets <see cref="ShowClientWindowResult"/> to simulate the host refusing it.</summary>
|
||||
public List<PluginClientWindow> ShowClientWindowCalls { get; } = [];
|
||||
|
||||
public bool ShowClientWindowResult { get; set; } = true;
|
||||
|
||||
public bool ShowClientWindow(PluginClientWindow window)
|
||||
{
|
||||
ShowClientWindowCalls.Add(window);
|
||||
return ShowClientWindowResult;
|
||||
}
|
||||
|
||||
public List<PluginClientWindow> HideClientWindowCalls { get; } = [];
|
||||
|
||||
public bool HideClientWindow(PluginClientWindow window)
|
||||
{
|
||||
HideClientWindowCalls.Add(window);
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<PluginClientWindow> ToggleClientWindowCalls { get; } = [];
|
||||
|
||||
public bool ToggleClientWindowResult { get; set; } = true;
|
||||
|
||||
public bool ToggleClientWindow(PluginClientWindow window)
|
||||
{
|
||||
ToggleClientWindowCalls.Add(window);
|
||||
return ToggleClientWindowResult;
|
||||
}
|
||||
|
||||
public HashSet<PluginClientWindow> VisibleClientWindows { get; } = [];
|
||||
|
||||
public bool IsClientWindowVisible(PluginClientWindow window)
|
||||
=> VisibleClientWindows.Contains(window);
|
||||
|
||||
public void AddMarkupPanel(string markupPath, object binding)
|
||||
=> Panels.Add(new RegisteredPanel(
|
||||
new PluginPanelDescriptor(
|
||||
Path.GetFileNameWithoutExtension(markupPath),
|
||||
"Mag-Tools"),
|
||||
markupPath,
|
||||
binding));
|
||||
|
||||
public void AddPanel(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding)
|
||||
=> Panels.Add(new RegisteredPanel(descriptor, markupPath, binding));
|
||||
|
||||
public IDisposable RegisterPanel(
|
||||
PluginPanelDescriptor descriptor,
|
||||
string markupPath,
|
||||
object binding)
|
||||
{
|
||||
var panel = new RegisteredPanel(descriptor, markupPath, binding);
|
||||
Panels.Add(panel);
|
||||
return new PanelRegistration(this, panel);
|
||||
}
|
||||
|
||||
private sealed class PanelRegistration(FakeUiRegistry owner, RegisteredPanel panel)
|
||||
: IDisposable
|
||||
{
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
owner.Panels.Remove(panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeCommandRegistry : IPluginCommandRegistry
|
||||
{
|
||||
public Dictionary<string, Action<PluginCommand>> Handlers { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IDisposable Register(string verb, Action<PluginCommand> handler)
|
||||
{
|
||||
Handlers[verb] = handler;
|
||||
return new Registration(this, verb);
|
||||
}
|
||||
|
||||
private sealed class Registration(FakeCommandRegistry owner, string verb)
|
||||
: IDisposable
|
||||
{
|
||||
public void Dispose() => owner.Handlers.Remove(verb);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory <see cref="IPluginStorage"/>.</summary>
|
||||
public sealed class MemoryStorage : IPluginStorage
|
||||
{
|
||||
private readonly Dictionary<string, string> _files =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
public string? RootPath { get; set; } = "memory://storage";
|
||||
|
||||
public int WriteCount { get; private set; }
|
||||
|
||||
/// <summary>Makes the next (and every subsequent) write throw.</summary>
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The message <see cref="WriteText"/> throws with while
|
||||
/// <see cref="ThrowOnWrite"/> is set. Change it between calls to make
|
||||
/// consecutive throws distinct EXCEPTION SHAPES for a test — the default
|
||||
/// keeps every throw identical, for a test pinning same-shape dedup.
|
||||
/// </summary>
|
||||
public string ThrowMessage { get; set; } = "Storage write failed (test fault).";
|
||||
|
||||
public string? ReadText(string key)
|
||||
=> _files.TryGetValue(key, out string? value) ? value : null;
|
||||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
=> [.. _files.Keys.Where(key =>
|
||||
key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))];
|
||||
|
||||
public void WriteText(string key, string content)
|
||||
{
|
||||
if (ThrowOnWrite)
|
||||
throw new InvalidOperationException(ThrowMessage);
|
||||
|
||||
_files[key] = content;
|
||||
WriteCount++;
|
||||
}
|
||||
|
||||
public bool Delete(string key) => _files.Remove(key);
|
||||
|
||||
/// <summary>Seeds a file as if it had been copied in by hand.</summary>
|
||||
public void Seed(string key, string content) => _files[key] = content;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue