openac-mosswartmassacre/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
Erik ec22348ebc Radar: nearby_objects every second and dungeon_map per dungeon landblock
start_radar/stop_radar from the backend drive it, as before. nearby_objects
keeps the original's shape: map coordinates on the surface, landblock-local
raw positions with zero map coordinates in a dungeon, ids as signed ints, the
same eight object classes, no wielded items or self. dungeon_map is built the
original's way from the landblock's cells (x a multiple of 10, whole metres,
6-metre floors, rotation = the orientation's stored W) and sent once per
landblock per session; the cells come through a small reader that the
plugin API's dungeon-cell data will fill (until then the map is not sent).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:28:07 +02:00

944 lines
31 KiB
C#

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();
public FakeNetwork Network { get; } = new();
public FakeDungeonMap DungeonMap { 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;
INetworkAutomation IAutomationSurface.Network => Network;
IDungeonMapAutomation IAutomationSurface.DungeonMap => DungeonMap;
}
/// <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 PluginCastCompletion LastCompletion { 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);
}
}