using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank.Tests; public sealed class LootingTests { private const uint Player = 0x50000001u; private static readonly PluginItemProperties EmptyProperties = new( new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary()); [Theory] [InlineData("Killed by Tester.", "Tester")] [InlineData("Killed by Moss Wart's War Magic.", "Moss Wart's War Magic")] [InlineData("Generated treasure", "")] public void CorpseKillerParsingMatchesVtankLongDescription( string description, string expected) => Assert.Equal(expected, LootController.KillerName(description)); [Theory] [InlineData("name ~= Pyreal && value >= 100", true)] [InlineData("name == 'Heavy Pyreal' || wcid == 55", true)] [InlineData("workmanship > 8 && material == 12", true)] [InlineData("int[105] == 7", true)] [InlineData("stack > 10", false)] public void LootExpressionMatchesProjectedAndRawProperties( string source, bool expected) { PluginInventoryItem item = Item(10, "Heavy Pyreal", 44) with { Value = 100, Workmanship = 9.5f, MaterialType = 12, StackSize = 5, }; PluginItemProperties properties = EmptyProperties with { Ints = new Dictionary { [105] = 7 }, }; Assert.Equal( expected, LootRuleExpression.Compile(source).IsMatch(item, properties)); } [Fact] public void RuleEngineUsesFirstMatchAndHonorsKeepUpToCounts() { PluginInventoryItem corpseItem = Item(10, "Health Elixir", 44) with { StackSize = 2, }; PluginInventoryItem held = Item(20, "Health Elixir", 44) with { StackSize = 8, }; LootRule[] rules = [ new() { Name = "Elixirs", Expression = "name ~= elixir", Action = LootAction.KeepUpTo, KeepCount = 10, }, new() { Name = "All", Expression = "*", Action = LootAction.Keep }, ]; Assert.Null(LootRuleEngine.Decide( corpseItem, EmptyProperties, rules, [held], new Dictionary { ["Health Elixir"] = 2 })); LootDecision decision = Assert.IsType( LootRuleEngine.Decide( corpseItem, EmptyProperties, rules, [], null)); Assert.Equal(LootAction.KeepUpTo, decision.Action); Assert.Equal("Elixirs", decision.RuleName); } [Fact] public void ImportedVtankRequirementsExecuteAsExactAndSet() { PluginInventoryItem item = Item(10u, "Epic Sword", 44u) with { Value = 25_000, ObjectClass = PluginObjectClass.MeleeWeapon, AppraisedSpellIds = [777u], Palettes = [ new PluginPaletteInfo(0x0400ABCDu, 0, 1, 255, 0, 0), ], GearDamage = 3, GearCriticalChance = 2, }; var automation = new Automation { CharacterLevel = 275, MainPackSlots = 101, KnownSpell = new PluginSpellInfo( 777u, "Legendary Blood Thirst", 1u, 1, 1, 1, 1f, 32u, string.Empty, false, true), SkillsValue = [ new PluginSkillInfo( 44u, "Heavy Weapons", PluginSkillTraining.Specialized, 500u) { Base = 450u, }, ], }; var rule = new LootRule { Name = "VTClassic", Action = LootAction.Keep, VtankRequirements = [ Requirement(1, "^Epic.*", "1"), Requirement(3, "25000", "19"), Requirement(7, "1"), Requirement(9, "Legendary.*", string.Empty, "1"), Requirement(14, "255", "0", "0", "0", "0"), Requirement( 15, "255", "0", "0", "0", "0", "Amuli Coat (Chest)"), Requirement(16, "255", "0", "0", "0", "0", "0"), Requirement(17, "0", "43981"), Requirement(1000, "500", "44"), Requirement(1001, "101"), Requirement(1002, "275"), Requirement(1004, "44", "400", "460"), Requirement(2007, "5"), Requirement(9999, "false"), ], }; Assert.NotNull(LootRuleEngine.Decide( item, EmptyProperties, [rule], [], host: new Host(automation))); rule.VtankRequirements[1].Payload = "25001\r\n19\r\n"; Assert.Null(LootRuleEngine.Decide( item, EmptyProperties, [rule], [], host: new Host(automation))); } [Fact] public void ManaStonePlannerUsesStoneOnQualifiedHighestManaTank() { PluginInventoryItem stone = Item(1u, "Mana Stone", 1u) with { ItemType = 0x00080000u, }; PluginInventoryItem lowTank = Item(2u, "Low Tank", 2u) with { ItemCurrentMana = 1200, Value = 1, }; PluginInventoryItem highTank = Item(3u, "High Tank", 3u) with { ItemCurrentMana = 3000, Value = 1, }; var classified = new Dictionary { [stone.ObjectId] = LootAction.ManaStone, [lowTank.ObjectId] = LootAction.ManaTank, [highTank.ObjectId] = LootAction.ManaTank, }; ManaStoneTransferPlan plan = Assert.IsType( ManaStoneTransferPlanner.Plan( [stone, lowTank, highTank], classified, minimumTankMana: 1000)); Assert.Equal(stone.ObjectId, plan.StoneObjectId); Assert.Equal(highTank.ObjectId, plan.TankObjectId); } [Theory] [InlineData(6.9f, 6.0f, true)] [InlineData(6.9f, 7.0f, false)] [InlineData(7.0f, 8.99f, true)] [InlineData(8.99f, 9.0f, false)] [InlineData(9.0f, 9.99f, true)] [InlineData(9.99f, 10.0f, false)] [InlineData(10.0f, 10.0f, true)] public void SalvageCombineUsesVtanksExactWorkmanshipBands( float left, float right, bool expected) => Assert.Equal( expected, SalvageBagCombinePlanner.SameVtankWorkmanshipBand(left, right)); [Fact] public void ImportedSalvageRangesAndValueModeDriveThePlanner() { Assert.False(SalvageBagCombinePlanner.SameCombineBand( 5f, 6f, "1-5, 6-10")); var settings = new VtankSalvageCombineSettings { DefaultCombineString = "1-5, 6-10", MaterialCombineStrings = [], MaterialValueModeValues = new Dictionary { [12] = 100, }, }; PluginInventoryItem[] bags = [ Item(1u, "Salvaged Iron (4)", 1u) with { MaterialType = 12u, Workmanship = 4f, Value = 60, Structure = 30, }, Item(2u, "Salvaged Iron (5)", 1u) with { MaterialType = 12u, Workmanship = 5f, Value = 50, Structure = 30, }, Item(3u, "Salvaged Iron (6)", 1u) with { MaterialType = 12u, Workmanship = 6f, Value = 500, Structure = 30, }, ]; SalvageBagCombinePlan plan = Assert.IsType( SalvageBagCombinePlanner.Plan(bags, settings: settings)); Assert.Equal(new uint[] { 1u, 2u }, plan.ObjectIds); } [Fact] public void CombineSalvageRunsRealTwoBagSalvageOperation() { var settings = new LootSettings { Enabled = true, CombineSalvage = true, }; settings.Rules.Add(new LootRule { Expression = "*" }); const uint tool = 0x50000600u; const uint first = 0x50000601u; const uint second = 0x50000602u; var automation = new Automation { Owned = [ Item(tool, "Salvage Tool", 1u) with { ItemType = 0x20000000u, }, Item(first, "Salvaged Iron (7)", 2u) with { MaterialType = 12u, Workmanship = 7.1f, }, Item(second, "Salvaged Iron (8)", 2u) with { MaterialType = 12u, Workmanship = 8.5f, }, ], }; var controller = new LootController(new Host(automation), settings); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal(new[] { (tool, first), (tool, second) }, automation.Salvaged); automation.Owned = [automation.Owned[0], automation.Owned[1]]; Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal("Combined salvage bags.", controller.Status); } [Fact] public void ControllerOpensClassifiesAndPicksThroughCanonicalLootSurface() { var settings = new LootSettings { Enabled = true }; settings.Rules.Add(new LootRule { Name = "Coins", Expression = "name ~= coin", Action = LootAction.Keep, Priority = 3, }); var automation = new Automation(); var controller = new LootController(new Host(automation), settings); const uint corpse = 0x70000001u; const uint coin = 0x70000002u; automation.Corpses = [ new PluginLootContainer( corpse, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Tester.", }, ]; Assert.True(controller.Tick(1d, canAct: true)); Assert.Equal(new[] { corpse }, automation.Opened); automation.Requested = corpse; automation.Current = corpse; automation.Contents = [Item(coin, "Colosseum coin", 77)]; Assert.True(controller.Tick(0.2d, canAct: true)); Assert.Equal(new[] { coin }, automation.Identified); automation.AppraisalState = new PluginAppraisalState(1, 0u, coin); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal(new[] { coin }, automation.Picked); automation.Contents = []; automation.InventoryCompletion = new PluginInventoryCompletion( 1, PluginInventoryCommandKind.Pickup, coin, 0u); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal(LootAction.Keep, controller.ClassifiedOwnedItems[coin]); Assert.False(controller.Tick(0.2d, canAct: true)); Assert.Equal("Corpse complete.", controller.Status); } [Fact] public void ControllerUsesSelectedExternalClassifierAndPreservesItsAction() { var settings = new LootSettings { Enabled = true, ExternalClassifierId = "loot-plugin/main", }; settings.Rules.Add(new LootRule { Expression = "*", Action = LootAction.NoLoot, }); var automation = new Automation(); var classifier = new ClassifierRegistry( "loot-plugin/main", new PluginLootClassification( Matched: true, PluginLootAction.User3, "External user action", Priority: 17)); var controller = new LootController( new Host(automation, classifier), settings); const uint corpse = 0x70000011u; const uint item = 0x70000012u; automation.Corpses = [ new PluginLootContainer( corpse, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Tester.", }, ]; Assert.True(controller.Tick(1d, canAct: true)); automation.Current = corpse; automation.Contents = [Item(item, "External prize", 88u)]; Assert.True(controller.Tick(0.2d, canAct: true)); automation.AppraisalState = new PluginAppraisalState(1, 0u, item); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal(new[] { item }, automation.Picked); Assert.Equal(1, classifier.ClassificationCount); Assert.Contains("External user action", controller.Status); PluginInventoryItem ownedItem = Item(item, "External prize", 88u); automation.Owned = [ownedItem]; automation.Contents = []; automation.InventoryCompletion = new PluginInventoryCompletion( 1, PluginInventoryCommandKind.Pickup, item, 0u); Assert.True(controller.Tick(0.1d, canAct: true)); PluginLootedItem looted = Assert.Single(classifier.Looted); Assert.Equal(item, looted.Item.ObjectId); Assert.Equal(PluginLootAction.User3, looted.Action); Assert.False(controller.Tick(0.2d, canAct: true)); automation.Owned = []; Assert.False(controller.Tick(0.2d, canAct: true)); Assert.Equal(new[] { item }, classifier.Removed); } [Fact] public void ExternalKeepUpToAndUnavailableClassifierNeverFallBackToInternalRules() { const uint corpse = 0x70000021u; const uint item = 0x70000022u; var automation = new Automation { Corpses = [ new PluginLootContainer( corpse, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Tester.", }, ], Owned = [Item(0x70000023u, "Limited prize", 99u) with { StackSize = 2, }], }; var settings = new LootSettings { Enabled = true, ExternalClassifierId = "loot-plugin/main", }; settings.Rules.Add(new LootRule { Expression = "*", Action = LootAction.Keep, }); var classifier = new ClassifierRegistry( "loot-plugin/main", new PluginLootClassification( Matched: true, PluginLootAction.KeepUpTo, KeepCount: 2)); var controller = new LootController( new Host(automation, classifier), settings); Assert.True(controller.Tick(1d, canAct: true)); automation.Current = corpse; automation.Contents = [Item(item, "Limited prize", 99u)]; Assert.True(controller.Tick(0.2d, canAct: true)); automation.AppraisalState = new PluginAppraisalState(1, 0u, item); Assert.False(controller.Tick(0.1d, canAct: true)); Assert.Empty(automation.Picked); controller.Reset(); automation.Requested = 0u; automation.Current = 0u; automation.Opened.Clear(); automation.Identified.Clear(); settings.ExternalClassifierId = "missing/classifier"; Assert.True(controller.Tick(1d, canAct: true)); automation.Current = corpse; Assert.False(controller.Tick(0.2d, canAct: true)); Assert.Empty(automation.Picked); } [Fact] public void FellowshipAndRareCorpsePolicyMatchesVtankProtectionRules() { var settings = new LootSettings { Enabled = true, LootFellowCorpses = true, }; settings.Rules.Add(new LootRule { Expression = "*" }); var automation = new Automation { Members = [ new PluginFellowMember( 0x50000002u, "Fellow", 1, 1, 1, 1, 1, 1, 2f) { ShareLoot = true, }, ], Corpses = [ new PluginLootContainer( 0x70000100u, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Fellow.", }, ], }; var controller = new LootController(new Host(automation), settings); Assert.True(controller.Tick(0.25d, canAct: true)); Assert.Equal(new[] { 0x70000100u }, automation.Opened); controller.Reset(); automation.Requested = 0u; automation.Opened.Clear(); automation.Members = [ automation.Members[0] with { ShareLoot = false }, ]; Assert.False(controller.Tick(0.25d, canAct: true)); Assert.True(controller.Tick(100d, canAct: true)); Assert.Single(automation.Opened); controller.Reset(); automation.Requested = 0u; automation.Opened.Clear(); automation.Corpses = [ automation.Corpses[0] with { IsGeneratedRare = true }, ]; Assert.False(controller.Tick(101d, canAct: true)); Assert.Empty(automation.Opened); settings.LootOnlyRareCorpses = true; automation.Corpses = [ automation.Corpses[0] with { IsGeneratedRare = true, LongDescription = "Killed by Tester. Generated rare treasure.", }, ]; Assert.True(controller.Tick(0.25d, canAct: true)); Assert.Single(automation.Opened); } [Fact] public void UnknownReadableScrollOverridesNoLootAndIsPickedForReading() { var settings = new LootSettings { Enabled = true }; settings.Rules.Add(new LootRule { Expression = "*", Action = LootAction.NoLoot, }); var automation = new Automation { KnownSpell = new PluginSpellInfo( 777u, "Incantation of Testing", 1u, 1, 100, 10, 0f, 34u, string.Empty, false, false), SkillsValue = [ new PluginSkillInfo( 34u, "War Magic", PluginSkillTraining.Trained, 90u), ], Corpses = [ new PluginLootContainer( 0x70000200u, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Tester.", }, ], }; var controller = new LootController(new Host(automation), settings); const uint scroll = 0x70000201u; Assert.True(controller.Tick(0.25d, canAct: true)); automation.Current = 0x70000200u; automation.Contents = [ Item(scroll, "Incantation of Testing Scroll", 88u) with { ItemType = 0x80u, SpellId = 777u, }, ]; Assert.True(controller.Tick(0.2d, canAct: true)); automation.AppraisalState = new PluginAppraisalState(1, 0u, scroll); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal(new[] { scroll }, automation.Picked); } [Fact] public void UnopenableCorpseUsesOfficialAttemptBlacklist() { var settings = new LootSettings { Enabled = true, CorpseOpenTimeoutSeconds = 0.25d, BlacklistCorpseOpenAttemptCount = 2, BlacklistCorpseOpenTimeoutSeconds = 200d, ScanIntervalSeconds = 0.05d, }; settings.Rules.Add(new LootRule { Expression = "*" }); var automation = new Automation { Corpses = [ new PluginLootContainer( 0x70000300u, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Tester.", }, ], }; var controller = new LootController(new Host(automation), settings); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.True(controller.Tick(0.3d, canAct: true)); Assert.False(controller.Tick(0.3d, canAct: true)); Assert.Equal(2, automation.Opened.Count); Assert.Contains("Blacklisted", controller.Status); Assert.False(controller.Tick(199d, canAct: true)); Assert.Equal(2, automation.Opened.Count); Assert.True(controller.Tick(2d, canAct: true)); Assert.Equal(3, automation.Opened.Count); } [Fact] public void SalvageRuleRunsRealRetailSalvageAfterCorpseIsComplete() { var settings = new LootSettings { Enabled = true }; settings.Rules.Add(new LootRule { Expression = "*", Action = LootAction.Salvage, }); const uint corpse = 0x70000400u; const uint source = 0x70000401u; const uint tool = 0x50000402u; var automation = new Automation { Corpses = [ new PluginLootContainer( corpse, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Tester.", }, ], Owned = [ Item(tool, "Salvage Tool", 99u) with { ItemType = 0x20000000u, }, ], }; var controller = new LootController(new Host(automation), settings); Assert.True(controller.Tick(0.25d, canAct: true)); automation.Current = corpse; automation.Contents = [ Item(source, "Iron Sword", 100u) with { MaterialType = 12u }, ]; Assert.True(controller.Tick(0.2d, canAct: true)); automation.AppraisalState = new PluginAppraisalState(1, 0u, source); Assert.True(controller.Tick(0.1d, canAct: true)); automation.Contents = []; automation.Owned = [automation.Owned[0], Item(source, "Iron Sword", 100u)]; automation.InventoryCompletion = new PluginInventoryCompletion( 1, PluginInventoryCommandKind.Pickup, source, 0u); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.False(controller.Tick(0.2d, canAct: true)); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal(new[] { (tool, source) }, automation.Salvaged); automation.Owned = [automation.Owned[0]]; Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal("Salvaged Iron Sword.", controller.Status); } [Fact] public void SellRuleQueuesUntilVendorThenRunsAuthoritativeSale() { var settings = new LootSettings { Enabled = true }; settings.Rules.Add(new LootRule { Expression = "*", Action = LootAction.Sell, }); const uint corpse = 0x70000500u; const uint source = 0x70000501u; var automation = new Automation { Corpses = [ new PluginLootContainer( corpse, 1u, "Corpse", 3f, false, false, false) { IsIdentified = true, LongDescription = "Killed by Tester.", }, ], }; var controller = new LootController(new Host(automation), settings); Assert.True(controller.Tick(0.25d, canAct: true)); automation.Current = corpse; automation.Contents = [Item(source, "Ruby", 101u)]; Assert.True(controller.Tick(0.2d, canAct: true)); automation.AppraisalState = new PluginAppraisalState(1, 0u, source); Assert.True(controller.Tick(0.1d, canAct: true)); automation.Contents = []; automation.Owned = [Item(source, "Ruby", 101u)]; automation.InventoryCompletion = new PluginInventoryCompletion( 1, PluginInventoryCommandKind.Pickup, source, 0u); Assert.True(controller.Tick(0.1d, canAct: true)); Assert.False(controller.Tick(0.2d, canAct: true)); Assert.False(controller.Tick(0.1d, canAct: true)); Assert.Contains("queued", controller.Status); Assert.Empty(automation.Sold); automation.VendorId = 0x70000510u; Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal(new[] { source }, automation.Sold); automation.Owned = []; Assert.True(controller.Tick(0.1d, canAct: true)); Assert.Equal("Sold Ruby.", controller.Status); } private static PluginInventoryItem Item(uint id, string name, uint wcid) => new( id, wcid, name, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0); private static VtankLootRequirement Requirement( int type, params string[] lines) => new() { Type = type, Payload = string.Join("\r\n", lines) + "\r\n", }; private sealed class Automation : IAutomationSurface, ICharacterInfo, ISpellCatalog, IItemAutomation, ILootAutomation, IFellowshipAutomation { public bool IsAvailable => true; public ICharacterInfo Character => this; public ISpellCatalog Spells => this; public IMagicCommands Magic => NoOpAutomationSurface.Instance; public IPluginChat Chat => NoOpAutomationSurface.Instance; public IItemAutomation Items => this; public ILootAutomation Loot => this; public IFellowshipAutomation Fellowship => this; public bool IsInWorld => true; public uint ObjectId => Player; public string Name => "Tester"; public int CharacterLevel { get; set; } public int Level => CharacterLevel; public int MainPackSlots { get; set; } public int MainPackFreeSlots => MainPackSlots; public uint CurrentHealth => 0; public uint MaxHealth => 0; public uint CurrentStamina => 0; public uint MaxStamina => 0; public uint CurrentMana => 0; public uint MaxMana => 0; public IReadOnlyList Skills => SkillsValue; public IReadOnlyList Attributes => []; public IReadOnlyList ActiveEnchantments => []; public uint Requested { get; set; } public uint Current { get; set; } public uint RequestedContainerId => Requested; public uint CurrentContainerId => Current; public IReadOnlyList Corpses { get; set; } = []; public IReadOnlyList Contents { get; set; } = []; public IReadOnlyList Owned { get; set; } = []; public PluginInventoryCompletion InventoryCompletion { get; set; } public PluginAppraisalState AppraisalState { get; set; } public PluginAppraisalState Appraisal => AppraisalState; public IReadOnlyList SkillsValue { get; set; } = []; public PluginSpellInfo? KnownSpell { get; set; } public bool SpellLearned { get; set; } public IReadOnlyList Members { get; set; } = []; public bool IsInFellowship => Members.Count != 0; public IReadOnlyList CaptureMembers() => Members; public IReadOnlyList KnownSelfBuffs => []; public bool IsKnown(uint spellId) => SpellLearned && KnownSpell?.SpellId == spellId; public bool TryGet(uint spellId, out PluginSpellInfo info) { if (KnownSpell is { } spell && spell.SpellId == spellId) { info = spell; return true; } info = default; return false; } PluginInventoryCompletion ILootAutomation.LastInventoryCompletion => InventoryCompletion; public List Opened { get; } = []; public List Picked { get; } = []; public List Identified { get; } = []; public List<(uint Tool, uint Item)> Salvaged { get; } = []; public List Sold { get; } = []; public uint VendorId { get; set; } public uint ActiveVendorObjectId => VendorId; public IReadOnlyList CaptureOwnedItems() => Owned; public IReadOnlyList CaptureCorpses( float maximumDistance) => Corpses .Where(corpse => corpse.Distance <= maximumDistance) .ToArray(); public IReadOnlyList CaptureCurrentContents() => Contents; public bool TryCaptureProperties( uint objectId, out PluginItemProperties properties) { properties = EmptyProperties; return true; } public PluginItemCommandResult Open(uint containerObjectId) { Opened.Add(containerObjectId); Requested = containerObjectId; return new(PluginItemCommandStatus.Started); } public PluginItemCommandResult Identify(uint objectId) { Identified.Add(objectId); AppraisalState = AppraisalState with { Revision = AppraisalState.Revision + 1, AwaitingObjectId = objectId, }; return new(PluginItemCommandStatus.Started); } public PluginItemCommandResult Pickup( uint objectId, bool mainPack = false) { Picked.Add(objectId); return new(PluginItemCommandStatus.Started); } public PluginItemCommandResult Salvage( uint toolObjectId, IReadOnlyList itemObjectIds) { foreach (uint itemObjectId in itemObjectIds) Salvaged.Add((toolObjectId, itemObjectId)); return new(PluginItemCommandStatus.Started); } public PluginItemCommandResult Sell(uint objectId, uint amount = 0u) { Sold.Add(objectId); return new(PluginItemCommandStatus.Started); } public bool TryGetSkill(uint skillId, out PluginSkillInfo skill) { foreach (PluginSkillInfo candidate in SkillsValue) { if (candidate.SkillId != skillId) continue; skill = candidate; return true; } skill = default; return false; } } private sealed class Host( IAutomationSurface automation, IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost { public bool HasUi => false; public IPluginLogger Log => Logger.Instance; public IGameState State => EmptyState.Instance; public IEvents Events => EmptyEvents.Instance; public ISelectionService Selection => EmptySelection.Instance; public IUiRegistry Ui => NoOpUiRegistry.Instance; public IAutomationSurface Automation { get; } = automation; public IPluginLootClassifierRegistry LootClassifiers { get; } = lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance; } private sealed class ClassifierRegistry( string id, PluginLootClassification result) : IPluginLootClassifierRegistry { public int ClassificationCount { get; private set; } public List Looted { get; } = []; public List Removed { get; } = []; public IReadOnlyList Available => [new(id, "Test classifier")]; public bool TryClassify( string classifierId, in PluginLootClassificationContext context, out PluginLootClassification classification) { if (!string.Equals(classifierId, id, StringComparison.OrdinalIgnoreCase)) { classification = default; return false; } ClassificationCount++; classification = result; return true; } public bool TryNotifyLooted( string classifierId, in PluginLootedItem item) { if (!string.Equals(classifierId, id, StringComparison.OrdinalIgnoreCase)) return false; Looted.Add(item); return true; } public bool TryNotifyItemRemoved(string classifierId, uint objectId) { if (!string.Equals(classifierId, id, StringComparison.OrdinalIgnoreCase)) return false; Removed.Add(objectId); return true; } } private sealed class Logger : IPluginLogger { public static Logger Instance { get; } = new(); public void Info(string message) { } public void Warn(string message) { } public void Error(string message, Exception? exception = null) { } } private sealed class EmptyState : IGameState { public static EmptyState Instance { get; } = new(); public IReadOnlyList Entities => []; } private sealed class EmptyEvents : IEvents { public static EmptyEvents Instance { get; } = new(); public event Action EntitySpawned { add { } remove { } } public event Action Tick { add { } remove { } } } private sealed class EmptySelection : ISelectionService { public static EmptySelection Instance { get; } = new(); public uint? SelectedObjectId => null; public uint? PreviousObjectId => null; public event Action Changed { add { } remove { } } public bool Select(uint objectId) => false; public bool Clear() => false; } }