From 1beb08f823c1a67f2ca03cb342541fa6a18d106f Mon Sep 17 00:00:00 2001 From: JustArchi Date: Thu, 29 Nov 2018 18:35:58 +0100 Subject: [PATCH] Implement ETradingPreferences.MatchActively This will probably need a lot more tests, tweaking and bugfixing, but basic logic is: - MatchActively added to TradingPreferences with value of 16 - User must also use SteamTradeMatcher, can't use MatchEverything - User must have statistics enabled and be eligible for being listed (no requirement of having 100 items minimum) Once all requirements are passed, statistics module will communicate with the listing and fetch match everything bots: - The matching will start in 1h since ASF start and will repeat every day (right now it starts in 1 minute to aid debugging). - Each matching is composed of up to 10 rounds maximum. - In each round ASF will fetch our inventory and inventory of listed bots in order to find MatchableTypes items to be matched. If match is found, offer is being sent and confirmed automatically. - Each set (composition of item type + appID it's from) can be matched in a single round only once, this is to minimize "items no longer available" as much as possible and also avoid a need to wait for each bot to react before sending all trades. - Round ends when we try to match a total of 20 bots, or we hit no items to match in consecutive 10 tries with 10 different bots. - If last round resulted in at least a single trade being sent, next round starts within 5 minutes since last one, otherwise matching ends and repeats the next day. We'll see how it works in practice, expect a lot of follow-up commits, unless I won't have anything to fix or improve. --- ArchiSteamFarm/Actions.cs | 2 +- ArchiSteamFarm/ArchiWebHandler.cs | 45 ++-- ArchiSteamFarm/BotConfig.cs | 3 +- ArchiSteamFarm/Statistics.cs | 350 +++++++++++++++++++++++++++++- ArchiSteamFarm/Trading.cs | 69 ++++-- 5 files changed, 432 insertions(+), 37 deletions(-) diff --git a/ArchiSteamFarm/Actions.cs b/ArchiSteamFarm/Actions.cs index f513f9f07..7da817929 100644 --- a/ArchiSteamFarm/Actions.cs +++ b/ArchiSteamFarm/Actions.cs @@ -294,7 +294,7 @@ namespace ArchiSteamFarm { return (false, Strings.BotLootingFailed); } - (bool success, HashSet mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(targetSteamID, inventory, Bot.BotConfig.SteamTradeToken).ConfigureAwait(false); + (bool success, HashSet mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(targetSteamID, inventory, token: Bot.BotConfig.SteamTradeToken).ConfigureAwait(false); if ((mobileTradeOfferIDs != null) && (mobileTradeOfferIDs.Count > 0) && Bot.HasMobileAuthenticator) { if (!await AcceptConfirmations(true, Steam.ConfirmationDetails.EType.Trade, targetSteamID, mobileTradeOfferIDs, true).ConfigureAwait(false)) { diff --git a/ArchiSteamFarm/ArchiWebHandler.cs b/ArchiSteamFarm/ArchiWebHandler.cs index 5b3389b37..718d28bc4 100644 --- a/ArchiSteamFarm/ArchiWebHandler.cs +++ b/ArchiSteamFarm/ArchiWebHandler.cs @@ -43,6 +43,7 @@ namespace ArchiSteamFarm { private const string ISteamApps = "ISteamApps"; private const string ISteamUserAuth = "ISteamUserAuth"; private const string ITwoFactorService = "ITwoFactorService"; + private const ushort MaxItemsInSingleInventoryRequest = 5000; private const byte MinSessionValidityInSeconds = GlobalConfig.DefaultConnectionTimeout / 6; private const string SteamCommunityHost = "steamcommunity.com"; private const string SteamCommunityURL = "https://" + SteamCommunityHost; @@ -541,7 +542,7 @@ namespace ArchiSteamFarm { } [SuppressMessage("ReSharper", "FunctionComplexityOverflow")] - internal async Task> GetInventory(ulong steamID = 0, uint appID = Steam.Asset.SteamAppID, byte contextID = Steam.Asset.SteamCommunityContextID, bool? tradable = null, IReadOnlyCollection wantedTypes = null, IReadOnlyCollection wantedRealAppIDs = null) { + internal async Task> GetInventory(ulong steamID = 0, uint appID = Steam.Asset.SteamAppID, byte contextID = Steam.Asset.SteamCommunityContextID, bool? tradable = null, IReadOnlyCollection wantedTypes = null, IReadOnlyCollection wantedRealAppIDs = null, IReadOnlyCollection<(uint AppID, Steam.Asset.EType Type)> skippedSets = null) { if ((appID == 0) || (contextID == 0)) { Bot.ArchiLogger.LogNullError(nameof(appID) + " || " + nameof(contextID)); return null; @@ -564,8 +565,7 @@ namespace ArchiSteamFarm { HashSet result = new HashSet(); - // 5000 is maximum allowed count per single request - string request = "/inventory/" + steamID + "/" + appID + "/" + contextID + "?count=5000&l=english"; + string request = "/inventory/" + steamID + "/" + appID + "/" + contextID + "?count=" + MaxItemsInSingleInventoryRequest + "&l=english"; ulong startAssetID = 0; await InventorySemaphore.WaitAsync().ConfigureAwait(false); @@ -625,7 +625,7 @@ namespace ArchiSteamFarm { foreach (Steam.Asset asset in response.Assets.Where(asset => asset != null)) { if (descriptionMap.TryGetValue(asset.ClassID, out (bool Tradable, Steam.Asset.EType Type, uint RealAppID) description)) { - if ((tradable.HasValue && (description.Tradable != tradable.Value)) || (wantedTypes?.Contains(description.Type) == false) || (wantedRealAppIDs?.Contains(description.RealAppID) == false)) { + if ((tradable.HasValue && (description.Tradable != tradable.Value)) || (wantedTypes?.Contains(description.Type) == false) || (wantedRealAppIDs?.Contains(description.RealAppID) == false) || (skippedSets?.Contains((description.RealAppID, description.Type)) == true)) { continue; } @@ -1146,26 +1146,43 @@ namespace ArchiSteamFarm { return response == null ? ((EResult Result, EPurchaseResultDetail? PurchaseResult)?) null : (response.Result, response.PurchaseResultDetail); } - internal async Task<(bool Success, HashSet MobileTradeOfferIDs)> SendTradeOffer(ulong partnerID, IReadOnlyCollection itemsToGive, string token = null) { - if ((partnerID == 0) || (itemsToGive == null) || (itemsToGive.Count == 0)) { - Bot.ArchiLogger.LogNullError(nameof(partnerID) + " || " + nameof(itemsToGive)); + internal async Task<(bool Success, HashSet MobileTradeOfferIDs)> SendTradeOffer(ulong partnerID, IReadOnlyCollection itemsToGive = null, IReadOnlyCollection itemsToReceive = null, string token = null, bool forcedSingleOffer = false) { + if ((partnerID == 0) || (((itemsToGive == null) || (itemsToGive.Count == 0)) && ((itemsToReceive == null) || (itemsToReceive.Count == 0)))) { + Bot.ArchiLogger.LogNullError(nameof(partnerID) + " || (" + nameof(itemsToGive) + " && " + nameof(itemsToReceive) + ")"); return (false, null); } Steam.TradeOfferSendRequest singleTrade = new Steam.TradeOfferSendRequest(); HashSet trades = new HashSet { singleTrade }; - foreach (Steam.Asset itemToGive in itemsToGive) { - if (singleTrade.ItemsToGive.Assets.Count >= Trading.MaxItemsPerTrade) { - if (trades.Count >= Trading.MaxTradesPerAccount) { - break; + if (itemsToGive != null) { + foreach (Steam.Asset itemToGive in itemsToGive) { + if (!forcedSingleOffer && (singleTrade.ItemsToGive.Assets.Count + singleTrade.ItemsToReceive.Assets.Count >= Trading.MaxItemsPerTrade)) { + if (trades.Count >= Trading.MaxTradesPerAccount) { + break; + } + + singleTrade = new Steam.TradeOfferSendRequest(); + trades.Add(singleTrade); } - singleTrade = new Steam.TradeOfferSendRequest(); - trades.Add(singleTrade); + singleTrade.ItemsToGive.Assets.Add(itemToGive); } + } - singleTrade.ItemsToGive.Assets.Add(itemToGive); + if (itemsToReceive != null) { + foreach (Steam.Asset itemToReceive in itemsToReceive) { + if (!forcedSingleOffer && (singleTrade.ItemsToGive.Assets.Count + singleTrade.ItemsToReceive.Assets.Count >= Trading.MaxItemsPerTrade)) { + if (trades.Count >= Trading.MaxTradesPerAccount) { + break; + } + + singleTrade = new Steam.TradeOfferSendRequest(); + trades.Add(singleTrade); + } + + singleTrade.ItemsToReceive.Assets.Add(itemToReceive); + } } const string request = "/tradeoffer/new/send"; diff --git a/ArchiSteamFarm/BotConfig.cs b/ArchiSteamFarm/BotConfig.cs index 76556d413..1dd85a4e9 100644 --- a/ArchiSteamFarm/BotConfig.cs +++ b/ArchiSteamFarm/BotConfig.cs @@ -401,7 +401,8 @@ namespace ArchiSteamFarm { SteamTradeMatcher = 2, MatchEverything = 4, DontAcceptBotTrades = 8, - All = AcceptDonations | SteamTradeMatcher | MatchEverything | DontAcceptBotTrades + MatchActively = 16, + All = AcceptDonations | SteamTradeMatcher | MatchEverything | DontAcceptBotTrades | MatchActively } // ReSharper disable UnusedMember.Global diff --git a/ArchiSteamFarm/Statistics.cs b/ArchiSteamFarm/Statistics.cs index 1746b0e34..a41f205ba 100644 --- a/ArchiSteamFarm/Statistics.cs +++ b/ArchiSteamFarm/Statistics.cs @@ -25,10 +25,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Json; +using ArchiSteamFarm.Localization; using Newtonsoft.Json; namespace ArchiSteamFarm { internal sealed class Statistics : IDisposable { + private const byte MaxMatchedBotsHard = 20; + private const byte MaxMatchesBotsSoft = 10; + private const byte MaxMatchingRounds = 10; private const byte MinAnnouncementCheckTTL = 6; // Minimum amount of hours we must wait before checking eligibility for Announcement, should be lower than MinPersonaStateTTL private const byte MinHeartBeatTTL = 10; // Minimum amount of minutes we must wait before sending next HeartBeat private const byte MinItemsCount = 100; // Minimum amount of items to be eligible for public listing @@ -43,6 +47,7 @@ namespace ArchiSteamFarm { }; private readonly Bot Bot; + private readonly Timer MatchActivelyTimer; private readonly SemaphoreSlim RequestsSemaphore = new SemaphoreSlim(1, 1); private DateTime LastAnnouncementCheck; @@ -50,9 +55,22 @@ namespace ArchiSteamFarm { private DateTime LastPersonaStateRequest; private bool ShouldSendHeartBeats; - internal Statistics(Bot bot) => Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + internal Statistics(Bot bot) { + Bot = bot ?? throw new ArgumentNullException(nameof(bot)); - public void Dispose() => RequestsSemaphore.Dispose(); + // TODO: This should start from 1 hour, not 1 minute + MatchActivelyTimer = new Timer( + async e => await MatchActively().ConfigureAwait(false), + null, + TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(Program.GlobalConfig.LoginLimiterDelay + Program.LoadBalancingDelay * Bot.Bots.Count), // Delay + TimeSpan.FromDays(1) // Period + ); + } + + public void Dispose() { + MatchActivelyTimer.Dispose(); + RequestsSemaphore.Dispose(); + } internal async Task OnHeartBeat() { // Request persona update if needed @@ -78,8 +96,7 @@ namespace ArchiSteamFarm { { "Guid", Program.GlobalDatabase.Guid.ToString("N") } }; - // We don't need retry logic here - if (await Program.WebBrowser.UrlPost(request, data, maxTries: 1).ConfigureAwait(false) != null) { + if (await Program.WebBrowser.UrlPost(request, data).ConfigureAwait(false) != null) { LastHeartBeat = DateTime.UtcNow; } } finally { @@ -103,7 +120,7 @@ namespace ArchiSteamFarm { // Don't announce if we don't meet conditions string tradeToken; - if (!await ShouldAnnounce().ConfigureAwait(false) || string.IsNullOrEmpty(tradeToken = await Bot.ArchiHandler.GetTradeToken().ConfigureAwait(false))) { + if (!await IsEligibleForMatching().ConfigureAwait(false) || string.IsNullOrEmpty(tradeToken = await Bot.ArchiHandler.GetTradeToken().ConfigureAwait(false))) { LastAnnouncementCheck = DateTime.UtcNow; ShouldSendHeartBeats = false; return; @@ -111,6 +128,7 @@ namespace ArchiSteamFarm { HashSet acceptedMatchableTypes = Bot.BotConfig.MatchableTypes.Where(type => AcceptedMatchableTypes.Contains(type)).ToHashSet(); if (acceptedMatchableTypes.Count == 0) { + Bot.ArchiLogger.LogNullError(nameof(acceptedMatchableTypes)); LastAnnouncementCheck = DateTime.UtcNow; ShouldSendHeartBeats = false; return; @@ -144,7 +162,7 @@ namespace ArchiSteamFarm { { "TradeToken", tradeToken } }; - // We don't need retry logic here + // Listing is free to deny our announce request, hence we don't retry if (await Program.WebBrowser.UrlPost(request, data, maxTries: 1).ConfigureAwait(false) != null) { LastAnnouncementCheck = DateTime.UtcNow; ShouldSendHeartBeats = true; @@ -154,7 +172,14 @@ namespace ArchiSteamFarm { } } - private async Task ShouldAnnounce() { + private static async Task> GetListedUsers() { + const string request = URL + "/Api/Bots"; + + WebBrowser.ObjectResponse> objectResponse = await Program.WebBrowser.UrlGetToJsonObject>(request).ConfigureAwait(false); + return objectResponse?.Content; + } + + private async Task IsEligibleForMatching() { // Bot must have ASF 2FA if (!Bot.HasMobileAuthenticator) { return false; @@ -178,5 +203,316 @@ namespace ArchiSteamFarm { // Bot must have valid API key (e.g. not being restricted account) return await Bot.ArchiWebHandler.HasValidApiKey().ConfigureAwait(false); } + + private async Task MatchActively() { + // TODO: This function has a lot of debug leftovers for logic testing, once that period is over, get rid of them + bool match = true; + + Bot.ArchiLogger.LogGenericDebug("Matching started!"); + + for (byte i = 0; (i < MaxMatchingRounds) && match; i++) { + if (i > 0) { + // After each round we wait at least 5 minutes for all bots to react + Bot.ArchiLogger.LogGenericDebug("Cooldown..."); + await Task.Delay(5 * 60 * 1000).ConfigureAwait(false); + } + + Bot.ArchiLogger.LogGenericDebug("Now matching, round #" + i); + match = await MatchActivelyRound().ConfigureAwait(false); + Bot.ArchiLogger.LogGenericDebug("Matching ended, round #" + i); + } + + Bot.ArchiLogger.LogGenericDebug("Matching finished!"); + } + + private async Task MatchActivelyRound() { + // TODO: This function has a lot of debug leftovers for logic testing, once that period is over, get rid of them + if (!Bot.IsConnectedAndLoggedOn || Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchEverything) || !Bot.BotConfig.TradingPreferences.HasFlag(BotConfig.ETradingPreferences.MatchActively) || !await IsEligibleForMatching().ConfigureAwait(false)) { + Bot.ArchiLogger.LogGenericDebug("User not eligible for this function, returning"); + return false; + } + + HashSet acceptedMatchableTypes = Bot.BotConfig.MatchableTypes.Where(type => AcceptedMatchableTypes.Contains(type)).ToHashSet(); + if (acceptedMatchableTypes.Count == 0) { + Bot.ArchiLogger.LogGenericDebug("No acceptable matchable types, returning"); + return false; + } + + HashSet ourInventory = await Bot.ArchiWebHandler.GetInventory(Bot.SteamID, tradable: true, wantedTypes: acceptedMatchableTypes).ConfigureAwait(false); + if ((ourInventory == null) || (ourInventory.Count == 0)) { + Bot.ArchiLogger.LogGenericDebug("Empty inventory, returning"); + return false; + } + + Dictionary classIDs = new Dictionary(); + + foreach (Steam.Asset item in ourInventory) { + if (classIDs.TryGetValue(item.ClassID, out uint amount)) { + classIDs[item.ClassID] = amount + item.Amount; + } else { + classIDs.Add(item.ClassID, item.Amount); + } + } + + if (classIDs.Values.All(amount => amount <= 1)) { + // User doesn't have any dupes in the inventory + Bot.ArchiLogger.LogGenericDebug("No dupes in inventory, returning"); + return false; + } + + HashSet listedUsers = await GetListedUsers().ConfigureAwait(false); + if ((listedUsers == null) || (listedUsers.Count == 0)) { + Bot.ArchiLogger.LogGenericDebug("No listed users, returning"); + return false; + } + + Dictionary<(uint AppID, Steam.Asset.EType Type), Dictionary> ourInventoryState = Trading.GetInventoryState(ourInventory); + + byte emptyMatches = 0; + HashSet<(uint AppID, Steam.Asset.EType Type)> skippedSets = new HashSet<(uint AppID, Steam.Asset.EType Type)>(); + + foreach (ListedUser listedUser in listedUsers.Where(listedUser => listedUser.MatchEverything && !Bot.IsBlacklistedFromTrades(listedUser.SteamID)).OrderByDescending(listedUser => listedUser.Score).Take(MaxMatchedBotsHard)) { + Bot.ArchiLogger.LogGenericDebug("Now matching " + listedUser.SteamID + "..."); + + HashSet theirInventory = await Bot.ArchiWebHandler.GetInventory(listedUser.SteamID, tradable: true, wantedTypes: acceptedMatchableTypes, skippedSets: skippedSets).ConfigureAwait(false); + if ((theirInventory == null) || (theirInventory.Count == 0)) { + Bot.ArchiLogger.LogGenericDebug("Inventory of " + listedUser.SteamID + " is empty, continuing..."); + continue; + } + + Dictionary<(uint AppID, Steam.Asset.EType Type), Dictionary> theirInventoryState = Trading.GetInventoryState(theirInventory); + + Dictionary classIDsToGive = new Dictionary(); + Dictionary classIDsToReceive = new Dictionary(); + HashSet<(uint AppID, Steam.Asset.EType Type)> skippedSetsThisTrade = new HashSet<(uint AppID, Steam.Asset.EType Type)>(); + + foreach (KeyValuePair<(uint AppID, Steam.Asset.EType Type), Dictionary> ourInventoryStateSet in ourInventoryState.Where(set => listedUser.MatchableTypes.Contains(set.Key.Type) && set.Value.Values.Any(count => count > 1))) { + if (!theirInventoryState.TryGetValue(ourInventoryStateSet.Key, out Dictionary theirItems)) { + continue; + } + + bool match; + + do { + match = false; + + foreach (KeyValuePair ourItem in ourInventoryStateSet.Value.Where(item => item.Value > 1).OrderByDescending(item => item.Value)) { + foreach (KeyValuePair theirItem in theirItems.OrderBy(item => ourInventoryStateSet.Value.TryGetValue(item.Key, out uint ourAmount) ? ourAmount : 0)) { + if (ourInventoryStateSet.Value.TryGetValue(theirItem.Key, out uint ourAmountOfTheirItem) && (ourItem.Value <= ourAmountOfTheirItem + 1)) { + continue; + } + + Bot.ArchiLogger.LogGenericDebug("Found a match: our " + ourItem.Key + " for theirs " + theirItem.Key); + + // Skip this set from the remaining of this round + skippedSetsThisTrade.Add(ourInventoryStateSet.Key); + + // Update our state based on given items + classIDsToGive[ourItem.Key] = classIDsToGive.TryGetValue(ourItem.Key, out uint givenAmount) ? givenAmount + 1 : 1; + ourInventoryStateSet.Value[ourItem.Key] = ourItem.Value - 1; + + // Update our state based on received items + classIDsToReceive[theirItem.Key] = classIDsToReceive.TryGetValue(theirItem.Key, out uint receivedAmount) ? receivedAmount + 1 : 1; + ourInventoryStateSet.Value[theirItem.Key] = ourAmountOfTheirItem + 1; + + // Update their state based on taken items + if (theirItems.TryGetValue(theirItem.Key, out uint theirAmount) && (theirAmount > 1)) { + theirItems[theirItem.Key] = theirAmount - 1; + } else { + theirItems.Remove(theirItem.Key); + } + + match = true; + break; + } + + if (match) { + break; + } + } + } while (match); + } + + if ((classIDsToGive.Count == 0) && (classIDsToReceive.Count == 0)) { + Bot.ArchiLogger.LogGenericDebug("No matches found, continuing..."); + + if (++emptyMatches >= MaxMatchesBotsSoft) { + break; + } + + continue; + } + + emptyMatches = 0; + + HashSet itemsToGive = Trading.GetItemsFromInventory(ourInventory, classIDsToGive); + HashSet itemsToReceive = Trading.GetItemsFromInventory(theirInventory, classIDsToReceive); + + // TODO: Debug only offer, should be removed after tests + Steam.TradeOffer debugOffer = new Steam.TradeOffer(1, 46697991, Steam.TradeOffer.ETradeOfferState.Active); + + foreach (Steam.Asset itemToGive in itemsToGive) { + debugOffer.ItemsToGive.Add(itemToGive); + } + + foreach (Steam.Asset itemToReceive in itemsToReceive) { + debugOffer.ItemsToReceive.Add(itemToReceive); + } + + if (!debugOffer.IsFairTypesExchange()) { + Bot.ArchiLogger.LogGenericDebug("CRITICAL: This offer is NOT fair!!!"); + return false; + } + + Bot.ArchiLogger.LogGenericDebug("Sending trade: our " + string.Join(", ", itemsToGive.Select(item => item.RealAppID + "/" + item.Type + " " + item.ClassID + " of " + item.Amount))); + + (bool success, HashSet mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(listedUser.SteamID, itemsToGive, itemsToReceive, listedUser.TradeToken, true).ConfigureAwait(false); + + if ((mobileTradeOfferIDs != null) && (mobileTradeOfferIDs.Count > 0) && Bot.HasMobileAuthenticator) { + if (!await Bot.Actions.AcceptConfirmations(true, Steam.ConfirmationDetails.EType.Trade, listedUser.SteamID, mobileTradeOfferIDs, true).ConfigureAwait(false)) { + return false; + } + } + + if (!success) { + Bot.ArchiLogger.LogGenericDebug("Trade failed (?), continuing..."); + continue; + } + + Bot.ArchiLogger.LogGenericDebug("Trade succeeded!"); + + foreach (KeyValuePair classIDToGive in classIDsToGive) { + if (!classIDs.TryGetValue(classIDToGive.Key, out uint amount)) { + continue; + } + + if (amount <= classIDToGive.Value) { + classIDs.Remove(classIDToGive.Key); + } else { + classIDs[classIDToGive.Key] = amount - classIDToGive.Value; + } + } + + if (classIDs.Values.All(amount => amount <= 1)) { + // User doesn't have any more dupes in the inventory + Bot.ArchiLogger.LogGenericDebug("No dupes in inventory, returning"); + return false; + } + + skippedSets.UnionWith(skippedSetsThisTrade); + } + + Bot.ArchiLogger.LogGenericDebug("This round is over, we traded " + skippedSets.Count + " sets!"); + return skippedSets.Count > 0; + } + + private sealed class ListedUser { + internal readonly HashSet MatchableTypes = new HashSet(); + + [JsonProperty(PropertyName = "steam_id", Required = Required.Always)] + internal readonly ulong SteamID; + + [JsonProperty(PropertyName = "trade_token", Required = Required.Always)] + internal readonly string TradeToken; + + internal float Score => GamesCount / (float) ItemsCount; + +#pragma warning disable 649 + [JsonProperty(PropertyName = "games_count", Required = Required.Always)] + private readonly ushort GamesCount; +#pragma warning restore 649 + +#pragma warning disable 649 + [JsonProperty(PropertyName = "items_count", Required = Required.Always)] + private readonly ushort ItemsCount; +#pragma warning restore 649 + + internal bool MatchEverything { get; private set; } + + [JsonProperty(PropertyName = "matchable_backgrounds", Required = Required.Always)] + private byte MatchableBackgroundsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Steam.Asset.EType.ProfileBackground); + break; + case 1: + MatchableTypes.Add(Steam.Asset.EType.ProfileBackground); + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + return; + } + } + } + + [JsonProperty(PropertyName = "matchable_cards", Required = Required.Always)] + private byte MatchableCardsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Steam.Asset.EType.TradingCard); + break; + case 1: + MatchableTypes.Add(Steam.Asset.EType.TradingCard); + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + return; + } + } + } + + [JsonProperty(PropertyName = "matchable_emoticons", Required = Required.Always)] + private byte MatchableEmoticonsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Steam.Asset.EType.Emoticon); + break; + case 1: + MatchableTypes.Add(Steam.Asset.EType.Emoticon); + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + return; + } + } + } + + [JsonProperty(PropertyName = "matchable_foil_cards", Required = Required.Always)] + private byte MatchableFoilCardsNumber { + set { + switch (value) { + case 0: + MatchableTypes.Remove(Steam.Asset.EType.FoilTradingCard); + break; + case 1: + MatchableTypes.Add(Steam.Asset.EType.FoilTradingCard); + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + return; + } + } + } + + [JsonProperty(PropertyName = "match_everything", Required = Required.Always)] + private byte MatchEverythingNumber { + set { + switch (value) { + case 0: + MatchEverything = false; + break; + case 1: + MatchEverything = true; + break; + default: + ASF.ArchiLogger.LogGenericError(string.Format(Strings.WarningUnknownValuePleaseReport, nameof(value), value)); + return; + } + } + } + } } } diff --git a/ArchiSteamFarm/Trading.cs b/ArchiSteamFarm/Trading.cs index 02aa6e32b..4f6213122 100644 --- a/ArchiSteamFarm/Trading.cs +++ b/ArchiSteamFarm/Trading.cs @@ -43,6 +43,60 @@ namespace ArchiSteamFarm { public void Dispose() => TradesSemaphore.Dispose(); + internal static Dictionary<(uint AppID, Steam.Asset.EType Type), Dictionary> GetInventoryState(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) { + (uint RealAppID, Steam.Asset.EType Type) key = (item.RealAppID, item.Type); + + if (sets.TryGetValue(key, 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[key] = new Dictionary { { item.ClassID, item.Amount } }; + } + } + + return sets; + } + + internal static HashSet GetItemsFromInventory(IReadOnlyCollection inventory, IDictionary classIDs) { + if ((inventory == null) || (inventory.Count == 0) || (classIDs == null) || (classIDs.Count == 0)) { + ASF.ArchiLogger.LogNullError(nameof(inventory) + " || " + nameof(classIDs)); + return null; + } + + HashSet result = new HashSet(); + + foreach (Steam.Asset item in inventory) { + if (!classIDs.TryGetValue(item.ClassID, out uint amount)) { + continue; + } + + if (amount < item.Amount) { + item.Amount = amount; + } + + result.Add(item); + + if (amount == item.Amount) { + classIDs.Remove(item.ClassID); + } else { + classIDs[item.ClassID] = amount - item.Amount; + } + } + + return result; + } + internal void OnDisconnected() => IgnoredTrades.Clear(); internal async Task OnNewTrade() { @@ -75,20 +129,7 @@ namespace ArchiSteamFarm { 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 } }; - } - } - + Dictionary<(uint AppID, Steam.Asset.EType Type), Dictionary> sets = GetInventoryState(inventory); return sets.ToDictionary(set => set.Key, set => set.Value.Values.OrderBy(amount => amount).ToList()); }