using AcDream.Plugin.Abstractions; namespace OpenAC.MosswartMassacre.Tests.Fakes; /// /// A settable automation surface. Every child defaults to an empty fake, so a /// test fills in only the state it exercises. /// 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; } /// A settable : a test drives and inspects . 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 MyItems { get; } = []; public List PartnerItems { get; } = []; public bool MyAccepted { get; set; } public bool PartnerAccepted { get; set; } public List Calls { get; } = []; /// The status //// answer with. public PluginTradeCommandStatus NextStatus { get; set; } = PluginTradeCommandStatus.Sent; IReadOnlyList ITradeAutomation.MyItems => MyItems; IReadOnlyList ITradeAutomation.PartnerItems => PartnerItems; public event Action? Opened; public event Action? Closed; public event Action? PartnerTradeAccepted; public event Action? 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 ITradeAutomation.Opened { add => Opened += value; remove => Opened -= value; } event Action ITradeAutomation.Closed { add => Closed += value; remove => Closed -= value; } event Action ITradeAutomation.PartnerTradeAccepted { add => PartnerTradeAccepted += value; remove => PartnerTradeAccepted -= value; } event Action ITradeAutomation.ItemAdded { add => ItemAdded += value; remove => ItemAdded -= value; } } /// A settable . 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 Items { get; } = []; public bool IsBusy { get; set; } public Dictionary Properties { get; } = []; public List<(uint TemplateObjectId, int Count)> BuyList { get; } = []; public List SellList { get; } = []; public List Calls { get; } = []; public PluginVendorCommandStatus NextStatus { get; set; } = PluginVendorCommandStatus.Sent; IReadOnlyList IVendorAutomation.Items => Items; IReadOnlyList<(uint TemplateObjectId, int Count)> IVendorAutomation.BuyList => BuyList; IReadOnlyList IVendorAutomation.SellList => SellList; public event Action? Opened; public event Action? Closed; public event Action? 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 IVendorAutomation.Opened { add => Opened += value; remove => Opened -= value; } event Action IVendorAutomation.Closed { add => Closed += value; remove => Closed -= value; } event Action IVendorAutomation.TransactionCompleted { add => TransactionCompleted += value; remove => TransactionCompleted -= value; } } /// A settable . 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; } } /// Records every call; a test can pre-arm which context ids it accepts. public sealed class FakeDialogAutomation : IDialogAutomation { public List<(uint ContextId, bool Accept)> Answers { get; } = []; /// Context ids reports as outstanding (returns true for). public HashSet 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 Skills { get; } = []; public IReadOnlyList Attributes { get; set; } = []; public IReadOnlyList ActiveEnchantments { get; set; } = []; IReadOnlyList 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; } } /// /// Records every posted line and every submitted command, and can raise /// and drive registered filters the way the real host /// does — before a line is added to or delivered to /// , every registered filter gets a chance to eat it. /// public sealed class RecordingChat : IPluginChat { private readonly List> _filters = []; private ulong _nextSequence = 1; public List Posted { get; } = []; public List Submitted { get; } = []; public List<(string Text, int LogTextType)> PostedTyped { get; } = []; /// Every message offered to , filtered or not. public List Offered { get; } = []; /// Messages that survived every filter and reached . public List Delivered { get; } = []; public IReadOnlyList Messages { get; set; } = []; public IReadOnlyList CaptureMessages(ulong afterSequence) => [.. Messages.Where(message => message.Sequence > afterSequence)]; public event Action? Received; /// /// How many distinct handlers are currently subscribed to /// — used to prove a shared fan-out /// (ChatClassificationDispatcher) adds exactly one subscription /// to the raw feed no matter how many of ITS OWN consumers subscribe to /// it in turn. /// public int ReceivedSubscriberCount => Received?.GetInvocationList().Length ?? 0; public IDisposable RegisterFilter(Func suppress) { _filters.Add(suppress); return new FilterRegistration(this, suppress); } /// Builds and offers a message the way the real client would, honoring filters. 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 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 filter) : IDisposable { public void Dispose() => owner._filters.Remove(filter); } } public sealed class FakeSpellCatalog : ISpellCatalog { public IReadOnlyList KnownSelfBuffs { get; set; } = []; public IReadOnlyList KnownAttackSpells { get; set; } = []; public IReadOnlyList KnownCombatSpells { get; set; } = []; /// The full spell table, independent of what the character knows. public IReadOnlyList 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; } /// Builds a minimal spell record for a test. 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; } = []; /// What / answer -- a test sets this to simulate a refusal that isn't a plain Cast()==false. 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; } /// What returns -- a test sets this to simulate the host rejecting the command. public PluginItemCommandResult UseResult { get; set; } = new(PluginItemCommandStatus.Started); public List Owned { get; } = []; public List<(string Command, uint ObjectId, uint TargetObjectId)> Calls { get; } = []; /// Keyed by object id; set by a test to make answer. public Dictionary Properties { get; } = []; /// /// How many times has been called — used /// to prove a coalescing host (H1/H3) captures at most once per tick no /// matter how many ObjectChanged events arrived inside it. /// public int CaptureCount { get; private set; } /// Same counter as under the name the HUD-gate tests use. public int CaptureOwnedItemsCalls => CaptureCount; public IReadOnlyList 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; } /// What returns -- a test sets this to simulate the host rejecting the command. 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); } /// Every call, in order. public List<(uint ObjectId, uint ContainerObjectId, uint Amount, int Placement)> Moves { get; } = []; /// Every call, in order. 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); } /// Builds a minimal owned item for a test. 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; } /// /// The shared appraisal slot's state (MEDIUM-2, P14 review) -- a test /// sets AwaitingObjectId to a DIFFERENT nonzero id than what it /// asked /// for to simulate that request having been displaced by a later one. /// public PluginAppraisalState Appraisal { get; set; } public List Contents { get; } = []; public List PickedUp { get; } = []; public List Opened { get; } = []; public List IdentifyRequests { get; } = []; public Dictionary Properties { get; } = []; public IReadOnlyList 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 Objects { get; } = []; public Dictionary Properties { get; } = []; public List IdentifyRequests { get; } = []; /// What returns by default for any object id not covered by . public PluginItemCommandStatus IdentifyResult { get; set; } = PluginItemCommandStatus.Started; /// Per-object-id override for 's returned status, checked before . public Dictionary IdentifyResultOverrides { get; } = []; /// /// Per-object-id remaining count of consecutive Busy responses /// should return before falling through to /// / -- /// simulates the host's single-in-flight-request gate /// (InventoryTransactionState.CanBeginRequest) staying busy for a /// bounded number of retries. Checked before, and decremented ahead of, /// . /// public Dictionary IdentifyBusyForCalls { get; } = []; /// /// How many times 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 is /// false. /// public int CaptureObjectsCalls { get; private set; } public IReadOnlyList 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); } /// Replaces a tracked object's snapshot (e.g. to flip HasAppraisalData). 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); } /// /// Builds a landscape object at east of the /// origin the fake navigation reports. /// 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 Headings { get; } = []; /// Backs for tests that need a nearby landscape object (e.g. a chest). public List Objects { get; } = []; public IReadOnlyList 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 Calls { get; } = []; public bool IsInFellowship { get; set; } /// What every command below answers -- a test sets this to simulate a server refusal. 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 Calls { get; } = []; /// Every BeginPhysicalAttack call, with the height/power it used. 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 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 Equipped { get; } = []; public PluginEquipmentCommandResult Equip( uint objectId, uint requestedLocation = 0u) { Equipped.Add(objectId); return new PluginEquipmentCommandResult( PluginEquipmentCommandStatus.Started); } }