diff --git a/ArchiSteamFarm.Tests/Bot.cs b/ArchiSteamFarm.Tests/Bot.cs new file mode 100644 index 000000000..5d7c06037 --- /dev/null +++ b/ArchiSteamFarm.Tests/Bot.cs @@ -0,0 +1,422 @@ +// _ _ _ ____ _ _____ +// / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ +// / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ +// / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | +// /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| +// | +// Copyright 2015-2020 Ł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.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ArchiSteamFarm.Tests { + [TestClass] + public sealed class Bot { + [TestMethod] + public void NotAllCardsPresent() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID), + CreateCard(2, appID) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint>(0); + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OneSet() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID), + CreateCard(2, appID) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 2), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void MultipleSets() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID), + CreateCard(1, appID), + CreateCard(2, appID), + CreateCard(2, appID) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 2 }, + { (appID, Steam.Asset.SteamCommunityContextID, 2), 2 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void MultipleSetsDifferentAmount() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, 2), + CreateCard(2, appID), + CreateCard(2, appID) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 2 }, + { (appID, Steam.Asset.SteamCommunityContextID, 2), 2 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void MoreCardsThanNeeded() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID), + CreateCard(1, appID), + CreateCard(2, appID), + CreateCard(3, appID) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 2), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 3), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherRarityFullSets() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, rarity: Steam.Asset.ERarity.Common), + CreateCard(1, appID, rarity: Steam.Asset.ERarity.Rare) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 1, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 2 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherRarityNoSets() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, rarity: Steam.Asset.ERarity.Common), + CreateCard(1, appID, rarity: Steam.Asset.ERarity.Rare) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint>(0); + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherRarityOneSet() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, rarity: Steam.Asset.ERarity.Common), + CreateCard(2, appID, rarity: Steam.Asset.ERarity.Common), + CreateCard(1, appID, rarity: Steam.Asset.ERarity.Uncommon), + CreateCard(2, appID, rarity: Steam.Asset.ERarity.Uncommon), + CreateCard(3, appID, rarity: Steam.Asset.ERarity.Uncommon) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 2), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 3), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherTypeFullSets() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, type: Steam.Asset.EType.TradingCard), + CreateCard(1, appID, type: Steam.Asset.EType.FoilTradingCard) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 1, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 2 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherTypeNoSets() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, type: Steam.Asset.EType.TradingCard), + CreateCard(1, appID, type: Steam.Asset.EType.FoilTradingCard) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint>(0); + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherTypeOneSet() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, type: Steam.Asset.EType.TradingCard), + CreateCard(2, appID, type: Steam.Asset.EType.TradingCard), + CreateCard(1, appID, type: Steam.Asset.EType.FoilTradingCard), + CreateCard(2, appID, type: Steam.Asset.EType.FoilTradingCard), + CreateCard(3, appID, type: Steam.Asset.EType.FoilTradingCard) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 2), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 3), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void MutliRarityAndType() { + const uint appID = 42; + + HashSet items = new HashSet { + CreateCard(1, appID, type: Steam.Asset.EType.TradingCard, rarity: Steam.Asset.ERarity.Common), + CreateCard(2, appID, type: Steam.Asset.EType.TradingCard, rarity: Steam.Asset.ERarity.Common), + + CreateCard(1, appID, type: Steam.Asset.EType.FoilTradingCard, rarity: Steam.Asset.ERarity.Uncommon), + CreateCard(2, appID, type: Steam.Asset.EType.FoilTradingCard, rarity: Steam.Asset.ERarity.Uncommon), + + CreateCard(1, appID, type: Steam.Asset.EType.FoilTradingCard, rarity: Steam.Asset.ERarity.Rare), + CreateCard(2, appID, type: Steam.Asset.EType.FoilTradingCard, rarity: Steam.Asset.ERarity.Rare), + + // for better readability and easier verification when thinking about this test the items that shall be selected for sending are the ones below this comment + CreateCard(1, appID, type: Steam.Asset.EType.TradingCard, rarity: Steam.Asset.ERarity.Uncommon), + CreateCard(2, appID, type: Steam.Asset.EType.TradingCard, rarity: Steam.Asset.ERarity.Uncommon), + CreateCard(3, appID, type: Steam.Asset.EType.TradingCard, rarity: Steam.Asset.ERarity.Uncommon), + + CreateCard(1, appID, type: Steam.Asset.EType.FoilTradingCard, rarity: Steam.Asset.ERarity.Common), + CreateCard(3, appID, type: Steam.Asset.EType.FoilTradingCard, rarity: Steam.Asset.ERarity.Common), + CreateCard(7, appID, type: Steam.Asset.EType.FoilTradingCard, rarity: Steam.Asset.ERarity.Common), + + CreateCard(2, appID, type: Steam.Asset.EType.Unknown, rarity: Steam.Asset.ERarity.Rare), + CreateCard(3, appID, type: Steam.Asset.EType.Unknown, rarity: Steam.Asset.ERarity.Rare), + CreateCard(4, appID, type: Steam.Asset.EType.Unknown, rarity: Steam.Asset.ERarity.Rare) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 3, appID); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID, Steam.Asset.SteamCommunityContextID, 1), 2 }, + { (appID, Steam.Asset.SteamCommunityContextID, 2), 2 }, + { (appID, Steam.Asset.SteamCommunityContextID, 3), 3 }, + { (appID, Steam.Asset.SteamCommunityContextID, 4), 1 }, + { (appID, Steam.Asset.SteamCommunityContextID, 7), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherAppIDFullSets() { + const uint appID0 = 42; + const uint appID1 = 43; + + HashSet items = new HashSet { + CreateCard(1, realAppID: appID0), + CreateCard(1, realAppID: appID1), + }; + + HashSet itemsToSend = GetItemsForFullBadge( + items, new Dictionary { + { appID0, 1 }, + { appID1, 1 } + } + ); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID0, Steam.Asset.SteamCommunityContextID, 1), 1 }, + { (appID1, Steam.Asset.SteamCommunityContextID, 1), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void OtherAppIDNoSets() { + const uint appID0 = 42; + const uint appID1 = 43; + + HashSet items = new HashSet { + CreateCard(1, realAppID: appID0), + CreateCard(1, realAppID: appID1), + }; + + HashSet itemsToSend = GetItemsForFullBadge( + items, new Dictionary { + { appID0, 2 }, + { appID1, 2 } + } + ); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint>(0); + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void TooManyCardsPerSet() { + const uint appID0 = 42; + const uint appID1 = 43; + const uint appID2 = 44; + + HashSet items = new HashSet { + CreateCard(1, appID0), + CreateCard(2, appID0), + CreateCard(3, appID0), + CreateCard(4, appID0) + }; + + GetItemsForFullBadge( + items, new Dictionary { + { appID0, 3 }, + { appID1, 3 }, + { appID2, 3 } + } + ); + + Assert.Fail(); + } + + [TestMethod] + public void OtherAppIDOneSet() { + const uint appID0 = 42; + const uint appID1 = 43; + const uint appID2 = 44; + + HashSet items = new HashSet { + CreateCard(1, realAppID: appID0), + CreateCard(2, realAppID: appID0), + + CreateCard(1, realAppID: appID1), + CreateCard(2, realAppID: appID1), + CreateCard(3, realAppID: appID1) + }; + + HashSet itemsToSend = GetItemsForFullBadge( + items, new Dictionary { + { appID0, 3 }, + { appID1, 3 }, + { appID2, 3 } + } + ); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID1, Steam.Asset.SteamCommunityContextID, 1), 1 }, + { (appID1, Steam.Asset.SteamCommunityContextID, 2), 1 }, + { (appID1, Steam.Asset.SteamCommunityContextID, 3), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + [TestMethod] + public void TooHighAmount() { + const uint appID0 = 42; + + HashSet items = new HashSet { + CreateCard(1, appID0, 2), + CreateCard(2, appID0) + }; + + HashSet itemsToSend = GetItemsForFullBadge(items, 2, appID0); + + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult = new Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> { + { (appID0, Steam.Asset.SteamCommunityContextID, 1), 1 }, + { (appID0, Steam.Asset.SteamCommunityContextID, 2), 1 } + }; + + AssertResultMatchesExpectation(expectedResult, itemsToSend); + } + + private static HashSet GetItemsForFullBadge(IReadOnlyCollection inventory, byte cardsPerSet, uint appID) => GetItemsForFullBadge(inventory, new Dictionary { { appID, cardsPerSet } }); + + private static HashSet GetItemsForFullBadge(IReadOnlyCollection inventory, IDictionary cardsPerSet) { + Dictionary<(uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity), List> inventorySets = ArchiSteamFarm.Trading.GetInventorySets(inventory); + + return ArchiSteamFarm.Bot.GetItemsForFullSets(inventory, inventorySets.ToDictionary(kv => kv.Key, kv => (SetsToExtract: inventorySets[kv.Key][0], cardsPerSet[kv.Key.RealAppID]))).ToHashSet(); + } + + private static Steam.Asset CreateCard(ulong classID, uint realAppID, uint amount = 1, Steam.Asset.EType type = Steam.Asset.EType.TradingCard, Steam.Asset.ERarity rarity = Steam.Asset.ERarity.Common) => new Steam.Asset(Steam.Asset.SteamAppID, Steam.Asset.SteamCommunityContextID, classID, amount, realAppID: realAppID, type: type, rarity: rarity); + + private static void AssertResultMatchesExpectation(IReadOnlyDictionary<(uint RealAppID, ulong ContextID, ulong ClassID), uint> expectedResult, IReadOnlyCollection itemsToSend) { + Dictionary<(uint RealAppID, ulong ContextID, ulong ClassID), long> realResult = itemsToSend.GroupBy(asset => (asset.RealAppID, asset.ContextID, asset.ClassID)).ToDictionary(group => group.Key, group => group.Sum(asset => asset.Amount)); + Assert.AreEqual(expectedResult.Count, realResult.Count); + Assert.IsTrue(expectedResult.All(expectation => realResult.TryGetValue(expectation.Key, out long reality) && (expectation.Value == reality))); + } + } +} diff --git a/ArchiSteamFarm/Actions.cs b/ArchiSteamFarm/Actions.cs index 3d5d4a024..2c0ab6303 100644 --- a/ArchiSteamFarm/Actions.cs +++ b/ArchiSteamFarm/Actions.cs @@ -244,9 +244,9 @@ namespace ArchiSteamFarm { } [PublicAPI] - public async Task<(bool Success, string Message)> SendInventory(uint appID = Steam.Asset.SteamAppID, ulong contextID = Steam.Asset.SteamCommunityContextID, ulong targetSteamID = 0, string? tradeToken = null, Func? filterFunction = null) { - if ((appID == 0) || (contextID == 0)) { - throw new ArgumentNullException(nameof(appID) + " || " + nameof(contextID)); + public async Task<(bool Success, string Message)> SendInventory(IReadOnlyCollection items, ulong targetSteamID = 0, string? tradeToken = null) { + if ((items == null) || (items.Count == 0)) { + throw new ArgumentNullException(nameof(items)); } if (!Bot.IsConnectedAndLoggedOn) { @@ -277,7 +277,6 @@ namespace ArchiSteamFarm { TradingScheduled = true; } - filterFunction ??= item => true; await TradingSemaphore.WaitAsync().ConfigureAwait(false); try { @@ -285,24 +284,6 @@ namespace ArchiSteamFarm { TradingScheduled = false; } - HashSet inventory; - - try { - inventory = await Bot.ArchiWebHandler.GetInventoryAsync(Bot.SteamID, appID, contextID).Where(item => item.Tradable && filterFunction(item)).ToHashSetAsync().ConfigureAwait(false); - } catch (HttpRequestException e) { - Bot.ArchiLogger.LogGenericWarningException(e); - - return (false, string.Format(Strings.WarningFailedWithError, e.Message)); - } catch (Exception e) { - Bot.ArchiLogger.LogGenericException(e); - - return (false, string.Format(Strings.WarningFailedWithError, e.Message)); - } - - if (inventory.Count == 0) { - return (false, string.Format(Strings.ErrorIsEmpty, nameof(inventory))); - } - if (!await Bot.ArchiWebHandler.MarkSentTrades().ConfigureAwait(false)) { return (false, Strings.BotLootingFailed); } @@ -315,7 +296,7 @@ namespace ArchiSteamFarm { } } - (bool success, HashSet? mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(targetSteamID, inventory, token: tradeToken).ConfigureAwait(false); + (bool success, HashSet? mobileTradeOfferIDs) = await Bot.ArchiWebHandler.SendTradeOffer(targetSteamID, items, token: tradeToken).ConfigureAwait(false); if ((mobileTradeOfferIDs != null) && (mobileTradeOfferIDs.Count > 0) && Bot.HasMobileAuthenticator) { (bool twoFactorSuccess, _) = await HandleTwoFactorAuthenticationConfirmations(true, MobileAuthenticator.Confirmation.EType.Trade, mobileTradeOfferIDs, true).ConfigureAwait(false); @@ -335,6 +316,35 @@ namespace ArchiSteamFarm { return (true, Strings.BotLootingSuccess); } + [PublicAPI] + public async Task<(bool Success, string Message)> SendInventory(uint appID = Steam.Asset.SteamAppID, ulong contextID = Steam.Asset.SteamCommunityContextID, ulong targetSteamID = 0, string? tradeToken = null, Func? filterFunction = null) { + if ((appID == 0) || (contextID == 0)) { + throw new ArgumentNullException(nameof(appID) + " || " + nameof(contextID)); + } + + if (!Bot.IsConnectedAndLoggedOn) { + return (false, Strings.BotNotConnected); + } + + filterFunction ??= item => true; + + HashSet inventory; + + try { + inventory = await Bot.ArchiWebHandler.GetInventoryAsync(Bot.SteamID, appID, contextID).Where(item => item.Tradable && filterFunction(item)).ToHashSetAsync().ConfigureAwait(false); + } catch (HttpRequestException e) { + Bot.ArchiLogger.LogGenericWarningException(e); + + return (false, string.Format(Strings.WarningFailedWithError, e.Message)); + } catch (Exception e) { + Bot.ArchiLogger.LogGenericException(e); + + return (false, string.Format(Strings.WarningFailedWithError, e.Message)); + } + + return await SendInventory(inventory, targetSteamID, tradeToken).ConfigureAwait(false); + } + [PublicAPI] public (bool Success, string Message) Start() { if (Bot.KeepRunning) { diff --git a/ArchiSteamFarm/ArchiWebHandler.cs b/ArchiSteamFarm/ArchiWebHandler.cs index 8b3b4ec10..acd1b525b 100644 --- a/ArchiSteamFarm/ArchiWebHandler.cs +++ b/ArchiSteamFarm/ArchiWebHandler.cs @@ -20,6 +20,7 @@ // limitations under the License. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -41,6 +42,8 @@ using Formatting = Newtonsoft.Json.Formatting; namespace ArchiSteamFarm { public sealed class ArchiWebHandler : IDisposable { + private static readonly ConcurrentDictionary CachedCardCountsForGame = new ConcurrentDictionary(); + [PublicAPI] public const string SteamCommunityURL = "https://" + SteamCommunityHost; @@ -1701,6 +1704,37 @@ namespace ArchiSteamFarm { return result; } + internal async Task GetCardCountForGame(uint appID) { + if (appID == 0) { + throw new ArgumentNullException(nameof(appID)); + } + + if (CachedCardCountsForGame.TryGetValue(appID, out byte result)) { + return result; + } + + using IDocument? htmlDocument = await GetGameCardsPage(appID).ConfigureAwait(false); + + if (htmlDocument == null) { + Bot.ArchiLogger.LogNullError(nameof(htmlDocument)); + + return 0; + } + + List htmlNodes = htmlDocument.SelectNodes("//div[@class='badge_card_set_cards']/div[starts-with(@class, 'badge_card_set_card')]"); + + if (htmlNodes.Count == 0) { + Bot.ArchiLogger.LogNullError(nameof(htmlNodes)); + + return 0; + } + + result = (byte) htmlNodes.Count; + CachedCardCountsForGame.TryAdd(appID, result); + + return result; + } + internal async Task GetGameCardsPage(uint appID) { if (appID == 0) { throw new ArgumentNullException(nameof(appID)); diff --git a/ArchiSteamFarm/Bot.cs b/ArchiSteamFarm/Bot.cs index 733e0f451..173d63d37 100755 --- a/ArchiSteamFarm/Bot.cs +++ b/ArchiSteamFarm/Bot.cs @@ -27,11 +27,14 @@ using System.Collections.Immutable; using System.Collections.Specialized; using System.IO; using System.Linq; +using System.Net.Http; using System.Security.Cryptography; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using AngleSharp.Dom; using ArchiSteamFarm.Collections; +using ArchiSteamFarm.Json; using ArchiSteamFarm.Localization; using ArchiSteamFarm.NLog; using ArchiSteamFarm.Plugins; @@ -52,6 +55,7 @@ namespace ArchiSteamFarm { private const byte MaxInvalidPasswordFailures = WebBrowser.MaxTries; // Max InvalidPassword failures in a row before we determine that our password is invalid (because Steam wrongly returns those, of course) private const ushort MaxMessageLength = 5000; // This is a limitation enforced by Steam private const byte MaxTwoFactorCodeFailures = WebBrowser.MaxTries; // Max TwoFactorCodeMismatch failures in a row before we determine that our 2FA credentials are invalid (because Steam wrongly returns those, of course) + private const byte MinimumCardsPerBadge = 5; private const byte RedeemCooldownInHours = 1; // 1 hour since first redeem attempt, this is a limitation enforced by Steam private const byte ReservedMessageLength = 2; // 2 for 2x optional … @@ -130,6 +134,7 @@ namespace ArchiSteamFarm { private readonly CallbackManager CallbackManager; private readonly SemaphoreSlim CallbackSemaphore = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim GamesRedeemerInBackgroundSemaphore = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim SendCompleteTypesSemaphore = new SemaphoreSlim(1, 1); private readonly Timer HeartBeatTimer; private readonly SemaphoreSlim InitializationSemaphore = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim MessagingSemaphore = new SemaphoreSlim(1, 1); @@ -140,6 +145,8 @@ namespace ArchiSteamFarm { private readonly SteamUser SteamUser; private readonly Trading Trading; + private bool SendCompleteTypesScheduled; + #pragma warning disable CS8605 private IEnumerable<(string FilePath, EFileType FileType)> RelatedFiles { get { @@ -309,6 +316,7 @@ namespace ArchiSteamFarm { BotDatabase.Dispose(); CallbackSemaphore.Dispose(); GamesRedeemerInBackgroundSemaphore.Dispose(); + SendCompleteTypesSemaphore.Dispose(); InitializationSemaphore.Dispose(); MessagingSemaphore.Dispose(); Trading.Dispose(); @@ -2831,11 +2839,7 @@ namespace ArchiSteamFarm { break; case ArchiHandler.UserNotificationsCallback.EUserNotification.Items when newNotification: - Utilities.InBackground(CardsFarmer.OnNewItemsNotification); - - if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.DismissInventoryNotifications)) { - Utilities.InBackground(ArchiWebHandler.MarkInventory); - } + OnInventoryChanged(); break; case ArchiHandler.UserNotificationsCallback.EUserNotification.Trading when newNotification: @@ -2850,6 +2854,294 @@ namespace ArchiSteamFarm { } } + private void OnInventoryChanged() { + Utilities.InBackground(CardsFarmer.OnNewItemsNotification); + + if (BotConfig.BotBehaviour.HasFlag(BotConfig.EBotBehaviour.DismissInventoryNotifications)) { + Utilities.InBackground(ArchiWebHandler.MarkInventory); + } + + if (BotConfig.CompleteTypesToSend.Count > 0) { + Utilities.InBackground(SendCompletedSets); + } + } + + [PublicAPI] + public async Task?> GetPossiblyCompletedBadgeAppIDs() { + using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(1).ConfigureAwait(false); + + if (badgePage == null) { + ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); + + return null; + } + + byte maxPages = 1; + IElement? htmlNode = badgePage.SelectSingleNode("(//a[@class='pagelink'])[last()]"); + + if (htmlNode != null) { + string lastPage = htmlNode.TextContent; + + if (string.IsNullOrEmpty(lastPage)) { + ArchiLogger.LogNullError(nameof(lastPage)); + + return null; + } + + if (!byte.TryParse(lastPage, out maxPages) || (maxPages == 0)) { + ArchiLogger.LogNullError(nameof(maxPages)); + + return null; + } + } + + HashSet? firstPageResult = GetPossiblyCompletedBadgeAppIDs(badgePage); + + if (firstPageResult == null) { + return null; + } + + if (maxPages == 1) { + return firstPageResult; + } + + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + for (byte page = 2; page <= maxPages; page++) { + HashSet? pageIDs = await GetPossiblyCompletedBadgeAppIDs(page).ConfigureAwait(false); + + if (pageIDs == null) { + return null; + } + + firstPageResult.UnionWith(pageIDs); + } + + return firstPageResult; + default: + HashSet?>> tasks = new HashSet?>>(maxPages - 1); + + for (byte page = 2; page <= maxPages; page++) { + // We need a copy of variable being passed when in for loops, as loop will proceed before our task is launched + byte currentPage = page; + tasks.Add(GetPossiblyCompletedBadgeAppIDs(currentPage)); + } + + IList?> results = await Utilities.InParallel(tasks).ConfigureAwait(false); + + foreach (HashSet? result in results) { + if (result == null) { + return null; + } + + firstPageResult.UnionWith(result); + } + + return firstPageResult; + } + } + + private async Task?> GetPossiblyCompletedBadgeAppIDs(byte page) { + if (page == 0) { + throw new ArgumentNullException(nameof(page)); + } + + using IDocument? badgePage = await ArchiWebHandler.GetBadgePage(page).ConfigureAwait(false); + + if (badgePage == null) { + ArchiLogger.LogGenericWarning(Strings.WarningCouldNotCheckBadges); + + return null; + } + + return GetPossiblyCompletedBadgeAppIDs(badgePage); + } + + private HashSet? GetPossiblyCompletedBadgeAppIDs(IDocument badgePage) { + if (badgePage == null) { + throw new ArgumentNullException(nameof(badgePage)); + } + + List linkElements = badgePage.SelectNodes("//a[@class='badge_craft_button']"); + + // We need to also select all badges that we have max level, as those will not display with a craft button + // Level 5 is maximum level for card badges according to https://steamcommunity.com/tradingcards/faq + linkElements.AddRange(badgePage.SelectNodes("//div[@class='badges_sheet']/div[contains(@class, 'badge_row') and .//div[@class='badge_info_description']/div[contains(text(), 'Level 5')]]/a[@class='badge_row_overlay']")); + + if (linkElements.Count == 0) { + return new HashSet(0); + } + + HashSet result = new HashSet(linkElements.Count); + + foreach (string? badgeUri in linkElements.Select(htmlNode => htmlNode.GetAttribute("href"))) { + if (string.IsNullOrEmpty(badgeUri)) { + ArchiLogger.LogNullError(nameof(badgeUri)); + + return null; + } + + // URIs to foil badges are the same as for normal badges except they end with "?border=1" + string? appIDText = badgeUri.Split('?', StringSplitOptions.RemoveEmptyEntries)[0].Split('/', StringSplitOptions.RemoveEmptyEntries)[^1]; + + if (!uint.TryParse(appIDText, out uint appID) || (appID == 0)) { + ArchiLogger.LogNullError(nameof(appID)); + + return null; + } + + result.Add(appID); + } + + return result; + } + + private async Task SendCompletedSets() { + lock (SendCompleteTypesSemaphore) { + if (SendCompleteTypesScheduled) { + return; + } + + SendCompleteTypesScheduled = true; + } + + await SendCompleteTypesSemaphore.WaitAsync().ConfigureAwait(false); + + try { + lock (SendCompleteTypesSemaphore) { + SendCompleteTypesScheduled = false; + } + + HashSet? appIDs = await GetPossiblyCompletedBadgeAppIDs().ConfigureAwait(false); + + if ((appIDs == null) || (appIDs.Count == 0)) { + return; + } + + HashSet inventory; + + try { + inventory = await ArchiWebHandler.GetInventoryAsync() + .Where(item => item.Tradable && appIDs.Contains(item.RealAppID) && BotConfig.CompleteTypesToSend.Contains(item.Type)) + .ToHashSetAsync() + .ConfigureAwait(false); + } catch (HttpRequestException e) { + ArchiLogger.LogGenericWarningException(e); + + return; + } catch (Exception e) { + ArchiLogger.LogGenericException(e); + + return; + } + + if (inventory.Count == 0) { + ArchiLogger.LogGenericWarning(string.Format(Strings.ErrorIsEmpty), nameof(inventory)); + + return; + } + + Dictionary<(uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity), List> inventorySets = Trading.GetInventorySets(inventory); + appIDs.IntersectWith(inventorySets.Where(kv => kv.Value.Count >= MinimumCardsPerBadge).Select(kv => kv.Key.RealAppID)); + Dictionary? cardCountPerAppID = await LoadCardsPerSet(appIDs).ConfigureAwait(false); + + if ((cardCountPerAppID == null) || (cardCountPerAppID.Count == 0)) { + return; + } + + Dictionary<(uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity), (uint Sets, byte CardsPerSet)> itemsToTakePerInventorySet = inventorySets.Where(kv => appIDs.Contains(kv.Key.RealAppID)).ToDictionary(kv => kv.Key, kv => (kv.Value[0], cardCountPerAppID[kv.Key.RealAppID])); + + if (itemsToTakePerInventorySet.Values.All(value => value.Sets == 0)) { + return; + } + + HashSet result = GetItemsForFullSets(inventory, itemsToTakePerInventorySet); + + if (result.Count > 0) { + await Actions.SendInventory(result).ConfigureAwait(false); + } + } finally { + SendCompleteTypesSemaphore.Release(); + } + } + + [PublicAPI] + public async Task?> LoadCardsPerSet(ISet appIDs) { + if ((appIDs == null) || (appIDs.Count == 0)) { + throw new ArgumentNullException(nameof(appIDs)); + } + + switch (ASF.GlobalConfig?.OptimizationMode) { + case GlobalConfig.EOptimizationMode.MinMemoryUsage: + Dictionary result = new Dictionary(appIDs.Count); + + foreach (uint appID in appIDs) { + byte cardCount = await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false); + + if (cardCount == 0) { + return null; + } + + result.Add(appID, cardCount); + } + + return result; + default: + IEnumerable> tasks = appIDs.Select(async appID => (AppID: appID, Cards: await ArchiWebHandler.GetCardCountForGame(appID).ConfigureAwait(false))); + IList<(uint AppID, byte Cards)> results = await Utilities.InParallel(tasks).ConfigureAwait(false); + + if (results.Any(tuple => tuple.Cards == 0)) { + return null; + } + + return results.ToDictionary(res => res.AppID, res => res.Cards); + } + } + + [PublicAPI] + public static HashSet GetItemsForFullSets(IReadOnlyCollection inventory, IReadOnlyDictionary<(uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity), (uint SetsToExtract, byte ItemsPerSet)> amountsToExtract) { + if ((inventory == null) || (inventory.Count == 0) || (amountsToExtract == null) || (amountsToExtract.Count == 0)) { + throw new ArgumentNullException(nameof(inventory) + " || " + nameof(amountsToExtract)); + } + + HashSet result = new HashSet(); + Dictionary<(uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity), Dictionary>> itemsPerClassIDPerSet = inventory.GroupBy(item => (item.RealAppID, item.Type, item.Rarity)).ToDictionary(grouping => grouping.Key, grouping => grouping.GroupBy(item => item.ClassID).ToDictionary(group => group.Key, group => group.ToHashSet())); + + foreach (((uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity) set, (uint setsToExtract, byte itemsPerSet)) in amountsToExtract) { + if (!itemsPerClassIDPerSet.TryGetValue(set, out Dictionary>? itemsPerClassID)) { + continue; + } + + if (itemsPerSet < itemsPerClassID.Count) { + throw new ArgumentOutOfRangeException(nameof(inventory) + " && " + nameof(amountsToExtract)); + } + + if (itemsPerSet > itemsPerClassID.Count) { + continue; + } + + foreach (HashSet itemsOfClass in itemsPerClassID.Values) { + uint classRemaining = setsToExtract; + + foreach (Steam.Asset item in itemsOfClass.TakeWhile(item => classRemaining > 0)) { + if (item.Amount > classRemaining) { + Steam.Asset itemToSend = item.CreateShallowCopy(); + itemToSend.Amount = classRemaining; + result.Add(itemToSend); + + classRemaining = 0; + } else { + result.Add(item); + + classRemaining -= item.Amount; + } + } + } + } + + return result; + } + private void OnVanityURLChangedCallback(ArchiHandler.VanityURLChangedCallback callback) { if (callback == null) { throw new ArgumentNullException(nameof(callback)); diff --git a/ArchiSteamFarm/BotConfig.cs b/ArchiSteamFarm/BotConfig.cs index 5aec701c7..489bf2715 100644 --- a/ArchiSteamFarm/BotConfig.cs +++ b/ArchiSteamFarm/BotConfig.cs @@ -110,6 +110,9 @@ namespace ArchiSteamFarm { private const byte SteamTradeTokenLength = 8; + [PublicAPI] + public static readonly ImmutableHashSet AllowedCompleteTypesToSend = ImmutableHashSet.Create(Steam.Asset.EType.TradingCard, Steam.Asset.EType.FoilTradingCard); + [PublicAPI] public static readonly ImmutableList DefaultFarmingOrders = ImmutableList.Empty; @@ -119,6 +122,9 @@ namespace ArchiSteamFarm { [PublicAPI] public static readonly ImmutableHashSet DefaultLootableTypes = ImmutableHashSet.Create(Steam.Asset.EType.BoosterPack, Steam.Asset.EType.FoilTradingCard, Steam.Asset.EType.TradingCard); + [PublicAPI] + public static readonly ImmutableHashSet DefaultCompleteTypesToSend = ImmutableHashSet.Empty; + [PublicAPI] public static readonly ImmutableHashSet DefaultMatchableTypes = ImmutableHashSet.Create(Steam.Asset.EType.TradingCard); @@ -184,6 +190,9 @@ namespace ArchiSteamFarm { [JsonProperty(Required = Required.DisallowNull)] public readonly bool SendOnFarmingFinished = DefaultSendOnFarmingFinished; + [JsonProperty(Required = Required.DisallowNull)] + public readonly ImmutableHashSet CompleteTypesToSend = DefaultCompleteTypesToSend; + [JsonProperty(Required = Required.DisallowNull)] public readonly byte SendTradePeriod = DefaultSendTradePeriod; @@ -322,6 +331,10 @@ namespace ArchiSteamFarm { return (false, string.Format(Strings.ErrorConfigPropertyInvalid, nameof(LootableTypes), lootableType)); } + foreach (Steam.Asset.EType completableType in CompleteTypesToSend.Where(completableType => !Enum.IsDefined(typeof(Steam.Asset.EType), completableType) || !AllowedCompleteTypesToSend.Contains(completableType))) { + return (false, string.Format(Strings.ErrorConfigPropertyInvalid, nameof(CompleteTypesToSend), completableType)); + } + foreach (Steam.Asset.EType matchableType in MatchableTypes.Where(matchableType => !Enum.IsDefined(typeof(Steam.Asset.EType), matchableType))) { return (false, string.Format(Strings.ErrorConfigPropertyInvalid, nameof(MatchableTypes), matchableType)); } @@ -530,6 +543,8 @@ namespace ArchiSteamFarm { public bool ShouldSerializeTransferableTypes() => ShouldSerializeDefaultValues || ((TransferableTypes != DefaultTransferableTypes) && !TransferableTypes.SetEquals(DefaultTransferableTypes)); public bool ShouldSerializeUseLoginKeys() => ShouldSerializeDefaultValues || (UseLoginKeys != DefaultUseLoginKeys); + public bool ShouldSerializeCompleteTypesToSend() => ShouldSerializeDefaultValues || ((CompleteTypesToSend != DefaultCompleteTypesToSend) && !CompleteTypesToSend.SetEquals(DefaultCompleteTypesToSend)); + // ReSharper restore UnusedMember.Global } } diff --git a/ArchiSteamFarm/Trading.cs b/ArchiSteamFarm/Trading.cs index ff6cc13a0..762893731 100644 --- a/ArchiSteamFarm/Trading.cs +++ b/ArchiSteamFarm/Trading.cs @@ -345,7 +345,8 @@ namespace ArchiSteamFarm { } } - private static Dictionary<(uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity), List> GetInventorySets(IReadOnlyCollection inventory) { + [PublicAPI] + public static Dictionary<(uint RealAppID, Steam.Asset.EType Type, Steam.Asset.ERarity Rarity), List> GetInventorySets(IReadOnlyCollection inventory) { if ((inventory == null) || (inventory.Count == 0)) { throw new ArgumentNullException(nameof(inventory)); }