From 1f8b68f5eea9e357481f7824fdeb873308efdc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20G=C3=B6ls?= <6608231+Abrynos@users.noreply.github.com> Date: Wed, 21 Oct 2020 18:41:20 +0200 Subject: [PATCH] Implement #852 (#2004) * Update Actions.cs, Bot.cs, and BotConfig.cs * First round of refactoring * Check all badge pages * Update Bot.cs * Make sure multiple pages of badges work with foil badges and Make item selection algorithm work with all kinds of amount values we could get from Valve * Change order of params in Actions.SendInventory(...), Cache amount of cards for game ids, Count card elements on page when fetching card count per game, Calculate items to send for all games in parallel * Add unit tests * Make sure only one real app id and one asset type are present while computing cards to send and Test it * Update ArchiWebHandler.cs * Update Bot.cs * Fix iteration over badge pages * Update Bot.cs * Make stackable item stacks smaller if possible * Simplify code based on changing stack size of stackable items and Adapt tests * Consider only cards of the same rarity to be of one set and Add handling of already crafted level 5 badges * Implement feedback * Update Bot.cs * Update Bot.cs * Implement feedback from review * Adapt tests * Improve XPath efficiency * Check real result for additional values in unit tests * Implement feedback * Add additional test combining classID, type and rarity * Make collections readonly wherever you can * Optimize misc. code and Add SetTypesToComplete to BotConfig * Throw exception if we have more card types than cards per set * Remove SendSetsOnCompleted and Make CompleteTypesToSend empty per default * Fix existing unit tests and add new ones due to new exception * Please nitpicky Archi * Update Bot.cs * Change expected exception type * Make appID constants local * Update Bot.cs and BotConfig.cs * Do as JetBrains Rider says * Only fetch card count for badge if we have cards for it * Improve naming and fix handling of URIs for foil badges * Add Bot.LoadCardsPerSet(...), Bot.GetItemsForFullSets(...) and Trading.GetInventorySets(...) to public API * Let AWH do its job * Make Bot.GetPossiblyCompletedBadgeAppIDs() part of public API as well --- ArchiSteamFarm.Tests/Bot.cs | 422 ++++++++++++++++++++++++++++++ ArchiSteamFarm/Actions.cs | 56 ++-- ArchiSteamFarm/ArchiWebHandler.cs | 34 +++ ArchiSteamFarm/Bot.cs | 302 ++++++++++++++++++++- ArchiSteamFarm/BotConfig.cs | 15 ++ ArchiSteamFarm/Trading.cs | 3 +- 6 files changed, 803 insertions(+), 29 deletions(-) create mode 100644 ArchiSteamFarm.Tests/Bot.cs 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)); }