From 57c708a1c60d9a043ad162cb9e4c3481c764fe56 Mon Sep 17 00:00:00 2001 From: JustArchi Date: Wed, 1 Aug 2018 23:11:15 +0200 Subject: [PATCH] Fix STM trading logic --- ArchiSteamFarm.Tests/Trading.cs | 35 +++++++- ArchiSteamFarm/Json/Steam.cs | 4 +- ArchiSteamFarm/Trading.cs | 141 ++++++++++++++++++++++---------- 3 files changed, 135 insertions(+), 45 deletions(-) diff --git a/ArchiSteamFarm.Tests/Trading.cs b/ArchiSteamFarm.Tests/Trading.cs index 60d6f2e9b..2d7d08441 100644 --- a/ArchiSteamFarm.Tests/Trading.cs +++ b/ArchiSteamFarm.Tests/Trading.cs @@ -84,8 +84,8 @@ namespace ArchiSteamFarm.Tests { Steam.Asset item1Game1X2 = GenerateSteamCommunityItem(1, 2, 570, Steam.Asset.EType.TradingCard); Steam.Asset item2Game1 = GenerateSteamCommunityItem(2, 1, 570, Steam.Asset.EType.TradingCard); - Steam.Asset item1Game2 = GenerateSteamCommunityItem(1, 1, 730, Steam.Asset.EType.TradingCard); - Steam.Asset item2Game2 = GenerateSteamCommunityItem(2, 1, 730, Steam.Asset.EType.TradingCard); + Steam.Asset item1Game2 = GenerateSteamCommunityItem(3, 1, 730, Steam.Asset.EType.TradingCard); + Steam.Asset item2Game2 = GenerateSteamCommunityItem(4, 1, 730, Steam.Asset.EType.TradingCard); HashSet inventory = new HashSet { item1Game1X2, item1Game2 }; HashSet itemsToGive = new HashSet { item1Game1, item1Game2 }; @@ -180,6 +180,22 @@ namespace ArchiSteamFarm.Tests { Assert.IsFalse(AcceptsTrade(inventory, itemsToGive, itemsToReceive)); } + [TestMethod] + public void SingleGameSingleTypeBadWithOverpayingReject() { + Steam.Asset item1 = GenerateSteamCommunityItem(1, 1, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item1X2 = GenerateSteamCommunityItem(1, 2, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item2 = GenerateSteamCommunityItem(2, 1, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item2X2 = GenerateSteamCommunityItem(2, 2, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item3 = GenerateSteamCommunityItem(3, 1, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item3X2 = GenerateSteamCommunityItem(3, 2, 570, Steam.Asset.EType.TradingCard); + + HashSet inventory = new HashSet { item1X2, item2X2, item3X2 }; + HashSet itemsToGive = new HashSet { item2 }; + HashSet itemsToReceive = new HashSet { item1, item3 }; + + Assert.IsFalse(AcceptsTrade(inventory, itemsToGive, itemsToReceive)); + } + [TestMethod] public void SingleGameSingleTypeGoodAccept() { Steam.Asset item1 = GenerateSteamCommunityItem(1, 1, 570, Steam.Asset.EType.TradingCard); @@ -205,6 +221,21 @@ namespace ArchiSteamFarm.Tests { Assert.IsTrue(AcceptsTrade(inventory, itemsToGive, itemsToReceive)); } + [TestMethod] + public void SingleGameSingleTypeNeutralWithOverpayingAccept() { + Steam.Asset item1 = GenerateSteamCommunityItem(1, 1, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item1X2 = GenerateSteamCommunityItem(1, 2, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item2 = GenerateSteamCommunityItem(2, 1, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item2X2 = GenerateSteamCommunityItem(2, 2, 570, Steam.Asset.EType.TradingCard); + Steam.Asset item3 = GenerateSteamCommunityItem(3, 1, 570, Steam.Asset.EType.TradingCard); + + HashSet inventory = new HashSet { item1X2, item2X2 }; + HashSet itemsToGive = new HashSet { item2 }; + HashSet itemsToReceive = new HashSet { item1, item3 }; + + Assert.IsTrue(AcceptsTrade(inventory, itemsToGive, itemsToReceive)); + } + private static bool AcceptsTrade(IReadOnlyCollection inventory, IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { Type trading = typeof(ArchiSteamFarm.Trading); MethodInfo method = trading.GetMethod("IsTradeNeutralOrBetter", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); diff --git a/ArchiSteamFarm/Json/Steam.cs b/ArchiSteamFarm/Json/Steam.cs index 39efbc1aa..1c5765bc9 100644 --- a/ArchiSteamFarm/Json/Steam.cs +++ b/ArchiSteamFarm/Json/Steam.cs @@ -35,7 +35,7 @@ namespace ArchiSteamFarm.Json { internal const ushort SteamAppID = 753; internal const byte SteamCommunityContextID = 6; - internal uint Amount { get; private set; } + internal uint Amount { get; set; } [JsonProperty(PropertyName = "appid", Required = Required.DisallowNull)] internal uint AppID { get; private set; } @@ -661,4 +661,4 @@ namespace ArchiSteamFarm.Json { } } } -} \ No newline at end of file +} diff --git a/ArchiSteamFarm/Trading.cs b/ArchiSteamFarm/Trading.cs index efc52ce66..d6ed5d70a 100644 --- a/ArchiSteamFarm/Trading.cs +++ b/ArchiSteamFarm/Trading.cs @@ -68,64 +68,123 @@ namespace ArchiSteamFarm { } } - private static bool IsTradeNeutralOrBetter(IReadOnlyCollection inventory, IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { + private static Dictionary<(uint AppID, Steam.Asset.EType Type), List> GetInventorySets(IReadOnlyCollection inventory) { + if ((inventory == null) || (inventory.Count == 0)) { + ASF.ArchiLogger.LogNullError(nameof(inventory)); + return null; + } + + Dictionary<(uint AppID, Steam.Asset.EType Type), Dictionary> sets = new Dictionary<(uint AppID, Steam.Asset.EType Type), Dictionary>(); + + foreach (Steam.Asset item in inventory) { + if (sets.TryGetValue((item.RealAppID, item.Type), out Dictionary set)) { + if (set.TryGetValue(item.ClassID, out uint amount)) { + set[item.ClassID] = amount + item.Amount; + } else { + set[item.ClassID] = item.Amount; + } + } else { + sets[(item.RealAppID, item.Type)] = new Dictionary { { item.ClassID, item.Amount } }; + } + } + + return sets.ToDictionary(set => set.Key, set => set.Value.Values.OrderByDescending(amount => amount).ToList()); + } + + private static bool IsTradeNeutralOrBetter(HashSet inventory, IReadOnlyCollection itemsToGive, IReadOnlyCollection itemsToReceive) { if ((inventory == null) || (inventory.Count == 0) || (itemsToGive == null) || (itemsToGive.Count == 0) || (itemsToReceive == null) || (itemsToReceive.Count == 0)) { ASF.ArchiLogger.LogNullError(nameof(inventory) + " || " + nameof(itemsToGive) + " || " + nameof(itemsToReceive)); return false; } - // Now let's create a map which maps items to their amount in our EQ - // This has to be done as we might have multiple items of given ClassID with multiple amounts - Dictionary itemAmounts = new Dictionary(); - foreach (Steam.Asset item in inventory) { - itemAmounts[item.ClassID] = itemAmounts.TryGetValue(item.ClassID, out uint amount) ? amount + item.Amount : item.Amount; - } + // Input of this function is items we're expected to give/receive and our inventory (limited to realAppIDs of itemsToGive/itemsToReceive) + // The objective is to determine whether the new state is beneficial (or at least neutral) towards us + // 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 - // Calculate our value of items to give on per-game basis - Dictionary<(Steam.Asset.EType Type, uint AppID), List> itemAmountToGivePerGame = new Dictionary<(Steam.Asset.EType Type, uint AppID), List>(); - Dictionary itemAmountsToGive = new Dictionary(itemAmounts); - foreach (Steam.Asset item in itemsToGive) { - if (!itemAmountToGivePerGame.TryGetValue((item.Type, item.RealAppID), out List amountsToGive)) { - amountsToGive = new List(); - itemAmountToGivePerGame[(item.Type, item.RealAppID)] = amountsToGive; + // Firstly we get initial sets state of our inventory + Dictionary<(uint AppID, Steam.Asset.EType Type), List> initialSets = GetInventorySets(inventory); + + // 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 + foreach (Steam.Asset itemToGive in itemsToGive) { + uint amountToGive = itemToGive.Amount; + HashSet itemsToRemove = new HashSet(); + + // Keep in mind that ClassID is unique only within appID/contextID scope - we can do it like this because we're not dealing with non-Steam items here (otherwise we'd need to check appID and contextID too) + foreach (Steam.Asset item in inventory.Where(item => item.ClassID == itemToGive.ClassID)) { + if (amountToGive >= item.Amount) { + itemsToRemove.Add(item); + amountToGive -= item.Amount; + } else { + item.Amount -= amountToGive; + amountToGive = 0; + } + + if (amountToGive == 0) { + break; + } } - for (uint i = 0; i < item.Amount; i++) { - amountsToGive.Add(itemAmountsToGive.TryGetValue(item.ClassID, out uint amount) ? amount : 0); - itemAmountsToGive[item.ClassID] = --amount; // We're giving one, so we have one less + if (amountToGive > 0) { + ASF.ArchiLogger.LogNullError(nameof(amountToGive)); + return false; + } + + if (itemsToRemove.Count > 0) { + inventory.ExceptWith(itemsToRemove); } } - // Sort all the lists of amounts to give on per-game basis ascending - foreach (List amountsToGive in itemAmountToGivePerGame.Values) { - amountsToGive.Sort(); + // 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 (Steam.Asset itemToReceive in itemsToReceive) { + inventory.Add(itemToReceive); } - // Calculate our value of items to receive on per-game basis - Dictionary<(Steam.Asset.EType Type, uint AppID), List> itemAmountToReceivePerGame = new Dictionary<(Steam.Asset.EType Type, uint AppID), List>(); - Dictionary itemAmountsToReceive = new Dictionary(itemAmounts); - foreach (Steam.Asset item in itemsToReceive) { - if (!itemAmountToReceivePerGame.TryGetValue((item.Type, item.RealAppID), out List amountsToReceive)) { - amountsToReceive = new List(); - itemAmountToReceivePerGame[(item.Type, item.RealAppID)] = amountsToReceive; + // Now we can get final sets state of our inventory after the exchange + Dictionary<(uint AppID, Steam.Asset.EType Type), List> finalSets = GetInventorySets(inventory); + + // Once we have both states, we can check overall fairness + foreach (KeyValuePair<(uint AppID, Steam.Asset.EType Type), List> finalSet in finalSets) { + List beforeAmounts = initialSets[finalSet.Key]; + List afterAmounts = finalSet.Value; + + // If amount of unique items in the set decreases, this is always a bad trade (e.g. 1 1 -> 0 2) + if (afterAmounts.Count < beforeAmounts.Count) { + return false; } - for (uint i = 0; i < item.Amount; i++) { - amountsToReceive.Add(itemAmountsToReceive.TryGetValue(item.ClassID, out uint amount) ? amount : 0); - itemAmountsToReceive[item.ClassID] = ++amount; // We're getting one, so we have one more + // If amount of unique items in the set increases, this is always a good trade (e.g. 0 2 -> 1 1) + if (afterAmounts.Count > beforeAmounts.Count) { + continue; + } + + // At this point we're sure that amount of unique items stays the same, so we can evaluate actual sets + uint beforeSets = beforeAmounts[beforeAmounts.Count - 1]; + uint afterSets = afterAmounts[afterAmounts.Count - 1]; + + // If amount of our sets for this game decreases, this is always a bad trade (e.g. 2 2 2 -> 3 2 1) + if (afterSets < beforeSets) { + return false; + } + + // If amount of our sets for this game increases, this is always a good trade (e.g. 3 2 1 -> 2 2 2) + if (afterSets > beforeSets) { + continue; + } + + // At this point we're sure that both number of unique items in the set stays the same, as well as number of our actual sets + // We need to ensure set progress here, so we'll check if no final amount of a single item is lower than initial one + // We also need to remember about overpaying, so we'll compare only appropriate indexes from a list (that is already sorted in descending order) + for (byte i = 0; i < afterAmounts.Count; i++) { + if (afterAmounts[i] < beforeAmounts[i]) { + return false; + } } } - // Sort all the lists of amounts to receive on per-game basis ascending - foreach (List amountsToReceive in itemAmountToReceivePerGame.Values) { - amountsToReceive.Sort(); - } - - // Calculate final neutrality result - // This is quite complex operation of taking minimum difference from all differences on per-game basis - // When calculating per-game difference, we sum only amounts at proper indexes, because user might be overpaying - int difference = itemAmountToGivePerGame.Min(kv => kv.Value.Select((t, i) => (int) (t - itemAmountToReceivePerGame[kv.Key][i])).Sum()); - return difference > 0; + // If we didn't find any reason above to reject this trade, it's at least neutral+ for us - it increases our progress towards badge completion + return true; } private async Task ParseActiveTrades() { @@ -345,4 +404,4 @@ namespace ArchiSteamFarm { } } } -} \ No newline at end of file +}