diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/MatchingUtilities.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/MatchingUtilities.cs new file mode 100644 index 000000000..91541f386 --- /dev/null +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/MatchingUtilities.cs @@ -0,0 +1,191 @@ +// ---------------------------------------------------------------------------------------------- +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// ---------------------------------------------------------------------------------------------- +// | +// Copyright 2015-2024 Ɓukasz "JustArchi" Domeradzki +// Contact: JustArchi@JustArchi.net +// | +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// | +// http://www.apache.org/licenses/LICENSE-2.0 +// | +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using ArchiSteamFarm.Steam.Data; + +namespace ArchiSteamFarm.OfficialPlugins.ItemsMatcher; + +internal static class MatchingUtilities { + internal static (Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> FullState, Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> TradableState) GetDividedInventoryState(IReadOnlyCollection inventory) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> fullState = new(); + Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> tradableState = new(); + + foreach (Asset item in inventory) { + (uint RealAppID, EAssetType Type, EAssetRarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + + if (fullState.TryGetValue(key, out Dictionary? fullSet)) { + fullSet[item.ClassID] = fullSet.GetValueOrDefault(item.ClassID) + item.Amount; + } else { + fullState[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + + if (!item.Tradable) { + continue; + } + + if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { + tradableSet[item.ClassID] = tradableSet.GetValueOrDefault(item.ClassID) + item.Amount; + } else { + tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + } + + return (fullState, tradableState); + } + + internal static Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> GetTradableInventoryState(IReadOnlyCollection inventory) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> tradableState = new(); + + foreach (Asset item in inventory.Where(static item => item.Tradable)) { + (uint RealAppID, EAssetType Type, EAssetRarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); + + if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { + tradableSet[item.ClassID] = tradableSet.GetValueOrDefault(item.ClassID) + item.Amount; + } else { + tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + } + + return tradableState; + } + + internal static HashSet GetTradableItemsFromInventory(IReadOnlyCollection inventory, IReadOnlyDictionary classIDs, bool randomize = false) { + if ((inventory == null) || (inventory.Count == 0)) { + throw new ArgumentNullException(nameof(inventory)); + } + + if ((classIDs == null) || (classIDs.Count == 0)) { + throw new ArgumentNullException(nameof(classIDs)); + } + + // We need a copy of classIDs passed since we're going to manipulate them + Dictionary classIDsState = classIDs.ToDictionary(); + + HashSet result = []; + + IEnumerable items = inventory.Where(static item => item.Tradable); + + // Randomization helps to decrease "items no longer available" in regards to sending offers to other users + if (randomize) { +#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner + items = items.Where(item => classIDsState.ContainsKey(item.ClassID)).OrderBy(static _ => Random.Shared.Next()); +#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner + } + + foreach (Asset item in items) { + if (!classIDsState.TryGetValue(item.ClassID, out uint amount)) { + continue; + } + + if (amount >= item.Amount) { + result.Add(item); + + if (amount > item.Amount) { + classIDsState[item.ClassID] = amount - item.Amount; + } else { + classIDsState.Remove(item.ClassID); + + if (classIDsState.Count == 0) { + return result; + } + } + } else { + Asset itemToAdd = item.DeepClone(); + + itemToAdd.Amount = amount; + + result.Add(itemToAdd); + + classIDsState.Remove(itemToAdd.ClassID); + + if (classIDsState.Count == 0) { + return result; + } + } + } + + // If we got here it means we still have classIDs to match + throw new InvalidOperationException(nameof(classIDs)); + } + + internal static bool IsEmptyForMatching(IReadOnlyDictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> fullState, IReadOnlyDictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> tradableState) { + ArgumentNullException.ThrowIfNull(fullState); + ArgumentNullException.ThrowIfNull(tradableState); + + foreach (((uint RealAppID, EAssetType Type, EAssetRarity Rarity) set, IReadOnlyDictionary state) in tradableState) { + if (!fullState.TryGetValue(set, out Dictionary? fullSet) || (fullSet.Count == 0)) { + throw new InvalidOperationException(nameof(fullSet)); + } + + if (!IsEmptyForMatching(fullSet, state)) { + return false; + } + } + + // We didn't find any matchable combinations, so this inventory is empty + return true; + } + + internal static bool IsEmptyForMatching(IReadOnlyDictionary fullSet, IReadOnlyDictionary tradableSet) { + ArgumentNullException.ThrowIfNull(fullSet); + ArgumentNullException.ThrowIfNull(tradableSet); + + foreach ((ulong classID, uint amount) in tradableSet) { + switch (amount) { + case 0: + // No tradable items, this should never happen, dictionary should not have this key to begin with + throw new InvalidOperationException(nameof(amount)); + case 1: + // Single tradable item, can be matchable or not depending on the rest of the inventory + if (!fullSet.TryGetValue(classID, out uint fullAmount) || (fullAmount == 0)) { + throw new InvalidOperationException(nameof(fullAmount)); + } + + if (fullAmount > 1) { + // If we have a single tradable item but more than 1 in total, this is matchable + return false; + } + + // A single exclusive tradable item is not matchable, continue + continue; + default: + // Any other combination of tradable items is always matchable + return false; + } + } + + // We didn't find any matchable combinations, so this inventory is empty + return true; + } +} diff --git a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs index e16dde016..f4d84a523 100644 --- a/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs +++ b/ArchiSteamFarm.OfficialPlugins.ItemsMatcher/RemoteCommunication.cs @@ -1180,9 +1180,9 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { throw new ArgumentNullException(nameof(acceptedMatchableTypes)); } - (Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> ourFullState, Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> ourTradableState) = Trading.GetDividedInventoryState(ourAssets); + (Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> ourFullState, Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> ourTradableState) = MatchingUtilities.GetDividedInventoryState(ourAssets); - if (Trading.IsEmptyForMatching(ourFullState, ourTradableState)) { + if (MatchingUtilities.IsEmptyForMatching(ourFullState, ourTradableState)) { // User doesn't have any more dupes in the inventory Bot.ArchiLogger.LogGenericTrace(string.Format(CultureInfo.CurrentCulture, Strings.ErrorIsEmpty, $"{nameof(ourFullState)} || {nameof(ourTradableState)}")); @@ -1292,7 +1292,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { HashSet<(uint RealAppID, EAssetType Type, EAssetRarity Rarity)> skippedSetsThisUser = []; - Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> theirTradableState = Trading.GetTradableInventoryState(theirInventory); + Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> theirTradableState = MatchingUtilities.GetTradableInventoryState(theirInventory); for (byte i = 0; i < Trading.MaxTradesPerAccount; i++) { byte itemsInTrade = 0; @@ -1314,7 +1314,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { continue; } - if (Trading.IsEmptyForMatching(ourFullItems, ourTradableItems)) { + if (MatchingUtilities.IsEmptyForMatching(ourFullItems, ourTradableItems)) { // We may have no more matchable items from this set continue; } @@ -1347,13 +1347,13 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { fairClassIDsToReceive[theirItem] = ++fairReceivedAmount; // Filter their inventory for the sets we're trading or have traded with this user - HashSet fairFiltered = theirInventory.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).Select(static item => item.DeepClone()).ToHashSet(); + HashSet fairFiltered = theirInventory.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).ToHashSet(); - // Copy list to HashSet - HashSet fairItemsToGive = Trading.GetTradableItemsFromInventory(ourInventory.Values.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).Select(static item => item.DeepClone()).ToHashSet(), fairClassIDsToGive.ToDictionary(static classID => classID.Key, static classID => classID.Value)); - HashSet fairItemsToReceive = Trading.GetTradableItemsFromInventory(fairFiltered.Select(static item => item.DeepClone()).ToHashSet(), fairClassIDsToReceive.ToDictionary(static classID => classID.Key, static classID => classID.Value)); + // Get tradable items from our and their inventory + HashSet fairItemsToGive = MatchingUtilities.GetTradableItemsFromInventory(ourInventory.Values.Where(item => ((item.RealAppID == set.RealAppID) && (item.Type == set.Type) && (item.Rarity == set.Rarity)) || skippedSetsThisTrade.Contains((item.RealAppID, item.Type, item.Rarity))).ToHashSet(), fairClassIDsToGive); + HashSet fairItemsToReceive = MatchingUtilities.GetTradableItemsFromInventory(fairFiltered, fairClassIDsToReceive); - // Actual check + // Actual check, since we do this against remote user, we flip places for items if (!Trading.IsTradeNeutralOrBetter(fairFiltered, fairItemsToReceive, fairItemsToGive)) { // Revert the changes if (fairGivenAmount > 1) { @@ -1421,8 +1421,8 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { } // Remove the items from inventories - HashSet itemsToGive = Trading.GetTradableItemsFromInventory(ourInventory.Values, classIDsToGive); - HashSet itemsToReceive = Trading.GetTradableItemsFromInventory(theirInventory, classIDsToReceive, true); + HashSet itemsToGive = MatchingUtilities.GetTradableItemsFromInventory(ourInventory.Values, classIDsToGive); + HashSet itemsToReceive = MatchingUtilities.GetTradableItemsFromInventory(theirInventory, classIDsToReceive, true); if ((itemsToGive.Count != itemsToReceive.Count) || !Trading.IsFairExchange(itemsToGive, itemsToReceive)) { // Failsafe @@ -1534,7 +1534,7 @@ internal sealed class RemoteCommunication : IAsyncDisposable, IDisposable { matchedSets += (uint) skippedSetsThisUser.Count; - if (Trading.IsEmptyForMatching(ourFullState, ourTradableState)) { + if (MatchingUtilities.IsEmptyForMatching(ourFullState, ourTradableState)) { // User doesn't have any more dupes in the inventory break; } diff --git a/ArchiSteamFarm/Steam/Bot.cs b/ArchiSteamFarm/Steam/Bot.cs index fcc244c01..c2d93539d 100644 --- a/ArchiSteamFarm/Steam/Bot.cs +++ b/ArchiSteamFarm/Steam/Bot.cs @@ -722,16 +722,16 @@ public sealed class Bot : IAsyncDisposable, IDisposable { ushort classRemaining = realSetsToExtract; foreach (Asset item in itemsOfClass.TakeWhile(_ => classRemaining > 0)) { - if (item.Amount > classRemaining) { + if (classRemaining >= item.Amount) { + result.Add(item); + + classRemaining -= (ushort) item.Amount; + } else { Asset itemToSend = item.DeepClone(); itemToSend.Amount = classRemaining; result.Add(itemToSend); classRemaining = 0; - } else { - result.Add(item); - - classRemaining -= (ushort) item.Amount; } } } diff --git a/ArchiSteamFarm/Steam/Exchange/Trading.cs b/ArchiSteamFarm/Steam/Exchange/Trading.cs index bedc35432..a6d7a7374 100644 --- a/ArchiSteamFarm/Steam/Exchange/Trading.cs +++ b/ArchiSteamFarm/Steam/Exchange/Trading.cs @@ -105,7 +105,7 @@ public sealed class Trading : IDisposable { } [PublicAPI] - public static bool IsTradeNeutralOrBetter(HashSet inventory, IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { + public static bool IsTradeNeutralOrBetter(IReadOnlyCollection inventory, IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { if ((inventory == null) || (inventory.Count == 0)) { throw new ArgumentNullException(nameof(inventory)); } @@ -123,8 +123,11 @@ public sealed class Trading : IDisposable { // There are a lot of factors involved here - different realAppIDs, different item types, possibility of user overpaying and more // All of those cases should be verified by our unit tests to ensure that the logic here matches all possible cases, especially those that were incorrectly handled previously + // We start from a deep copy of the inventory, along with its assets, since we'll be manipulating amounts in them + HashSet inventoryState = inventory.Select(static item => item.DeepClone()).ToHashSet(); + // Firstly we get initial sets state of our inventory - Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), List> initialSets = GetInventorySets(inventory); + Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), List> initialSets = GetInventorySets(inventoryState); // Once we have initial state, we remove items that we're supposed to give from our inventory // This loop is a bit more complex due to the fact that we might have a mix of the same item splitted into different amounts @@ -132,8 +135,7 @@ public sealed class Trading : IDisposable { uint amountToGive = itemToGive.Amount; HashSet itemsToRemove = []; - // Keep in mind that ClassID is unique only within appID scope - we can do it like this because we're not dealing with non-Steam items here (otherwise we'd need to check appID too) - foreach (Asset item in inventory.Where(item => item.ClassID == itemToGive.ClassID)) { + foreach (Asset item in inventoryState.Where(item => (item.AppID == itemToGive.AppID) && (item.ClassID == itemToGive.ClassID) && (item.InstanceID == itemToGive.InstanceID))) { if (amountToGive >= item.Amount) { itemsToRemove.Add(item); amountToGive -= item.Amount; @@ -152,17 +154,17 @@ public sealed class Trading : IDisposable { } if (itemsToRemove.Count > 0) { - inventory.ExceptWith(itemsToRemove); + inventoryState.ExceptWith(itemsToRemove); } } // Now we can add items that we're supposed to receive, this one doesn't require advanced amounts logic since we can just add items regardless foreach (Asset itemToReceive in itemsToReceive) { - inventory.Add(itemToReceive); + inventoryState.Add(itemToReceive); } // Now we can get final sets state of our inventory after the exchange - Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), List> finalSets = GetInventorySets(inventory); + Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), List> finalSets = GetInventorySets(inventoryState); // Once we have both states, we can check overall fairness foreach (((uint RealAppID, EAssetType Type, EAssetRarity Rarity) set, List beforeAmounts) in initialSets) { @@ -200,151 +202,6 @@ public sealed class Trading : IDisposable { return true; } - internal static (Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> FullState, Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> TradableState) GetDividedInventoryState(IReadOnlyCollection inventory) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } - - Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> fullState = new(); - Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> tradableState = new(); - - foreach (Asset item in inventory) { - (uint RealAppID, EAssetType Type, EAssetRarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); - - if (fullState.TryGetValue(key, out Dictionary? fullSet)) { - fullSet[item.ClassID] = fullSet.GetValueOrDefault(item.ClassID) + item.Amount; - } else { - fullState[key] = new Dictionary { { item.ClassID, item.Amount } }; - } - - if (!item.Tradable) { - continue; - } - - if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { - tradableSet[item.ClassID] = tradableSet.GetValueOrDefault(item.ClassID) + item.Amount; - } else { - tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; - } - } - - return (fullState, tradableState); - } - - internal static Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> GetTradableInventoryState(IReadOnlyCollection inventory) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } - - Dictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> tradableState = new(); - - foreach (Asset item in inventory.Where(static item => item.Tradable)) { - (uint RealAppID, EAssetType Type, EAssetRarity Rarity) key = (item.RealAppID, item.Type, item.Rarity); - - if (tradableState.TryGetValue(key, out Dictionary? tradableSet)) { - tradableSet[item.ClassID] = tradableSet.GetValueOrDefault(item.ClassID) + item.Amount; - } else { - tradableState[key] = new Dictionary { { item.ClassID, item.Amount } }; - } - } - - return tradableState; - } - - internal static HashSet GetTradableItemsFromInventory(IReadOnlyCollection inventory, IDictionary classIDs, bool randomize = false) { - if ((inventory == null) || (inventory.Count == 0)) { - throw new ArgumentNullException(nameof(inventory)); - } - - if ((classIDs == null) || (classIDs.Count == 0)) { - throw new ArgumentNullException(nameof(classIDs)); - } - - HashSet result = []; - - IEnumerable items = inventory.Where(static item => item.Tradable); - - // Randomization helps to decrease "items no longer available" in regards to sending offers to other users - if (randomize) { -#pragma warning disable CA5394 // This call isn't used in a security-sensitive manner - items = items.Where(item => classIDs.ContainsKey(item.ClassID)).OrderBy(static _ => Random.Shared.Next()); -#pragma warning restore CA5394 // This call isn't used in a security-sensitive manner - } - - foreach (Asset item in items) { - if (!classIDs.TryGetValue(item.ClassID, out uint amount)) { - continue; - } - - Asset itemToAdd = item.DeepClone(); - - if (amount < itemToAdd.Amount) { - // We give only a fraction of this item - itemToAdd.Amount = amount; - } - - result.Add(itemToAdd); - - if (amount == itemToAdd.Amount) { - classIDs.Remove(itemToAdd.ClassID); - } else { - classIDs[itemToAdd.ClassID] = amount - itemToAdd.Amount; - } - } - - return result; - } - - internal static bool IsEmptyForMatching(IReadOnlyDictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> fullState, IReadOnlyDictionary<(uint RealAppID, EAssetType Type, EAssetRarity Rarity), Dictionary> tradableState) { - ArgumentNullException.ThrowIfNull(fullState); - ArgumentNullException.ThrowIfNull(tradableState); - - foreach (((uint RealAppID, EAssetType Type, EAssetRarity Rarity) set, IReadOnlyDictionary state) in tradableState) { - if (!fullState.TryGetValue(set, out Dictionary? fullSet) || (fullSet.Count == 0)) { - throw new InvalidOperationException(nameof(fullSet)); - } - - if (!IsEmptyForMatching(fullSet, state)) { - return false; - } - } - - // We didn't find any matchable combinations, so this inventory is empty - return true; - } - - internal static bool IsEmptyForMatching(IReadOnlyDictionary fullSet, IReadOnlyDictionary tradableSet) { - ArgumentNullException.ThrowIfNull(fullSet); - ArgumentNullException.ThrowIfNull(tradableSet); - - foreach ((ulong classID, uint amount) in tradableSet) { - switch (amount) { - case 0: - // No tradable items, this should never happen, dictionary should not have this key to begin with - throw new InvalidOperationException(nameof(amount)); - case 1: - // Single tradable item, can be matchable or not depending on the rest of the inventory - if (!fullSet.TryGetValue(classID, out uint fullAmount) || (fullAmount == 0)) { - throw new InvalidOperationException(nameof(fullAmount)); - } - - if (fullAmount > 1) { - // If we have a single tradable item but more than 1 in total, this is matchable - return false; - } - - // A single exclusive tradable item is not matchable, continue - continue; - default: - // Any other combination of tradable items is always matchable - return false; - } - } - - // We didn't find any matchable combinations, so this inventory is empty - return true; - } - internal void OnDisconnected() => HandledTradeOfferIDs.Clear(); internal async Task OnNewTrade() { @@ -664,7 +521,7 @@ public sealed class Trading : IDisposable { return ParseTradeResult.EResult.TryAgain; } - bool accept = IsTradeNeutralOrBetter(inventory, tradeOffer.ItemsToGive.Select(static item => item.DeepClone()).ToHashSet(), tradeOffer.ItemsToReceive.Select(static item => item.DeepClone()).ToHashSet()); + bool accept = IsTradeNeutralOrBetter(inventory, tradeOffer.ItemsToGive, tradeOffer.ItemsToReceive); // We're now sure whether the trade is neutral+ for us or not ParseTradeResult.EResult acceptResult = accept ? ParseTradeResult.EResult.Accepted : ParseTradeResult.EResult.Rejected;