diff --git a/BoosterManager/BoosterManager.cs b/BoosterManager/BoosterManager.cs index b3aa2e4..412c981 100644 --- a/BoosterManager/BoosterManager.cs +++ b/BoosterManager/BoosterManager.cs @@ -15,7 +15,6 @@ namespace BoosterManager { public sealed class BoosterManager : IASF, IBotModules, IBotCommand2, IBotTradeOfferResults, IGitHubPluginUpdates { public string Name => nameof(BoosterManager); public string RepositoryName => "Citrinate/BoosterManager"; - public bool CanUpdate => !BoosterHandler.IsCraftingOneTimeBoosters(); public Version Version => typeof(BoosterManager).Assembly.GetName().Version ?? new Version("0"); public Task OnLoaded() { @@ -45,11 +44,6 @@ public Task OnASFInit(IReadOnlyDictionary? additionalConfig BoosterHandler.AllowCraftUnmarketableBoosters = configProperty.Value.GetBoolean(); break; } - case "BoosterDelayBetweenBots" when configProperty.Value.ValueKind == JsonValueKind.Number: { - ASF.ArchiLogger.LogGenericInfo("Booster Delay Between Bots : " + configProperty.Value); - BoosterHandler.UpdateBotDelays(configProperty.Value.GetInt32()); - break; - } case "BoosterDataAPI" when configProperty.Value.ValueKind == JsonValueKind.String: { ASF.ArchiLogger.LogGenericInfo("Booster Data API : " + configProperty.Value); DataHandler.BoosterDataAPI = new Uri(configProperty.Value.GetString()!); @@ -92,7 +86,8 @@ public Task OnASFInit(IReadOnlyDictionary? additionalConfig } public Task OnBotInitModules(Bot bot, IReadOnlyDictionary? additionalConfigProperties = null) { - BoosterHandler.AddHandler(bot); + string databaseFilePath = Bot.GetFilePath(String.Format("{0}_{1}", bot.BotName, nameof(BoosterManager)), Bot.EFileType.Database); + BoosterHandler.AddHandler(bot, BoosterDatabase.CreateOrLoad(databaseFilePath)); if (additionalConfigProperties == null) { return Task.FromResult(0); @@ -106,7 +101,7 @@ public Task OnBotInitModules(Bot bot, IReadOnlyDictionary? if (gameIDs == null) { bot.ArchiLogger.LogNullError(gameIDs); } else { - BoosterHandler.BoosterHandlers[bot.BotName].SchedulePermanentBoosters(gameIDs); + BoosterHandler.BoosterHandlers[bot.BotName].ScheduleBoosters(BoosterJobType.Permanent, gameIDs.ToList(), StatusReporter.StatusLogger()); } break; } diff --git a/BoosterManager/BoosterManager.csproj b/BoosterManager/BoosterManager.csproj index 9ba86d8..2b0867c 100644 --- a/BoosterManager/BoosterManager.csproj +++ b/BoosterManager/BoosterManager.csproj @@ -2,7 +2,7 @@ Citrinate - 2.9.0.2 + 2.10.0.0 enable latest net8.0 diff --git a/BoosterManager/Boosters/Booster.cs b/BoosterManager/Boosters/Booster.cs index e2932f5..993a204 100644 --- a/BoosterManager/Boosters/Booster.cs +++ b/BoosterManager/Boosters/Booster.cs @@ -1,30 +1,32 @@ using ArchiSteamFarm.Steam; using SteamKit2; using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace BoosterManager { internal sealed class Booster { private readonly Bot Bot; - private readonly BoosterQueue BoosterQueue; + private BoosterDatabase BoosterDatabase => BoosterHandler.BoosterHandlers[Bot.BotName].BoosterDatabase; + internal readonly BoosterJob BoosterJob; internal readonly uint GameID; - internal readonly BoosterType Type; internal readonly Steam.BoosterInfo Info; private readonly DateTime InitTime; private readonly BoosterLastCraft? LastCraft; - internal bool WasCrafted = false; + internal bool WasCrafted { get; private set; } = false; - internal Booster(Bot bot, uint gameID, BoosterType type, Steam.BoosterInfo info, BoosterQueue boosterQueue, BoosterLastCraft? lastCraft) { + internal Booster(Bot bot, uint gameID, Steam.BoosterInfo info, BoosterJob boosterJob) { Bot = bot; GameID = gameID; - Type = type; Info = info; InitTime = DateTime.Now; - BoosterQueue = boosterQueue; - LastCraft = lastCraft; + BoosterJob = boosterJob; + LastCraft = BoosterDatabase.GetLastCraft(gameID); } - internal async Task Craft(TradabilityPreference nTp) { + internal async Task Craft(Steam.TradabilityPreference nTp) { + await BoosterDatabase.PreCraft(this).ConfigureAwait(false); + Steam.BoostersResponse? result = await WebRequest.CreateBooster(Bot, Info.AppID, Info.Series, nTp).ConfigureAwait(false); if (result?.Result?.Result == EResult.OK) { @@ -35,19 +37,13 @@ internal Booster(Bot bot, uint gameID, BoosterType type, Steam.BoosterInfo info, } internal void SetWasCrafted() { - BoosterQueue.UpdateLastCraft(GameID, DateTime.Now); + BoosterDatabase.PostCraft(); + BoosterDatabase.SetLastCraft(GameID, DateTime.Now); WasCrafted = true; } - internal DateTime GetAvailableAtTime(int delayInSeconds = 0) { + internal DateTime GetAvailableAtTime() { if (Info.Unavailable && Info.AvailableAtTime != null) { - if (LastCraft != null) { - // If this booster had a delay the last time it was crafted then, because of the 24 hour - // cooldown, that delay still exists, and doesn't need to be added in again. If the new delay - // is bigger then the old one, then we'll still need to delay some more. - delayInSeconds = Math.Max(0, delayInSeconds - LastCraft.BoosterDelay); - } - if (LastCraft == null || LastCraft.CraftTime.AddDays(1) > Info.AvailableAtTime.Value.AddMinutes(1) || (Info.AvailableAtTime.Value.AddMinutes(1) - LastCraft.CraftTime.AddDays(1)).TotalMinutes > 2 // LastCraft time is too old to be used @@ -55,13 +51,23 @@ internal DateTime GetAvailableAtTime(int delayInSeconds = 0) { // Unavailable boosters become available exactly 24 hours after being crafted, down to the second, but Steam // doesn't tell us which second that is. To get around this, we try to save the exact craft time. If that // fails, then we use Steam's time and round up a minute get a time we know the booster will be available at. - return Info.AvailableAtTime.Value.AddMinutes(1).AddSeconds(delayInSeconds); + return Info.AvailableAtTime.Value.AddMinutes(1); } - return LastCraft.CraftTime.AddDays(1).AddSeconds(delayInSeconds); + return LastCraft.CraftTime.AddDays(1); } - return InitTime.AddSeconds(delayInSeconds); + return InitTime; + } + } + + internal class BoosterComparer : IEqualityComparer { + public bool Equals(Booster? x, Booster? y) { + return x?.GameID == y?.GameID; + } + + public int GetHashCode(Booster obj) { + return HashCode.Combine(obj.GameID); } } } diff --git a/BoosterManager/Boosters/BoosterDatabase.cs b/BoosterManager/Boosters/BoosterDatabase.cs index 6a2f175..bf26344 100644 --- a/BoosterManager/Boosters/BoosterDatabase.cs +++ b/BoosterManager/Boosters/BoosterDatabase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -12,6 +13,15 @@ internal sealed class BoosterDatabase : SerializableFile { [JsonInclude] private ConcurrentDictionary BoosterLastCrafts { get; init; } = new(); + [JsonInclude] + internal List BoosterJobs { get; private set; } = new(); + + [JsonInclude] + internal uint? CraftingGameID { get; private set; } = null; + + [JsonInclude] + internal DateTime? CraftingTime { get; private set; } = null; + [JsonConstructor] private BoosterDatabase() { } @@ -25,7 +35,7 @@ private BoosterDatabase(string filePath) : this() { protected override Task Save() => Save(this); - internal static BoosterDatabase? CreateOrLoad(string filePath) { + internal static BoosterDatabase CreateOrLoad(string filePath) { if (string.IsNullOrEmpty(filePath)) { throw new ArgumentNullException(nameof(filePath)); } @@ -42,20 +52,20 @@ private BoosterDatabase(string filePath) : this() { if (string.IsNullOrEmpty(json)) { ASF.ArchiLogger.LogGenericError(string.Format(ArchiSteamFarm.Localization.Strings.ErrorIsEmpty, nameof(json))); - return null; + return new BoosterDatabase(filePath); } boosterDatabase = json.ToJsonObject(); } catch (Exception e) { ASF.ArchiLogger.LogGenericException(e); - return null; + return new BoosterDatabase(filePath); } if (boosterDatabase == null) { ASF.ArchiLogger.LogNullError(boosterDatabase); - return null; + return new BoosterDatabase(filePath); } boosterDatabase.FilePath = filePath; @@ -79,15 +89,34 @@ private BoosterDatabase(string filePath) : this() { return null; } - internal void SetLastCraft(uint appID, DateTime craftTime, int boosterDelay) { - BoosterLastCraft newCraft = new BoosterLastCraft(craftTime, boosterDelay); + internal void SetLastCraft(uint appID, DateTime craftTime) { + BoosterLastCraft newCraft = new BoosterLastCraft(craftTime); BoosterLastCrafts.AddOrUpdate(appID, newCraft, (key, oldCraft) => { oldCraft.CraftTime = craftTime; - // boosterDelay might change, but the old delay will still be there, the real delay will be the bigger of the two - oldCraft.BoosterDelay = Math.Max(oldCraft.BoosterDelay, boosterDelay); return oldCraft; }); + + Utilities.InBackground(Save); + } + + internal void UpdateBoosterJobs(List boosterJobs) { + BoosterJobs = boosterJobs; + + Utilities.InBackground(Save); + } + + internal async Task PreCraft(Booster booster) { + CraftingGameID = booster.GameID; + CraftingTime = booster.Info.AvailableAtTime; + + await Save().ConfigureAwait(false); + } + + internal void PostCraft() { + CraftingGameID = null; + CraftingTime = null; + Utilities.InBackground(Save); } } diff --git a/BoosterManager/Boosters/BoosterDequeueReason.cs b/BoosterManager/Boosters/BoosterDequeueReason.cs new file mode 100644 index 0000000..8a417b2 --- /dev/null +++ b/BoosterManager/Boosters/BoosterDequeueReason.cs @@ -0,0 +1,11 @@ +namespace BoosterManager { + internal enum BoosterDequeueReason { + AlreadyQueued, + Crafted, + RemovedByUser, + Uncraftable, + UnexpectedlyUncraftable, + Unmarketable, + JobStopped + } +} diff --git a/BoosterManager/Boosters/BoosterJob.cs b/BoosterManager/Boosters/BoosterJob.cs new file mode 100644 index 0000000..f2b15d2 --- /dev/null +++ b/BoosterManager/Boosters/BoosterJob.cs @@ -0,0 +1,390 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ArchiSteamFarm.Collections; +using ArchiSteamFarm.Steam; +using BoosterManager.Localization; + +// Represents the state of a !booster command + +namespace BoosterManager { + internal sealed class BoosterJob { + private Bot Bot; + internal BoosterJobType JobType; + private List GameIDsToBooster; + internal StatusReporter StatusReporter; + private bool CreatedFromSaveState = false; + private readonly object LockObject = new(); + private bool JobStopped = false; + private ConcurrentHashSet UncraftableGameIDs = new(); + private ConcurrentHashSet UnmarketableGameIDs = new(); + + private BoosterHandler BoosterHandler => BoosterHandler.BoosterHandlers[Bot.BotName]; + private BoosterQueue BoosterQueue => BoosterHandler.BoosterQueue; + + private readonly List Boosters = new(); + internal bool IsFinished => UncraftedGameIDs.Count == 0; + + internal List GameIDs { + get { + lock(LockObject) { + return GameIDsToBooster.Concat(Boosters.Select(booster => booster.GameID)).ToList(); + } + } + } + + internal List UncraftedGameIDs { + get { + lock(LockObject) { + return GameIDsToBooster.Concat(Boosters.Where(booster => !booster.WasCrafted).Select(booster => booster.GameID)).ToList(); + } + } + } + + internal (List, List) QueuedAndUnqueuedBoosters { + get { + lock(LockObject) { + return (Boosters.Where(booster => !booster.WasCrafted).ToList(), GameIDsToBooster); + } + } + } + + internal int NumBoosters { + get { + lock(LockObject) { + return Boosters.Count + GameIDsToBooster.Count; + } + } + } + + internal int NumCrafted => Boosters.Where(booster => booster.WasCrafted).Count(); + internal int NumUncrafted { + get { + lock(LockObject) { + return GameIDsToBooster.Count + Boosters.Where(booster => !booster.WasCrafted).Count(); + } + } + } + + internal int GemsNeeded { + get { + lock(LockObject) { + int unqueuedGemsNeeded = 0; + if (GameIDsToBooster.Count > 0) { + foreach (var group in GameIDsToBooster.GroupBy(x => x)) { + uint gameID = group.Key; + int count = group.Count(); + Booster? booster = BoosterHandler.Jobs.GetBooster(gameID); + if (booster == null) { + continue; + } + + unqueuedGemsNeeded += (int) booster.Info.Price * count; + } + } + + return unqueuedGemsNeeded + Boosters.Where(booster => !booster.WasCrafted).Sum(booster => (int) booster.Info.Price); + } + } + } + internal Booster? NextBooster => Boosters.Where(booster => !booster.WasCrafted).OrderBy(booster => booster.GetAvailableAtTime()).FirstOrDefault(); // Not necessary to consider unqueued boosters here, based on how this property is currently used + internal DateTime? LastBoosterCraftTime { + get { + lock(LockObject) { + DateTime? lastUnqueuedBoosterCraftTime = null; + if (GameIDsToBooster.Count > 0) { + foreach (uint gameID in GameIDsToBooster.Distinct()) { + int count = BoosterHandler.Jobs.GetNumUnqueuedBoosters(gameID); // Number of unqueable boosters across all jobs for this gameID + Booster? booster = BoosterHandler.Jobs.GetBooster(gameID); // The one queued booster across all jobs for this gameID + if (booster == null) { + continue; + } + + // I don't consider here if multiple jobs have the same unqueued booster, which will get to queue first + // It's not relevant to consider this for how this property is currently being used + lastUnqueuedBoosterCraftTime = BoosterJobUtilities.MaxDateTime(lastUnqueuedBoosterCraftTime, booster.GetAvailableAtTime().AddDays(count)); + } + } + + Booster? lastQueuedBooster = Boosters.Where(booster => !booster.WasCrafted).OrderBy(booster => booster.GetAvailableAtTime()).LastOrDefault(); + + return BoosterJobUtilities.MaxDateTime(lastQueuedBooster?.GetAvailableAtTime(), lastUnqueuedBoosterCraftTime); + } + } + } + + internal BoosterJob(Bot bot, BoosterJobType jobType, List gameIDsToBooster, StatusReporter statusReporter) { + Bot = bot; + JobType = jobType; + StatusReporter = statusReporter; + GameIDsToBooster = gameIDsToBooster; + + Start(); + } + + internal BoosterJob(Bot bot, BoosterJobType jobType, BoosterJobState jobState) : this(bot, jobType, jobState.GameIDs, jobState.StatusReporter) { + CreatedFromSaveState = true; + } + + private void Start() { + foreach (uint gameID in GameIDsToBooster) { + BoosterQueue.AddBooster(gameID, this); + } + + BoosterQueue.OnBoosterInfosUpdated += OnBoosterInfosUpdated; + BoosterQueue.OnBoosterRemoved += OnBoosterRemoved; + BoosterQueue.Start(); + } + + internal void Finish() { + BoosterQueue.OnBoosterRemoved -= OnBoosterRemoved; + + if (NumBoosters > 0) { + StatusReporter.Report(Bot, String.Format(Strings.BoosterCreationFinished, NumBoosters)); + } + } + + internal void Stop() { + JobStopped = true; + BoosterQueue.OnBoosterInfosUpdated -= OnBoosterInfosUpdated; + BoosterQueue.OnBoosterRemoved -= OnBoosterRemoved; + + lock (LockObject) { + foreach (Booster booster in Boosters.Where(booster => !booster.WasCrafted)) { + BoosterQueue.RemoveBooster(booster.GameID, BoosterDequeueReason.JobStopped); + } + } + } + + private void SaveJobState() { + if (JobStopped) { + return; + } + + // Save the current state of this job + BoosterHandler.UpdateBoosterJobs(); + } + + void OnBoosterInfosUpdated(Dictionary boosterInfos) { + try { + // At this point, all boosters that can be added to the queue have been + if (NumBoosters == 0) { + if (UnmarketableGameIDs.Count > 0) { + if (UncraftableGameIDs.Count > 0) { + StatusReporter.Report(Bot, String.Format(Strings.BoostersUncraftableAndUnmarketable, String.Join(", ", UnmarketableGameIDs)), log: CreatedFromSaveState); + } else { + StatusReporter.Report(Bot, Strings.BoostersUnmarketable, log: CreatedFromSaveState); + } + } else { + StatusReporter.Report(Bot, Strings.BoostersUncraftable, log: CreatedFromSaveState); + } + + Finish(); + + return; + } + + SaveJobState(); + + DateTime? lastBoosterCraftTime = LastBoosterCraftTime; + if (lastBoosterCraftTime == null) { + StatusReporter.Report(Bot, String.Format(Strings.QueueStatusShortWithoutTime, NumBoosters, String.Format("{0:N0}", GemsNeeded)), log: CreatedFromSaveState); + } else if (lastBoosterCraftTime.Value.Date == DateTime.Today) { + StatusReporter.Report(Bot, String.Format(Strings.QueueStatusShort, NumBoosters, String.Format("{0:N0}", GemsNeeded), String.Format("{0:t}", lastBoosterCraftTime)), log: CreatedFromSaveState); + } else { + StatusReporter.Report(Bot, String.Format(Strings.QueueStatusShortWithDate, NumBoosters, String.Format("{0:N0}", GemsNeeded), String.Format("{0:d}", lastBoosterCraftTime), String.Format("{0:t}", lastBoosterCraftTime)), log: CreatedFromSaveState); + } + } finally { + BoosterQueue.OnBoosterInfosUpdated -= OnBoosterInfosUpdated; + } + } + + internal void OnBoosterRemoved(Booster booster, BoosterDequeueReason reason) { + if (!(reason == BoosterDequeueReason.Crafted + // Currently we don't prevent user from queing a booster that already exists in the permanent booster job + // This can prevent the permanent job from queueing boosters if the user sometimes removes a booster, this addresses that + || (JobType == BoosterJobType.Permanent && reason == BoosterDequeueReason.RemovedByUser) + )) { + return; + } + + lock(LockObject) { + if (GameIDsToBooster.Contains(booster.GameID)) { + // Try to queue boosters that couldn't initially be queued + BoosterQueue.AddBooster(booster.GameID, this); + BoosterQueue.Start(); + } + } + } + + internal void OnBoosterQueued(Booster booster) { + lock(LockObject) { + if (!GameIDsToBooster.Remove(booster.GameID)) { + // We queued a booster that no longer exists in our list of boosters to craft, must have been removed by the user + BoosterQueue.RemoveBooster(booster.GameID, BoosterDequeueReason.RemovedByUser); + } + + Boosters.Add(booster); + } + } + + internal void OnBoosterUnqueueable (uint gameID, BoosterDequeueReason reason) { + if (reason == BoosterDequeueReason.AlreadyQueued) { + // We'll try again later + return; + } + + // All other reasons are some variation of "we can't craft this booster" + lock(LockObject) { + GameIDsToBooster.RemoveAll(x => x == gameID); + } + + if (reason == BoosterDequeueReason.Uncraftable) { + UncraftableGameIDs.Add(gameID); + } else if (reason == BoosterDequeueReason.Unmarketable) { + UnmarketableGameIDs.Add(gameID); + } + + SaveJobState(); + } + + internal void OnBoosterDequeued(Booster booster, BoosterDequeueReason reason) { + if (reason == BoosterDequeueReason.UnexpectedlyUncraftable) { + // No longer have access to craft boosters for this game (game removed from account, or sometimes due to very rare Steam bugs) + lock(LockObject) { + Boosters.Remove(booster); + } + StatusReporter.Report(Bot, String.Format(Strings.BoosterUnexpectedlyUncraftable, booster.Info.Name, booster.GameID)); + SaveJobState(); + + return; + } + + if (JobType == BoosterJobType.Permanent) { + // Requeue this booster as permanent boosters are meant to be crafted endlessly + lock(LockObject) { + Boosters.Remove(booster); + GameIDsToBooster.Add(booster.GameID); + } + + Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.PermanentBoosterRequeued, booster.GameID)); + BoosterQueue.AddBooster(booster.GameID, this); + BoosterQueue.Start(); + + return; + } + + if (JobType == BoosterJobType.Limited) { + if (reason == BoosterDequeueReason.RemovedByUser) { + lock(LockObject) { + Boosters.Remove(booster); + } + } + + SaveJobState(); + + return; + } + } + + internal void OnInsufficientGems(Booster booster) { + StatusReporter.Report(Bot, String.Format(Strings.NotEnoughGems, String.Format("{0:N0}", booster.Info.Price - BoosterQueue.AvailableGems)), suppressDuplicateMessages: true); + } + + internal int RemoveBoosters(uint gameID) { + if (JobType == BoosterJobType.Permanent) { + return 0; + } + + int numRemoved = 0; + + lock(LockObject) { + numRemoved += GameIDsToBooster.RemoveAll(x => x == gameID); + + if (BoosterQueue.RemoveBooster(gameID, BoosterDequeueReason.RemovedByUser, this)) { + numRemoved++; + } + } + + if (numRemoved > 0) { + SaveJobState(); + + for (int i = 0; i < numRemoved; i++) { + Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterUnqueuedByUser, gameID)); + } + } + + return numRemoved; + } + + internal bool RemoveUnqueuedBooster(uint gameID) { + if (JobType == BoosterJobType.Permanent) { + return false; + } + + bool removed = false; + + lock(LockObject) { + removed = GameIDsToBooster.Remove(gameID); + } + + SaveJobState(); + + return removed; + } + + internal bool RemoveQueuedBooster(uint gameID) { + if (JobType == BoosterJobType.Permanent) { + return false; + } + + lock(LockObject) { + return BoosterQueue.RemoveBooster(gameID, BoosterDequeueReason.RemovedByUser, this); + } + } + + internal List RemoveAllBoosters() { + if (JobType == BoosterJobType.Permanent) { + return new List(); + } + + List gameIDsRemoved = new List(); + + lock(LockObject) { + foreach (uint gameID in GameIDsToBooster.ToList()) { + if (GameIDsToBooster.Remove(gameID)) { + gameIDsRemoved.Add(gameID); + } + } + + foreach (Booster booster in Boosters.ToList()) { + if (BoosterQueue.RemoveBooster(booster.GameID, BoosterDequeueReason.RemovedByUser)) { + gameIDsRemoved.Add(booster.GameID); + } + } + } + + SaveJobState(); + + return gameIDsRemoved; + } + + internal Booster? GetBooster(uint gameID) { + lock(LockObject) { + return Boosters.FirstOrDefault(booster => !booster.WasCrafted && booster.GameID == gameID); + } + } + + internal int GetNumUnqueuedBoosters(uint gameID) { + lock(LockObject) { + return GameIDsToBooster.Where(x => x == gameID).Count(); + } + } + + internal int GetNumBoosters(uint gameID) { + lock(LockObject) { + return (GetBooster(gameID) == null ? 0 : 1) + GetNumUnqueuedBoosters(gameID); + } + } + } +} diff --git a/BoosterManager/Boosters/BoosterJobState.cs b/BoosterManager/Boosters/BoosterJobState.cs new file mode 100644 index 0000000..03eae18 --- /dev/null +++ b/BoosterManager/Boosters/BoosterJobState.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace BoosterManager { + internal sealed class BoosterJobState { + [JsonInclude] + [JsonRequired] + internal List GameIDs { get; init; } + + [JsonInclude] + [JsonRequired] + internal StatusReporter StatusReporter { get; init; } + + internal BoosterJobState(BoosterJob boosterJob) { + GameIDs = boosterJob.UncraftedGameIDs; + StatusReporter = boosterJob.StatusReporter; + } + + [JsonConstructor] + internal BoosterJobState(List gameIDs, StatusReporter statusReporter) { + GameIDs = gameIDs; + StatusReporter = statusReporter; + } + } +} diff --git a/BoosterManager/Boosters/BoosterJobType.cs b/BoosterManager/Boosters/BoosterJobType.cs new file mode 100644 index 0000000..91e9fc2 --- /dev/null +++ b/BoosterManager/Boosters/BoosterJobType.cs @@ -0,0 +1,6 @@ +namespace BoosterManager { + internal enum BoosterJobType { + Limited, + Permanent + } +} diff --git a/BoosterManager/Boosters/BoosterJobUtilities.cs b/BoosterManager/Boosters/BoosterJobUtilities.cs new file mode 100644 index 0000000..c4005a3 --- /dev/null +++ b/BoosterManager/Boosters/BoosterJobUtilities.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BoosterManager { + internal static class BoosterJobUtilities { + internal static IEnumerable Limited(this IEnumerable jobs) { + return jobs.ToList().Where(job => job.JobType == BoosterJobType.Limited); + } + + internal static IEnumerable Permanent(this IEnumerable jobs) { + return jobs.ToList().Where(job => job.JobType == BoosterJobType.Permanent); + } + + internal static IEnumerable Finished(this IEnumerable jobs) { + return jobs.ToList().Where(job => job.IsFinished); + } + + internal static IEnumerable Unfinised(this IEnumerable jobs) { + return jobs.ToList().Where(job => !job.IsFinished); + } + + internal static List SaveState (this IEnumerable jobs) { + return jobs.ToList().Select(job => new BoosterJobState(job)).ToList(); + } + + internal static List GameIDs (this IEnumerable jobs) { + List gameIDs = new(); + foreach (List gameIDsFromJob in jobs.ToList().Select(job => job.GameIDs)) { + gameIDs.AddRange(gameIDsFromJob); + } + + return gameIDs; + } + + internal static List UncraftedGameIDs (this IEnumerable jobs) { + List uncraftedGameIDs = new(); + foreach (List uncraftedGameIDsFromJob in jobs.ToList().Select(job => job.UncraftedGameIDs)) { + uncraftedGameIDs.AddRange(uncraftedGameIDsFromJob); + } + + return uncraftedGameIDs; + } + + internal static (List, List) QueuedAndUnqueuedBoosters (this IEnumerable jobs) { + List queuedBoosters = new(); + List unqueuedBoosters = new(); + foreach ((List queuedBoostersFromJob, List unqueuedBoostersFromJob) in jobs.ToList().Select(job => job.QueuedAndUnqueuedBoosters)) { + queuedBoosters.AddRange(queuedBoostersFromJob); + unqueuedBoosters.AddRange(unqueuedBoostersFromJob); + } + + return (queuedBoosters, unqueuedBoosters); + } + + internal static int NumBoosters (this IEnumerable jobs) { + return jobs.ToList().Sum(job => job.NumBoosters); + } + + internal static int NumCrafted (this IEnumerable jobs) { + return jobs.ToList().Sum(job => job.NumCrafted); + } + + internal static int NumUncrafted (this IEnumerable jobs) { + return jobs.ToList().Sum(job => job.NumUncrafted); + } + + internal static int GemsNeeded (this IEnumerable jobs) { + return jobs.ToList().Sum(job => job.GemsNeeded); + } + + internal static Booster? NextBooster (this IEnumerable jobs) { + List boosters = jobs.ToList().Select(job => job.NextBooster).Where(booster => booster != null)!.ToList(); + if (boosters.Count == 0) { + return null; + } + + return boosters.OrderBy(booster => booster.GetAvailableAtTime()).First(); + } + + internal static DateTime? LastBoosterCraftTime (this IEnumerable jobs) { + DateTime? lastBoosterCraftTime = null; + foreach (BoosterJob job in jobs.ToList()) { + lastBoosterCraftTime = MaxDateTime(lastBoosterCraftTime, job.LastBoosterCraftTime); + } + + return lastBoosterCraftTime; + } + + internal static int RemoveBoosters(this IEnumerable jobs, uint gameID) { + return jobs.ToList().Sum(job => job.RemoveBoosters(gameID)); + } + + internal static bool RemoveUnqueuedBooster(this IEnumerable jobs, uint gameID) { + return jobs.ToList().Any(job => job.RemoveUnqueuedBooster(gameID)); + } + + internal static bool RemoveQueuedBooster(this IEnumerable jobs, uint gameID) { + return jobs.ToList().Any(job => job.RemoveQueuedBooster(gameID)); + } + + internal static List RemoveAllBoosters(this IEnumerable jobs) { + List removedGameIDs = new(); + foreach (List removedFromJob in jobs.ToList().Select(job => job.RemoveAllBoosters())) { + removedGameIDs.AddRange(removedFromJob); + } + + return removedGameIDs; + } + + internal static Booster? GetBooster(this IEnumerable jobs, uint gameID) { + return jobs.ToList().Select(job => job.GetBooster(gameID)).FirstOrDefault(); + } + + internal static int GetNumUnqueuedBoosters(this IEnumerable jobs, uint gameID) { + return jobs.ToList().Sum(job => job.GetNumUnqueuedBoosters(gameID)); + } + + internal static int GetNumBoosters(this IEnumerable jobs, uint gameID) { + return jobs.ToList().Sum(job => job.GetNumBoosters(gameID)); + } + + internal static DateTime? MaxDateTime(DateTime? a, DateTime? b) { + if (a == null || b == null) { + if (a == null && b == null) { + return null; + } + + return a == null ? b : a; + } + + return a > b ? a : b; + } + } +} diff --git a/BoosterManager/Boosters/BoosterLastCraft.cs b/BoosterManager/Boosters/BoosterLastCraft.cs index 0104e4f..7eb1e31 100644 --- a/BoosterManager/Boosters/BoosterLastCraft.cs +++ b/BoosterManager/Boosters/BoosterLastCraft.cs @@ -7,16 +7,11 @@ internal sealed class BoosterLastCraft { [JsonRequired] internal DateTime CraftTime { get; set; } - [JsonInclude] - [JsonRequired] - internal int BoosterDelay { get; set; } - [JsonConstructor] private BoosterLastCraft() { } - internal BoosterLastCraft(DateTime craftTime, int boosterDelay) { + internal BoosterLastCraft(DateTime craftTime) { CraftTime = craftTime; - BoosterDelay = boosterDelay; } } } diff --git a/BoosterManager/Boosters/BoosterQueue.cs b/BoosterManager/Boosters/BoosterQueue.cs index 83983ea..12e9e75 100644 --- a/BoosterManager/Boosters/BoosterQueue.cs +++ b/BoosterManager/Boosters/BoosterQueue.cs @@ -1,140 +1,151 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using ArchiSteamFarm.Collections; +using ArchiSteamFarm.Core; using ArchiSteamFarm.Steam; using BoosterManager.Localization; namespace BoosterManager { - internal sealed class BoosterQueue : IDisposable { + internal sealed class BoosterQueue { private readonly Bot Bot; - private readonly BoosterHandler BoosterHandler; private readonly Timer Timer; - private readonly ConcurrentDictionary Boosters = new(); - private Dictionary BoosterInfos = new(); + private readonly ConcurrentHashSet Boosters = new(new BoosterComparer()); private uint GooAmount = 0; private uint TradableGooAmount = 0; private uint UntradableGooAmount = 0; + internal uint AvailableGems => BoosterHandler.AllowCraftUntradableBoosters ? GooAmount : TradableGooAmount; + internal event Action>? OnBoosterInfosUpdated; + internal event Action? OnBoosterRemoved; private const int MinDelayBetweenBoosters = 5; // Minimum delay, in seconds, between booster crafts - internal int BoosterDelay = 0; // Delay, in seconds, added to all booster crafts - private readonly BoosterDatabase? BoosterDatabase; - internal event Action? OnBoosterInfosUpdated; - private float BoosterInfosUpdateBackOffMultiplier = 1.0F; - - internal BoosterQueue(Bot bot, BoosterHandler boosterHandler) { + private const float BoosterInfosUpdateBackOffMultiplierDefault = 1.0F; + private const float BoosterInfosUpdateBackOffMultiplierStep = 0.5F; + private const int BoosterInfosUpdateBackOffMinMinutes = 1; + private const int BoosterInfosUpdateBackOffMaxMinutes = 15; + private float BoosterInfosUpdateBackOffMultiplier = BoosterInfosUpdateBackOffMultiplierDefault; + private SemaphoreSlim RunSemaphore = new SemaphoreSlim(1, 1); + + internal BoosterQueue(Bot bot) { Bot = bot; - BoosterHandler = boosterHandler; Timer = new Timer( async e => await Run().ConfigureAwait(false), null, Timeout.Infinite, Timeout.Infinite ); - - string databaseFilePath = Bot.GetFilePath(String.Format("{0}_{1}", bot.BotName, nameof(BoosterManager)), Bot.EFileType.Database); - BoosterDatabase = BoosterDatabase.CreateOrLoad(databaseFilePath); - - if (BoosterDatabase == null) { - bot.ArchiLogger.LogGenericError(String.Format(ArchiSteamFarm.Localization.Strings.ErrorDatabaseInvalid, databaseFilePath)); - } - } - - public void Dispose() { - Timer.Dispose(); } internal void Start() { - UpdateTimer(DateTime.Now); + Utilities.InBackground(async() => await Run().ConfigureAwait(false)); } private async Task Run() { - if (!Bot.IsConnectedAndLoggedOn) { - UpdateTimer(DateTime.Now.AddSeconds(1)); + await RunSemaphore.WaitAsync().ConfigureAwait(false); + try { + if (!Bot.IsConnectedAndLoggedOn) { + UpdateTimer(DateTime.Now.AddSeconds(1)); - return; - } - - if (!await UpdateBoosterInfos().ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericError(Strings.BoosterInfoUpdateFailed); - UpdateTimer(DateTime.Now.AddMinutes(Math.Min(15, 1 * BoosterInfosUpdateBackOffMultiplier))); - BoosterInfosUpdateBackOffMultiplier += 0.5F; + return; + } - return; - } + // Reload the booster creator page + if (!await UpdateBoosterInfos().ConfigureAwait(false)) { + // Reload failed, try again later + Bot.ArchiLogger.LogGenericError(Strings.BoosterInfoUpdateFailed); + UpdateTimer(DateTime.Now.AddMinutes(Math.Min(BoosterInfosUpdateBackOffMaxMinutes, BoosterInfosUpdateBackOffMinMinutes * BoosterInfosUpdateBackOffMultiplier))); + BoosterInfosUpdateBackOffMultiplier += BoosterInfosUpdateBackOffMultiplierStep; - Booster? booster = GetNextCraftableBooster(BoosterType.Any); - if (booster == null) { - BoosterInfosUpdateBackOffMultiplier = 1.0F; + return; + } - return; - } - - if (DateTime.Now >= booster.GetAvailableAtTime(BoosterDelay)) { - if (booster.Info.Price > GetAvailableGems()) { - BoosterHandler.PerpareStatusReport(String.Format(Strings.NotEnoughGems, String.Format("{0:N0}", GetGemsNeeded(BoosterType.Any, wasCrafted: false) - GetAvailableGems())), suppressDuplicateMessages: true); - OnBoosterInfosUpdated += ForceUpdateBoosterInfos; - UpdateTimer(DateTime.Now.AddMinutes(Math.Min(15, (GetNumBoosters(BoosterType.OneTime) > 0 ? 1 : 15) * BoosterInfosUpdateBackOffMultiplier))); - BoosterInfosUpdateBackOffMultiplier += 0.5F; + Booster? booster = GetNextCraftableBooster(); + if (booster == null) { + // Booster queue is empty + BoosterInfosUpdateBackOffMultiplier = BoosterInfosUpdateBackOffMultiplierDefault; return; } + + if (DateTime.Now >= booster.GetAvailableAtTime()) { + // Attempt to craft the next booster in the queue + if (booster.Info.Price > AvailableGems) { + // Not enough gems, wait until we get more gems + booster.BoosterJob.OnInsufficientGems(booster); + OnBoosterInfosUpdated += ForceUpdateBoosterInfos; + UpdateTimer(DateTime.Now.AddMinutes(Math.Min(BoosterInfosUpdateBackOffMaxMinutes, BoosterInfosUpdateBackOffMinMinutes * BoosterInfosUpdateBackOffMultiplier))); + BoosterInfosUpdateBackOffMultiplier += BoosterInfosUpdateBackOffMultiplierStep; - BoosterInfosUpdateBackOffMultiplier = 1.0F; + return; + } - if (!await CraftBooster(booster).ConfigureAwait(false)) { - Bot.ArchiLogger.LogGenericError(String.Format(Strings.BoosterCreationFailed, booster.GameID)); - VerifyCraftBoosterError(booster); - UpdateTimer(DateTime.Now); + BoosterInfosUpdateBackOffMultiplier = BoosterInfosUpdateBackOffMultiplierDefault; - return; - } + if (!await CraftBooster(booster).ConfigureAwait(false)) { + // Craft failed, decide whether or not to remove this booster from the queue + Bot.ArchiLogger.LogGenericError(String.Format(Strings.BoosterCreationFailed, booster.GameID)); + VerifyCraftBoosterError(booster); + UpdateTimer(DateTime.Now); + + return; + } - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterCreationSuccess, booster.GameID)); - CheckIfFinished(booster.Type); + Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterCreationSuccess, booster.GameID)); + RemoveBooster(booster.GameID, BoosterDequeueReason.Crafted); - booster = GetNextCraftableBooster(BoosterType.Any); - if (booster == null) { - return; + booster = GetNextCraftableBooster(); + if (booster == null) { + // Queue has no more boosters in it + return; + } } - } - BoosterInfosUpdateBackOffMultiplier = 1.0F; + BoosterInfosUpdateBackOffMultiplier = BoosterInfosUpdateBackOffMultiplierDefault; - DateTime nextBoosterTime = booster.GetAvailableAtTime(BoosterDelay); - if (nextBoosterTime < DateTime.Now.AddSeconds(MinDelayBetweenBoosters)) { - nextBoosterTime = DateTime.Now.AddSeconds(MinDelayBetweenBoosters); + // Wait until the next booster is ready to craft + DateTime nextBoosterTime = booster.GetAvailableAtTime(); + if (nextBoosterTime < DateTime.Now.AddSeconds(MinDelayBetweenBoosters)) { + nextBoosterTime = DateTime.Now.AddSeconds(MinDelayBetweenBoosters); + } + + UpdateTimer(nextBoosterTime); + Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.NextBoosterCraft, String.Format("{0:T}", nextBoosterTime))); + } finally { + Utilities.InBackground( + async() => { + await Task.Delay(TimeSpan.FromSeconds(MinDelayBetweenBoosters)).ConfigureAwait(false); + RunSemaphore.Release(); + } + ); } - UpdateTimer(nextBoosterTime); - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.NextBoosterCraft, String.Format("{0:T}", nextBoosterTime))); } - internal void AddBooster(uint gameID, BoosterType type) { - void handler() { + internal void AddBooster(uint gameID, BoosterJob boosterJob) { + void handler(Dictionary boosterInfos) { try { - if (!BoosterInfos.TryGetValue(gameID, out Steam.BoosterInfo? boosterInfo)) { - Bot.ArchiLogger.LogGenericError(String.Format(Strings.BoosterUncraftable, gameID)); + if (!boosterInfos.TryGetValue(gameID, out Steam.BoosterInfo? boosterInfo)) { + // Bot cannot craft this booster + Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.BoosterUncraftable, gameID)); + boosterJob.OnBoosterUnqueueable(gameID, BoosterDequeueReason.Uncraftable); return; } - if (Boosters.TryGetValue(gameID, out Booster? existingBooster)) { - // Re-add a booster that was successfully crafted and is waiting to be cleared out of the queue - if (existingBooster.Type == BoosterType.OneTime && existingBooster.WasCrafted) { - RemoveBooster(gameID); - } - } - if (!BoosterHandler.AllowCraftUnmarketableBoosters && !MarketableApps.AppIDs.Contains(gameID)) { - Bot.ArchiLogger.LogGenericError(String.Format(Strings.BoosterUnmarketable, gameID)); + // This booster is unmarketable and the plugin was configured to not craft marketable boosters + Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.BoosterUnmarketable, gameID)); + boosterJob.OnBoosterUnqueueable(gameID, BoosterDequeueReason.Unmarketable); return; } - Booster newBooster = new Booster(Bot, gameID, type, boosterInfo, this, GetLastCraft(gameID)); - if (Boosters.TryAdd(gameID, newBooster)) { - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterQueued, gameID)); + Booster booster = new Booster(Bot, gameID, boosterInfo, boosterJob); + if (Boosters.Add(booster)) { + Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.BoosterQueued, gameID)); + boosterJob.OnBoosterQueued(booster); + } else { + boosterJob.OnBoosterUnqueueable(gameID, BoosterDequeueReason.AlreadyQueued); } } finally { OnBoosterInfosUpdated -= handler; @@ -144,16 +155,20 @@ void handler() { OnBoosterInfosUpdated += handler; } - private bool RemoveBooster(uint gameID) { - if (Boosters.TryRemove(gameID, out Booster? booster)) { - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterUnqueued, gameID)); - if (booster.Type == BoosterType.Permanent) { - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.PermanentBoosterRequeued, gameID)); - AddBooster(gameID, BoosterType.Permanent); - UpdateTimer(DateTime.Now.AddSeconds(MinDelayBetweenBoosters)); + internal bool RemoveBooster(uint gameID, BoosterDequeueReason reason, BoosterJob? boosterJob = null) { + Booster? booster = Boosters.FirstOrDefault(booster => booster.GameID == gameID); + if (booster == null) { + return false; + } - return false; - } + if (boosterJob != null && booster.BoosterJob != boosterJob) { + return false; + } + + if (Boosters.Remove(booster)) { + Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.BoosterUnqueued, booster.GameID)); + booster.BoosterJob.OnBoosterDequeued(booster, reason); + OnBoosterRemoved?.Invoke(booster, reason); return true; } @@ -161,6 +176,18 @@ private bool RemoveBooster(uint gameID) { return false; } + internal Booster? GetNextCraftableBooster() { + HashSet uncraftedBoosters = Boosters.Where(booster => !booster.WasCrafted).ToHashSet(); + if (uncraftedBoosters.Count == 0) { + return null; + } + + return uncraftedBoosters.OrderBy(booster => booster.GetAvailableAtTime()).First(); + } + + internal void ForceUpdateBoosterInfos(Dictionary _) => OnBoosterInfosUpdated -= ForceUpdateBoosterInfos; + internal bool IsUpdatingBoosterInfos() => OnBoosterInfosUpdated != null; + private async Task UpdateBoosterInfos() { if (OnBoosterInfosUpdated == null) { return true; @@ -180,25 +207,25 @@ private async Task UpdateBoosterInfos() { GooAmount = boosterPage.GooAmount; TradableGooAmount = boosterPage.TradableGooAmount; UntradableGooAmount = boosterPage.UntradableGooAmount; - BoosterInfos = boosterPage.BoosterInfos.ToDictionary(boosterInfo => boosterInfo.AppID); + OnBoosterInfosUpdated?.Invoke(boosterPage.BoosterInfos.ToDictionary(boosterInfo => boosterInfo.AppID)); - Bot.ArchiLogger.LogGenericInfo(Strings.BoosterInfoUpdateSuccess); - OnBoosterInfosUpdated?.Invoke(); + Bot.ArchiLogger.LogGenericDebug(Strings.BoosterInfoUpdateSuccess); return true; } private async Task CraftBooster(Booster booster) { - TradabilityPreference nTp; + Steam.TradabilityPreference nTp; if (!BoosterHandler.AllowCraftUntradableBoosters) { - nTp = TradabilityPreference.Tradable; + nTp = Steam.TradabilityPreference.Tradable; } else if (UntradableGooAmount > 0) { - nTp = TradableGooAmount >= booster.Info?.Price ? TradabilityPreference.Tradable : TradabilityPreference.Untradable; + nTp = TradableGooAmount >= booster.Info?.Price ? Steam.TradabilityPreference.Tradable : Steam.TradabilityPreference.Untradable; } else { - nTp = TradabilityPreference.Default; + nTp = Steam.TradabilityPreference.Default; } Steam.BoostersResponse? result = await booster.Craft(nTp).ConfigureAwait(false); + GooAmount = result?.GooAmount ?? GooAmount; TradableGooAmount = result?.TradableGooAmount ?? TradableGooAmount; UntradableGooAmount = result?.UntradableGooAmount ?? UntradableGooAmount; @@ -210,15 +237,13 @@ private void VerifyCraftBoosterError(Booster booster) { // Most errors we'll get when we try to create a booster will never go away. Retrying on an error will usually put us in an infinite loop. // Sometimes Steam will falsely report that an attempt to craft a booster failed, when it really didn't. It could also happen that the user crafted the booster on their own. // For any error we get, we'll need to refresh the booster page and see if the AvailableAtTime has changed to determine if we really failed to craft - void handler() { + void handler(Dictionary boosterInfos) { try { - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterCreationError, booster.GameID)); + Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.BoosterCreationError, booster.GameID)); - if (!BoosterInfos.TryGetValue(booster.GameID, out Steam.BoosterInfo? newBoosterInfo)) { + if (!boosterInfos.TryGetValue(booster.GameID, out Steam.BoosterInfo? newBoosterInfo)) { // No longer have access to craft boosters for this game (game removed from account, or sometimes due to very rare Steam bugs) - BoosterHandler.PerpareStatusReport(String.Format(Strings.BoosterUnexpectedlyUncraftable, booster.Info.Name, booster.GameID)); - RemoveBooster(booster.GameID); - CheckIfFinished(booster.Type); + RemoveBooster(booster.GameID, BoosterDequeueReason.UnexpectedlyUncraftable); return; } @@ -232,12 +257,12 @@ void handler() { ) { Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterUnexpectedlyCrafted, booster.GameID)); booster.SetWasCrafted(); - CheckIfFinished(booster.Type); + RemoveBooster(booster.GameID, BoosterDequeueReason.Crafted); return; } - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterCreationRetry, booster.GameID)); + Bot.ArchiLogger.LogGenericDebug(String.Format(Strings.BoosterCreationRetry, booster.GameID)); } finally { OnBoosterInfosUpdated -= handler; } @@ -246,133 +271,7 @@ void handler() { OnBoosterInfosUpdated += handler; } - private Booster? GetNextCraftableBooster(BoosterType type, bool getLast = false) { - HashSet uncraftedBoosters = GetBoosters(type, wasCrafted: false); - if (uncraftedBoosters.Count == 0) { - return null; - } - - IOrderedEnumerable orderedUncraftedBoosters = uncraftedBoosters.OrderBy(booster => booster.GetAvailableAtTime()); - if (getLast) { - return orderedUncraftedBoosters.Last(); - } - - return orderedUncraftedBoosters.First(); - } - - internal bool CheckIfFinished(BoosterType type) { - bool doneCrafting = GetNumBoosters(type, wasCrafted: true) > 0 && GetNumBoosters(type, wasCrafted: false) == 0; - if (!doneCrafting) { - return false; - } - - if (type == BoosterType.OneTime) { - BoosterHandler.PerpareStatusReport(String.Format(Strings.BoosterCreationFinished, GetNumBoosters(BoosterType.OneTime))); - } - ClearCraftedBoosters(type); - - return true; - } - - private void ClearCraftedBoosters(BoosterType type) { - HashSet boosters = GetBoosters(type, wasCrafted: true); - foreach (Booster booster in boosters) { - RemoveBooster(booster.GameID); - } - } - - internal HashSet RemoveBoosters(HashSet? gameIDs = null, int? timeLimitHours = null) { - if (gameIDs == null) { - gameIDs = new HashSet(); - } - if (timeLimitHours != null) { - if (timeLimitHours == 0) { - // Cancel everything - gameIDs.UnionWith(GetBoosterIDs(BoosterType.OneTime)); - } else { - DateTime timeLimit = DateTime.Now.AddHours(timeLimitHours.Value); - HashSet timeFilteredGameIDs = GetBoosters(BoosterType.OneTime).Where(booster => booster.GetAvailableAtTime() >= timeLimit).Select(booster => booster.GameID).ToHashSet(); - gameIDs.UnionWith(timeFilteredGameIDs); - } - } - HashSet removedGameIDs = new HashSet(); - foreach (uint gameID in gameIDs) { - if (Boosters.TryGetValue(gameID, out Booster? booster)) { - if (booster.WasCrafted) { - continue; - } - - if (RemoveBooster(gameID)) { - Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterUnqueuedByUser, gameID)); - removedGameIDs.Add(gameID); - } - } - } - CheckIfFinished(BoosterType.OneTime); - CheckIfFinished(BoosterType.Permanent); - - return removedGameIDs; - } - - internal string? GetShortStatus() { - Booster? lastOneTimeBooster = GetNextCraftableBooster(BoosterType.OneTime, getLast: true); - if (lastOneTimeBooster == null) { - return null; - } - - return String.Format(Strings.QueueStatusShort, GetNumBoosters(BoosterType.OneTime), String.Format("{0:N0}", GetGemsNeeded(BoosterType.OneTime)), String.Format("~{0:t}", lastOneTimeBooster.GetAvailableAtTime(BoosterDelay))); - } - - internal string GetStatus() { - Booster? nextBooster = GetNextCraftableBooster(BoosterType.Any); - if (nextBooster == null) { - if (OnBoosterInfosUpdated != null) { - return Strings.BoosterInfoUpdating; - } - - return Strings.QueueEmpty; - } - - HashSet responses = new HashSet(); - if (GetGemsNeeded(BoosterType.Any, wasCrafted: false) > GetAvailableGems()) { - responses.Add(String.Format("{0} :steamsad:", Strings.QueueStatusNotEnoughGems)); - if (nextBooster.Info.Price > GetAvailableGems()) { - responses.Add(String.Format(Strings.QueueStatusGemsNeeded, String.Format("{0:N0}", nextBooster.Info.Price - GetAvailableGems()))); - } - if (GetNumBoosters(BoosterType.Any, wasCrafted: false) > 1) { - responses.Add(String.Format(Strings.QueueStatusTotalGemsNeeded, String.Format("{0:N0}", GetGemsNeeded(BoosterType.Any, wasCrafted: false) - GetAvailableGems()))); - } - } - if (GetNumBoosters(BoosterType.OneTime) > 0) { - Booster? lastOneTimeBooster = GetNextCraftableBooster(BoosterType.OneTime, getLast: true); - if (lastOneTimeBooster != null) { - responses.Add(String.Format(Strings.QueueStatusLimitedBoosters, GetNumBoosters(BoosterType.OneTime, wasCrafted: true), GetNumBoosters(BoosterType.OneTime), String.Format("~{0:t}", lastOneTimeBooster.GetAvailableAtTime(BoosterDelay)), String.Format("{0:N0}", GetGemsNeeded(BoosterType.OneTime, wasCrafted: false)))); - responses.Add(String.Format(Strings.QueueStatusLimitedBoosterList, String.Join(", ", GetBoosterIDs(BoosterType.OneTime, wasCrafted: false)))); - } - } - if (GetNumBoosters(BoosterType.Permanent) > 0) { - responses.Add(String.Format(Strings.QueueStatusPermanentBoosters, String.Format("{0:N0}", GetGemsNeeded(BoosterType.Permanent)), String.Join(", ", GetBoosterIDs(BoosterType.Permanent)))); - } - if (DateTime.Now > nextBooster.GetAvailableAtTime(BoosterDelay)) { - responses.Add(String.Format(Strings.QueueStatusNextBoosterCraftingNow, nextBooster.Info.Name, nextBooster.GameID)); - } else { - responses.Add(String.Format(Strings.QueueStatusNextBoosterCraftingLater, String.Format("{0:t}", nextBooster.GetAvailableAtTime(BoosterDelay)), nextBooster.Info.Name, nextBooster.GameID)); - } - responses.Add(""); - - return String.Join(Environment.NewLine, responses); - } - - private bool FilterBoosterByType(Booster booster, BoosterType type) => type == BoosterType.Any || booster.Type == type; - private HashSet GetBoosters(BoosterType type, bool? wasCrafted = null) => Boosters.Values.Where(booster => (wasCrafted == null || booster.WasCrafted == wasCrafted) && FilterBoosterByType(booster, type)).ToHashSet(); - private HashSet GetBoosterIDs(BoosterType type, bool? wasCrafted = null) => GetBoosters(type, wasCrafted).Select(booster => booster.GameID).ToHashSet(); - internal int GetNumBoosters(BoosterType type, bool? wasCrafted = null) => GetBoosters(type, wasCrafted).Count; - internal int GetGemsNeeded(BoosterType type, bool? wasCrafted = null) => GetBoosters(type, wasCrafted).Sum(booster => (int) booster.Info.Price); - internal void ForceUpdateBoosterInfos() => OnBoosterInfosUpdated -= ForceUpdateBoosterInfos; - private static int GetMillisecondsFromNow(DateTime then) => Math.Max(0, (int) (then - DateTime.Now).TotalMilliseconds); private void UpdateTimer(DateTime then) => Timer.Change(GetMillisecondsFromNow(then), Timeout.Infinite); - internal uint GetAvailableGems() => BoosterHandler.AllowCraftUntradableBoosters ? GooAmount : TradableGooAmount; - internal BoosterLastCraft? GetLastCraft(uint appID) => BoosterDatabase?.GetLastCraft(appID); - internal void UpdateLastCraft(uint appID, DateTime craftTime) => BoosterDatabase?.SetLastCraft(appID, craftTime, BoosterDelay); + private static int GetMillisecondsFromNow(DateTime then) => Math.Max(0, (int) (then - DateTime.Now).TotalMilliseconds); } } \ No newline at end of file diff --git a/BoosterManager/Boosters/BoosterType.cs b/BoosterManager/Boosters/BoosterType.cs deleted file mode 100644 index 53adaa3..0000000 --- a/BoosterManager/Boosters/BoosterType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BoosterManager { - internal enum BoosterType { - OneTime, - Permanent, - Any - } -} diff --git a/BoosterManager/Boosters/TradabilityPreference.cs b/BoosterManager/Boosters/TradabilityPreference.cs deleted file mode 100644 index 733b367..0000000 --- a/BoosterManager/Boosters/TradabilityPreference.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BoosterManager { - internal enum TradabilityPreference { - Tradable = 1, - Default = 2, - Untradable = 3 - } -} diff --git a/BoosterManager/Commands.cs b/BoosterManager/Commands.cs index f3a99f3..df5ef9a 100644 --- a/BoosterManager/Commands.cs +++ b/BoosterManager/Commands.cs @@ -48,13 +48,18 @@ internal static class Commands { return ResponseBoosterStatus(bot, access); case "BSA^": - return ResponseBoosterStatus(access, steamID, "ASF", true); + return ResponseBoosterStatus(access, steamID, "ASF", shortStatus: true); case "BSTATUS^" or "BOOSTERSTATUS^": - return ResponseBoosterStatus(bot, access, true); + return ResponseBoosterStatus(bot, access, shortStatus: true); case "BSTOPALL" or "BOOSTERSTOPALL": return ResponseBoosterStopTime(bot, access, "0"); + case "BLA": + return await ResponseBuyLimit(access, steamID, "ASF").ConfigureAwait(false); + case "BUYLIMIT" or "BL": + return await ResponseBuyLimit(bot, access).ConfigureAwait(false); + case "CARDS" or "MCARDS" or "CARD" or "MCARD": return await ResponseCountItems(bot, access, ItemIdentifier.CardIdentifier, marketable: true).ConfigureAwait(false); case "UCARDS" or "UCARD": @@ -108,7 +113,7 @@ internal static class Commands { return ResponseLogStop(bot, access); case "LOGINVENTORYHISTORY" or "SENDINVENTORYHISTORY" or "LOGIH" or "SENDIH": - return await ResponseLogInventoryHistory(bot, access, steamID).ConfigureAwait(false); + return await ResponseLogInventoryHistory(bot, access, new StatusReporter(bot, steamID)).ConfigureAwait(false); case "LOGMARKETHISTORY" or "SENDMARKETHISTORY" or "LOGMH" or "SENDMH": return await ResponseLogMarketHistory(bot, access).ConfigureAwait(false); @@ -209,9 +214,14 @@ internal static class Commands { default: switch (args[0].ToUpperInvariant()) { case "BOOSTER" when args.Length > 2: - return ResponseBooster(access, steamID, args[1], Utilities.GetArgsAsText(args, 2, ","), bot); + return ResponseBooster(access, steamID, new StatusReporter(bot, steamID), args[1], Utilities.GetArgsAsText(args, 2, ",")); case "BOOSTER": - return ResponseBooster(bot, access, steamID, args[1]); + return ResponseBooster(bot, access, steamID, new StatusReporter(bot, steamID), args[1]); + + case "BOOSTER^" when args.Length > 3: + return ResponseSmartBooster(access, steamID, new StatusReporter(bot, steamID), args[1], args[2], Utilities.GetArgsAsText(args, 3, ",")); + case "BOOSTER^" when args.Length > 2: + return ResponseSmartBooster(access, steamID, new StatusReporter(bot, steamID), bot.BotName, args[1], args[2]); case "BOOSTERS" or "MBOOSTERS": return await ResponseCountItems(access, steamID, Utilities.GetArgsAsText(args, 1, ","), ItemIdentifier.BoosterIdentifier, marketable: true).ConfigureAwait(false); @@ -230,7 +240,7 @@ internal static class Commands { return ResponseBoosterStatus(access, steamID, args[1]); case "BSTATUS^" or "BOOSTERSTATUS^": - return ResponseBoosterStatus(access, steamID, args[1], true); + return ResponseBoosterStatus(access, steamID, args[1], shortStatus: true); case "BSTOP" or "BOOSTERSTOP" when args.Length > 2: return ResponseBoosterStop(access, steamID, args[1], Utilities.GetArgsAsText(args, 2, ",")); @@ -245,6 +255,9 @@ internal static class Commands { case "BSTOPTIME" or "BOOSTERSTOPTIME": return ResponseBoosterStopTime(bot, access, args[1]); + case "BUYLIMIT" or "BL": + return await ResponseBuyLimit(access, steamID, args[1]).ConfigureAwait(false); + case "CARDS" or "MCARDS" or "CARD" or "MCARD": return await ResponseCountItems(access, steamID, Utilities.GetArgsAsText(args, 1, ","), ItemIdentifier.CardIdentifier, marketable: true).ConfigureAwait(false); case "UCARDS" or "UCARD": @@ -291,13 +304,13 @@ internal static class Commands { return ResponseLogStop(access, steamID, Utilities.GetArgsAsText(args, 1, ",")); case "LOGINVENTORYHISTORY" or "SENDINVENTORYHISTORY" or "LOGIH" or "SENDIH" when args.Length > 5: - return await ResponseLogInventoryHistory(access, steamID, bot, args[1], args[2], args[3], args[4], args[5]).ConfigureAwait(false); + return await ResponseLogInventoryHistory(access, steamID, new StatusReporter(bot, steamID), args[1], args[2], args[3], args[4], args[5]).ConfigureAwait(false); case "LOGINVENTORYHISTORY" or "SENDINVENTORYHISTORY" or "LOGIH" or "SENDIH" when args.Length > 3: - return await ResponseLogInventoryHistory(access, steamID, bot, args[1], args[2], args[3]).ConfigureAwait(false); + return await ResponseLogInventoryHistory(access, steamID, new StatusReporter(bot, steamID), args[1], args[2], args[3]).ConfigureAwait(false); case "LOGINVENTORYHISTORY" or "SENDINVENTORYHISTORY" or "LOGIH" or "SENDIH" when args.Length > 2: - return await ResponseLogInventoryHistory(access, steamID, bot, args[1], args[2]).ConfigureAwait(false); + return await ResponseLogInventoryHistory(access, steamID, new StatusReporter(bot, steamID), args[1], args[2]).ConfigureAwait(false); case "LOGINVENTORYHISTORY" or "SENDINVENTORYHISTORY" or "LOGIH" or "SENDIH": - return await ResponseLogInventoryHistory(access, steamID, bot, args[1]).ConfigureAwait(false); + return await ResponseLogInventoryHistory(access, steamID, new StatusReporter(bot, steamID), args[1]).ConfigureAwait(false); case "LOGMARKETHISTORY" or "SENDMARKETHISTORY" or "LOGMH" or "SENDMH" when args.Length > 3: return await ResponseLogMarketHistory(access, steamID, args[1], args[2], args[3]).ConfigureAwait(false); @@ -347,14 +360,18 @@ internal static class Commands { return await ResponseSendItemToBot(access, steamID, Utilities.GetArgsAsText(args, 1, ","), ItemIdentifier.SackIdentifier).ConfigureAwait(false); case "MARKET2FAOK" or "M2FAOK" when args.Length > 2: - return await Response2FAOK(access, steamID, args[1], Confirmation.EConfirmationType.Market, args[2]).ConfigureAwait(false); + return await Response2FAOK(access, steamID, args[1], Confirmation.EConfirmationType.Market, args[2], new StatusReporter(bot, steamID, reportDelaySeconds: 30, reportMaxDelaySeconds: 60)).ConfigureAwait(false); case "MARKET2FAOK" or "M2FAOK": return await Response2FAOK(access, steamID, args[1], Confirmation.EConfirmationType.Market).ConfigureAwait(false); case "MARKET2FAOKA" or "M2FAOKA": - return await Response2FAOK(access, steamID, "ASF", Confirmation.EConfirmationType.Market, args[1]).ConfigureAwait(false); + return await Response2FAOK(access, steamID, "ASF", Confirmation.EConfirmationType.Market, args[1], new StatusReporter(bot, steamID, reportDelaySeconds: 30, reportMaxDelaySeconds: 60)).ConfigureAwait(false); + case "TRADE2FAOK" or "T2FAOK" when args.Length > 2: + return await Response2FAOK(access, steamID, args[1], Confirmation.EConfirmationType.Trade, args[2], new StatusReporter(bot, steamID, reportDelaySeconds: 30, reportMaxDelaySeconds: 60)).ConfigureAwait(false); case "TRADE2FAOK" or "T2FAOK": return await Response2FAOK(access, steamID, args[1], Confirmation.EConfirmationType.Trade).ConfigureAwait(false); + case "TRADE2FAOKA" or "T2FAOKA": + return await Response2FAOK(access, steamID, "ASF", Confirmation.EConfirmationType.Trade, args[1], new StatusReporter(bot, steamID, reportDelaySeconds: 30, reportMaxDelaySeconds: 60)).ConfigureAwait(false); case "TRADECHECK" or "TCHECK" or "TC": return ResponseTradeCheck(access, steamID, args[1]); @@ -491,7 +508,7 @@ internal static class Commands { } } - private static async Task Response2FAOK(Bot bot, EAccess access, Confirmation.EConfirmationType acceptedType, string? minutesAsText = null) { + private static async Task Response2FAOK(Bot bot, EAccess access, Confirmation.EConfirmationType acceptedType, string? minutesAsText = null, StatusReporter? statusReporter = null) { if (access < EAccess.Master) { return null; } @@ -505,20 +522,27 @@ internal static class Commands { } string? repeatMessage = null; - if (minutesAsText != null && acceptedType == Confirmation.EConfirmationType.Market) { + if (minutesAsText != null && (acceptedType == Confirmation.EConfirmationType.Market || acceptedType == Confirmation.EConfirmationType.Trade)) { if (!uint.TryParse(minutesAsText, out uint minutes)) { return String.Format(ArchiSteamFarm.Localization.Strings.ErrorParsingObject, nameof(minutesAsText)); } if (minutes == 0) { - if (BoosterHandler.BoosterHandlers[bot.BotName].StopMarketTimer()) { + if ((acceptedType == Confirmation.EConfirmationType.Market && MarketHandler.StopMarketRepeatTimer(bot)) + || (acceptedType == Confirmation.EConfirmationType.Trade && InventoryHandler.StopTradeRepeatTimer(bot)) + ) { return FormatBotResponse(bot, Strings.RepetitionCancelled); } else { return FormatBotResponse(bot, Strings.RepetitionNotActive); } } else { - BoosterHandler.BoosterHandlers[bot.BotName].StartMarketTimer(minutes); - repeatMessage = String.Format(Strings.RepetitionNotice, minutes, String.Format("!m2faok {0} 0", bot.BotName)); + if (acceptedType == Confirmation.EConfirmationType.Market) { + MarketHandler.StartMarketRepeatTimer(bot, minutes, statusReporter); + repeatMessage = String.Format(Strings.RepetitionNotice, minutes, String.Format("!m2faok {0} 0", bot.BotName)); + } else if (acceptedType == Confirmation.EConfirmationType.Trade) { + InventoryHandler.StartTradeRepeatTimer(bot, minutes, statusReporter); + repeatMessage = String.Format(Strings.RepetitionNotice, minutes, String.Format("!t2faok {0} 0", bot.BotName)); + } } } @@ -526,13 +550,13 @@ internal static class Commands { string twofacMessage = success ? message : String.Format(ArchiSteamFarm.Localization.Strings.WarningFailedWithError, message); if (repeatMessage != null) { - return FormatBotResponse(bot, String.Format("{0}. {1}", twofacMessage, repeatMessage)); + return FormatBotResponse(bot, String.Format("{0} {1}", twofacMessage, repeatMessage)); } return FormatBotResponse(bot, twofacMessage); } - private static async Task Response2FAOK(EAccess access, ulong steamID, string botNames, Confirmation.EConfirmationType acceptedType, string? minutesAsText = null) { + private static async Task Response2FAOK(EAccess access, ulong steamID, string botNames, Confirmation.EConfirmationType acceptedType, string? minutesAsText = null, StatusReporter? statusReporter = null) { if (String.IsNullOrEmpty(botNames)) { throw new ArgumentNullException(nameof(botNames)); } @@ -543,14 +567,14 @@ internal static class Commands { return access >= EAccess.Owner ? FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.BotNotFound, botNames)) : null; } - IList results = await Utilities.InParallel(bots.Select(bot => Response2FAOK(bot, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID), acceptedType, minutesAsText))).ConfigureAwait(false); + IList results = await Utilities.InParallel(bots.Select(bot => Response2FAOK(bot, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID), acceptedType, minutesAsText, statusReporter))).ConfigureAwait(false); List responses = new(results.Where(result => !String.IsNullOrEmpty(result))); return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; } - private static string? ResponseBooster(Bot bot, EAccess access, ulong steamID, string targetGameIDs, Bot? respondingBot = null) { + private static string? ResponseBooster(Bot bot, EAccess access, ulong steamID, StatusReporter craftingReporter, string targetGameIDs) { if (String.IsNullOrEmpty(targetGameIDs)) { throw new ArgumentNullException(nameof(targetGameIDs)); } @@ -569,7 +593,7 @@ internal static class Commands { return FormatBotResponse(bot, String.Format(ArchiSteamFarm.Localization.Strings.ErrorIsEmpty, nameof(gameIDs))); } - HashSet gamesToBooster = new HashSet(); + List gamesToBooster = new List(); foreach (string game in gameIDs) { if (!uint.TryParse(game, out uint gameID) || (gameID == 0)) { @@ -579,10 +603,10 @@ internal static class Commands { gamesToBooster.Add(gameID); } - return BoosterHandler.BoosterHandlers[bot.BotName].ScheduleBoosters(gamesToBooster, respondingBot ?? bot, steamID); + return BoosterHandler.BoosterHandlers[bot.BotName].ScheduleBoosters(BoosterJobType.Limited, gamesToBooster, craftingReporter); } - private static string? ResponseBooster(EAccess access, ulong steamID, string botNames, string targetGameIDs, Bot respondingBot) { + private static string? ResponseBooster(EAccess access, ulong steamID, StatusReporter craftingReporter, string botNames, string targetGameIDs) { if (String.IsNullOrEmpty(botNames)) { throw new ArgumentNullException(nameof(botNames)); } @@ -593,7 +617,7 @@ internal static class Commands { return access >= EAccess.Owner ? FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.BotNotFound, botNames)) : null; } - IEnumerable results = bots.Select(bot => ResponseBooster(bot, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID), steamID, targetGameIDs, respondingBot)); + IEnumerable results = bots.Select(bot => ResponseBooster(bot, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID), steamID, craftingReporter, targetGameIDs)); List responses = new(results.Where(result => !String.IsNullOrEmpty(result))); @@ -773,6 +797,36 @@ internal static class Commands { return responses.Count > 0 ? String.Join(Environment.NewLine, responses) : null; } + + private static async Task ResponseBuyLimit(Bot bot, EAccess access) { + if (access < EAccess.Master) { + return null; + } + + if (!bot.IsConnectedAndLoggedOn) { + return FormatBotResponse(bot, ArchiSteamFarm.Localization.Strings.BotNotConnected); + } + + return await MarketHandler.GetBuyLimit(bot).ConfigureAwait(false); + } + + private static async Task ResponseBuyLimit(EAccess access, ulong steamID, string botNames) { + if (String.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return access >= EAccess.Owner ? FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.BotNotFound, botNames)) : null; + } + + IList results = await Utilities.InParallel(bots.Select(bot => ResponseBuyLimit(bot, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID)))).ConfigureAwait(false); + + List responses = new(results.Where(result => !String.IsNullOrEmpty(result))); + + return responses.Count > 0 ? string.Join(Environment.NewLine, responses) : null; + } private static async Task ResponseCountItems(Bot bot, EAccess access, ItemIdentifier itemIdentifier, bool? marketable = null) { string? appIDAsText = itemIdentifier.AppID.ToString(); @@ -1089,7 +1143,7 @@ internal static class Commands { return responses.Count > 0 ? String.Join(Environment.NewLine, responses) : null; } - private static async Task ResponseLogInventoryHistory(Bot bot, EAccess access, ulong steamID, string? numPagesString = null, string? startTimeString = null, string? timeFracString = null, string? sString = null, Bot? respondingBot = null) { + private static async Task ResponseLogInventoryHistory(Bot bot, EAccess access, StatusReporter rateLimitReporter, string? numPagesString = null, string? startTimeString = null, string? timeFracString = null, string? sString = null) { if (access < EAccess.Master) { return null; } @@ -1134,10 +1188,10 @@ internal static class Commands { } } - return await DataHandler.SendInventoryHistoryOnly(bot, respondingBot ?? bot, steamID, numPages, startTime, timeFrac, s).ConfigureAwait(false); + return await DataHandler.SendInventoryHistoryOnly(bot, rateLimitReporter, numPages, startTime, timeFrac, s).ConfigureAwait(false); } - private static async Task ResponseLogInventoryHistory(EAccess access, ulong steamID, Bot respondingBot, string botNames, string? numPagesString = null, string? startTimeString = null, string? timeFracString = null, string? sString = null) { + private static async Task ResponseLogInventoryHistory(EAccess access, ulong steamID, StatusReporter rateLimitReporter, string botNames, string? numPagesString = null, string? startTimeString = null, string? timeFracString = null, string? sString = null) { if (String.IsNullOrEmpty(botNames)) { throw new ArgumentNullException(nameof(botNames)); } @@ -1148,7 +1202,7 @@ internal static class Commands { return access >= EAccess.Owner ? FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.BotNotFound, botNames)) : null; } - IList results = await Utilities.InParallel(bots.Select(bot => ResponseLogInventoryHistory(bot, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID), steamID, numPagesString, startTimeString, timeFracString, sString, respondingBot))).ConfigureAwait(false); + IList results = await Utilities.InParallel(bots.Select(bot => ResponseLogInventoryHistory(bot, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID), rateLimitReporter, numPagesString, startTimeString, timeFracString, sString))).ConfigureAwait(false); List responses = new(results.Where(result => !String.IsNullOrEmpty(result))); @@ -1637,6 +1691,82 @@ internal static class Commands { return await ResponseSendMultipleItemsToMultipleBots(sender, ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(sender, access, steamID), recieverBotNames, amountsAsText, appIDAsText, contextIDAsText, itemIdentifiersAsText, marketable).ConfigureAwait(false); } + private static string? ResponseSmartBooster(EAccess access, ulong steamID, StatusReporter craftingReporter, string botNames, string gameIDsAsText, string amountsAsText) { + if (String.IsNullOrEmpty(botNames)) { + throw new ArgumentNullException(nameof(botNames)); + } + + if (String.IsNullOrEmpty(gameIDsAsText)) { + throw new ArgumentNullException(nameof(gameIDsAsText)); + } + + if (String.IsNullOrEmpty(amountsAsText)) { + throw new ArgumentNullException(nameof(amountsAsText)); + } + + HashSet? bots = Bot.GetBots(botNames); + + if ((bots == null) || (bots.Count == 0)) { + return access >= EAccess.Owner ? FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.BotNotFound, botNames)) : null; + } + + if (bots.Any(bot => ArchiSteamFarm.Steam.Interaction.Commands.GetProxyAccess(bot, access, steamID) < EAccess.Master)) { + return null; + } + + Bot? offlineBot = bots.FirstOrDefault(bot => !bot.IsConnectedAndLoggedOn); + if (offlineBot != null) { + return FormatBotResponse(offlineBot, ArchiSteamFarm.Localization.Strings.BotNotConnected); + } + + // Parse GameIDs + string[] gameIDs = gameIDsAsText.Split(",", StringSplitOptions.RemoveEmptyEntries); + + if (gameIDs.Length == 0) { + return FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.ErrorIsEmpty, nameof(gameIDs))); + } + + List gamesToBooster = new List(); + + foreach (string game in gameIDs) { + if (!uint.TryParse(game, out uint gameID) || (gameID == 0)) { + return FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.ErrorParsingObject, nameof(gameID))); + } + + gamesToBooster.Add(gameID); + } + + // Parse Amounts + string[] amountStrings = amountsAsText.Split(",", StringSplitOptions.RemoveEmptyEntries); + + if (amountStrings.Length == 0) { + return FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.ErrorIsEmpty, nameof(amountStrings))); + } + + if (amountStrings.Length == 1 && gamesToBooster.Count > 1) { + amountStrings = Enumerable.Repeat(amountStrings[0], gamesToBooster.Count).ToArray(); + } + + if (amountStrings.Length != gamesToBooster.Count) { + return FormatStaticResponse(String.Format(Strings.AppIDCountDoesNotEqualAmountCount, gamesToBooster.Count, amountStrings.Length)); + } + + List amounts = new List(); + foreach (string amount in amountStrings) { + if (!uint.TryParse(amount, out uint amountNum)) { + return FormatStaticResponse(String.Format(ArchiSteamFarm.Localization.Strings.ErrorParsingObject, nameof(amountNum))); + } + + amounts.Add(amountNum); + } + + // Try to craft boosters + List<(uint, uint)> gameIDsWithAmounts = Zip(gamesToBooster, amounts).ToList(); + BoosterHandler.GetBoosterInfos(bots, (boosterInfos) => BoosterHandler.SmartScheduleBoosters(BoosterJobType.Limited, bots, boosterInfos, gameIDsWithAmounts, craftingReporter)); + + return FormatStaticResponse(String.Format(Strings.BoosterAssignmentStarting, gameIDsWithAmounts.Sum(gameIDWithAmount => gameIDWithAmount.Item2))); + } + private static string? ResponseTradeCheck(Bot bot, EAccess access) { if (access < EAccess.Master) { return null; diff --git a/BoosterManager/Data/MarketableApps.cs b/BoosterManager/Data/MarketableApps.cs index 505902b..ef1ad52 100644 --- a/BoosterManager/Data/MarketableApps.cs +++ b/BoosterManager/Data/MarketableApps.cs @@ -12,8 +12,12 @@ namespace BoosterManager { internal static class MarketableApps { internal static HashSet AppIDs = new(); + private static HashSet MarketableOverrides = new(); + private static HashSet UnmarketableOverrides = new(); private static Uri Source = new("https://raw.githubusercontent.com/Citrinate/Steam-MarketableApps/main/data/marketable_apps.min.json"); + private static Uri MarketableOverridesSource = new("https://raw.githubusercontent.com/Citrinate/Steam-MarketableApps/main/overrides/marketable_app_overrides.json"); + private static Uri UnmarketableOverridesSource = new("https://raw.githubusercontent.com/Citrinate/Steam-MarketableApps/main/overrides/unmarketable_app_overrides.json"); private static TimeSpan UpdateFrequency = TimeSpan.FromMinutes(30); private static TimeSpan SteamUpdateFrequency = TimeSpan.FromMinutes(5); @@ -38,13 +42,20 @@ internal static async Task Update() { // Can't account for these errors whithin this plugin (in a timely fashion), and so we use a cached version of ISteamApps/GetApplist which is known to be good ObjectResponse>? response = await ASF.WebBrowser.UrlGetToJsonObject>(Source).ConfigureAwait(false); - if (response == null || response.Content == null) { + ObjectResponse>? marketableOverrideResponse = await ASF.WebBrowser.UrlGetToJsonObject>(MarketableOverridesSource).ConfigureAwait(false); + ObjectResponse>? unmarketableOverrideResponse = await ASF.WebBrowser.UrlGetToJsonObject>(UnmarketableOverridesSource).ConfigureAwait(false); + if (response == null || response.Content == null + || marketableOverrideResponse == null || marketableOverrideResponse.Content == null + || unmarketableOverrideResponse == null || unmarketableOverrideResponse.Content == null + ) { ASF.ArchiLogger.LogGenericDebug("Failed to fetch marketable apps data"); return false; } AppIDs = response.Content; + MarketableOverrides = marketableOverrideResponse.Content; + UnmarketableOverrides = unmarketableOverrideResponse.Content; LastUpdate = DateTime.Now; // We're good to stop here, but let's try to replace the cached data that may be up to 1 hour old with fresh data from Steam @@ -108,7 +119,7 @@ private static async Task UpdateFromSteam() { return; } - AppIDs = newerAppIDs; + AppIDs = newerAppIDs.Union(MarketableOverrides).Except(UnmarketableOverrides).ToHashSet(); } } } diff --git a/BoosterManager/Handlers/BoosterHandler.cs b/BoosterManager/Handlers/BoosterHandler.cs index e7fc568..4171964 100644 --- a/BoosterManager/Handlers/BoosterHandler.cs +++ b/BoosterManager/Handlers/BoosterHandler.cs @@ -1,188 +1,335 @@ -using ArchiSteamFarm.Core; +using ArchiSteamFarm.Collections; using ArchiSteamFarm.Steam; using BoosterManager.Localization; -using SteamKit2; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace BoosterManager { - internal sealed class BoosterHandler : IDisposable { + internal sealed class BoosterHandler { private readonly Bot Bot; - private readonly BoosterQueue BoosterQueue; - private Bot RespondingBot; // When we send status alerts, they'll come from this bot - private ulong RecipientSteamID; // When we send status alerts, they'll go to this SteamID - internal static ConcurrentDictionary ResponseTimers = new(); - internal List StoredResponses = new(); - private string LastResponse = ""; + internal BoosterDatabase BoosterDatabase { get; private set; } + internal readonly BoosterQueue BoosterQueue; internal static ConcurrentDictionary BoosterHandlers = new(); - private static int DelayBetweenBots = 0; // Delay, in minutes, between when bots will craft boosters + internal ConcurrentList Jobs = new(); internal static bool AllowCraftUntradableBoosters = true; internal static bool AllowCraftUnmarketableBoosters = true; - private Timer? MarketRepeatTimer = null; - private BoosterHandler(Bot bot) { - Bot = bot; - BoosterQueue = new BoosterQueue(Bot, this); - RespondingBot = bot; - RecipientSteamID = Bot.Actions.GetFirstSteamMasterID(); - } + private BoosterHandler(Bot bot, BoosterDatabase boosterDatabase) { + ArgumentNullException.ThrowIfNull(boosterDatabase); - public void Dispose() { - BoosterQueue.Dispose(); - MarketRepeatTimer?.Dispose(); + Bot = bot; + BoosterDatabase = boosterDatabase; + BoosterQueue = new BoosterQueue(Bot); } - internal static void AddHandler(Bot bot) { + internal static void AddHandler(Bot bot, BoosterDatabase boosterDatabase) { if (BoosterHandlers.ContainsKey(bot.BotName)) { - BoosterHandlers[bot.BotName].Dispose(); - BoosterHandlers.TryRemove(bot.BotName, out BoosterHandler? _); - } + // Bot's config was reloaded, cancel and then restore jobs + BoosterHandlers[bot.BotName].CancelBoosterJobs(); + BoosterHandlers[bot.BotName].BoosterDatabase = boosterDatabase; + BoosterHandlers[bot.BotName].RestoreBoosterJobs(); - if (BoosterHandlers.TryAdd(bot.BotName, new BoosterHandler(bot))) { - UpdateBotDelays(); - } - } - - internal static void UpdateBotDelays(int? delayInSeconds = null) { - if (DelayBetweenBots <= 0 && (delayInSeconds == null || delayInSeconds <= 0)) { return; } - // This assumes that the same bots will be used all of the time, with the same names, and all boosters will be - // crafted when they're scheduled to be crafted (no unexpected delays due to Steam downtime or insufficient gems). - // If all of these things are true then BoosterDelayBetweenBots should work as it's described in the README. If these - // assumptions are not met, then the delay between bots might become lower than intended, but it should never be higher - // I don't intend to fix this. - // A workaround for users caught in an undesirable state is to let the 24-hour cooldown on all of their boosters expire. - - DelayBetweenBots = delayInSeconds ?? DelayBetweenBots; - List botNames = BoosterHandlers.Keys.ToList(); - botNames.Sort(); - foreach (KeyValuePair kvp in BoosterHandlers) { - int index = botNames.IndexOf(kvp.Key); - kvp.Value.BoosterQueue.BoosterDelay = DelayBetweenBots * index; + BoosterHandler handler = new BoosterHandler(bot, boosterDatabase); + if (BoosterHandlers.TryAdd(bot.BotName, handler)) { + handler.RestoreBoosterJobs(); } } - internal string ScheduleBoosters(HashSet gameIDs, Bot respondingBot, ulong recipientSteamID) { - RespondingBot = respondingBot; - RecipientSteamID = recipientSteamID; - foreach (uint gameID in gameIDs) { - BoosterQueue.AddBooster(gameID, BoosterType.OneTime); - } - BoosterQueue.OnBoosterInfosUpdated -= ScheduleBoostersResponse; - BoosterQueue.OnBoosterInfosUpdated += ScheduleBoostersResponse; - BoosterQueue.Start(); + internal string ScheduleBoosters(BoosterJobType jobType, List gameIDs, StatusReporter craftingReporter) { + Jobs.Add(new BoosterJob(Bot, jobType, gameIDs, craftingReporter)); return Commands.FormatBotResponse(Bot, String.Format(Strings.BoosterCreationStarting, gameIDs.Count)); } - private void ScheduleBoostersResponse() { - BoosterQueue.OnBoosterInfosUpdated -= ScheduleBoostersResponse; - string? message = BoosterQueue.GetShortStatus(); - if (message == null) { - PerpareStatusReport(Strings.BoostersUncraftable); + internal static void SmartScheduleBoosters(BoosterJobType jobType, HashSet bots, Dictionary> botBoosterInfos, List<(uint gameID, uint amount)> gameIDsWithAmounts, StatusReporter craftingReporter) { + // Group together any duplicate gameIDs + gameIDsWithAmounts = gameIDsWithAmounts.GroupBy(item => item.gameID).Select(group => (group.First().gameID, (uint) group.Sum(item => item.amount))).ToList(); + + // Figure out the most efficient way to queue the given boosters and amounts using the given bots + Dictionary> gameIDsToQueue = new(); + DateTime now = DateTime.Now; + + foreach (var gameIDWithAmount in gameIDsWithAmounts) { + uint gameID = gameIDWithAmount.gameID; + uint amount = gameIDWithAmount.amount; + + // Get all the data we need to determine which is the best bot for this booster + Dictionary botStates = new(); + foreach(Bot bot in bots) { + if (!botBoosterInfos.TryGetValue(bot, out Dictionary? boosterInfos)) { + continue; + } + + if (!boosterInfos.TryGetValue(gameID, out Steam.BoosterInfo? boosterInfo)) { + continue; + } + + botStates.Add(bot, (BoosterHandlers[bot.BotName].Jobs.GetNumBoosters(gameID), boosterInfo.AvailableAtTime ?? now)); + } + + // No bots can craft boosters for this gameID + if (botStates.Count == 0) { + continue; + } + + for (int i = 0; i < amount; i++) { + // Find the best bot for this booster + Bot? bestBot = null; + foreach(var botState in botStates) { + if (bestBot == null) { + bestBot = botState.Key; + + continue; + } + + Bot bot = botState.Key; + int numQueued = botState.Value.numQueued; + DateTime nextCraftTime = botState.Value.nextCraftTime; + + if (botStates[bestBot].nextCraftTime.AddDays(botStates[bestBot].numQueued) > nextCraftTime.AddDays(numQueued)) { + bestBot = bot; + } + } + + if (bestBot == null) { + break; + } + + // Assign the booster to the best bot + gameIDsToQueue.TryAdd(bestBot, new List()); + gameIDsToQueue[bestBot].Add(gameID); + var bestBotState = botStates[bestBot]; + botStates[bestBot] = (bestBotState.numQueued + 1, bestBotState.nextCraftTime); + } + } + + if (gameIDsToQueue.Count == 0) { + foreach(Bot bot in bots) { + craftingReporter.Report(bot, Strings.BoostersUncraftable); + } + + craftingReporter.ForceSend(); return; } - PerpareStatusReport(message); - } + // Queue the boosters + foreach (var item in gameIDsToQueue) { + Bot bot = item.Key; + List gameIDs = item.Value; - internal void SchedulePermanentBoosters(HashSet gameIDs) { - foreach (uint gameID in gameIDs) { - BoosterQueue.AddBooster(gameID, BoosterType.Permanent); + BoosterHandlers[bot.BotName].Jobs.Add(new BoosterJob(bot, jobType, gameIDs, craftingReporter)); + craftingReporter.Report(bot, String.Format(Strings.BoosterCreationStarting, gameIDs.Count)); } - BoosterQueue.Start(); + + craftingReporter.ForceSend(); } internal string UnscheduleBoosters(HashSet? gameIDs = null, int? timeLimitHours = null) { - HashSet removedGameIDs = BoosterQueue.RemoveBoosters(gameIDs, timeLimitHours); + List removedGameIDs = new List(); + + if (timeLimitHours == 0) { + // Cancel everything, as everything takes more than 0 hours to craft + removedGameIDs.AddRange(Jobs.RemoveAllBoosters()); + } else { + // Cancel all boosters for a certain game + if (gameIDs != null) { + foreach(uint gameID in gameIDs) { + int numRemoved = Jobs.RemoveBoosters(gameID); + + if (numRemoved > 0) { + for (int i = 0; i < numRemoved; i++) { + removedGameIDs.Add(gameID); + } + } + } + } + + // Cancel all boosters that will take more than a certain number of hours to craft + if (timeLimitHours != null) { + DateTime timeLimit = DateTime.Now.AddHours(timeLimitHours.Value); + (List queuedBoosters, List unqueuedBoosters) = Jobs.QueuedAndUnqueuedBoosters(); + + foreach (Booster booster in queuedBoosters) { + int unqueuedCount = unqueuedBoosters.Where(x => x == booster.GameID).Count(); + DateTime boosterCraftTime = booster.GetAvailableAtTime(); + + if (unqueuedCount > 0) { + for (int i = 0; i < unqueuedCount; i++) { + if (boosterCraftTime.AddDays(i + 1) > timeLimit) { + if (Jobs.RemoveUnqueuedBooster(booster.GameID)) { + removedGameIDs.Add(booster.GameID); + } + } + } + } + + if (boosterCraftTime > timeLimit) { + if (Jobs.RemoveQueuedBooster(booster.GameID)) { + removedGameIDs.Add(booster.GameID); + } + } + } + } + } if (removedGameIDs.Count == 0) { - if (timeLimitHours == null) { + if (timeLimitHours != null) { + return Commands.FormatBotResponse(Bot, Strings.QueueRemovalByTimeFail); + } else { return Commands.FormatBotResponse(Bot, Strings.QueueRemovalByAppFail); } - - return Commands.FormatBotResponse(Bot, Strings.QueueRemovalByTimeFail); - } - return Commands.FormatBotResponse(Bot, String.Format(Strings.QueueRemovalSuccess, removedGameIDs.Count, String.Join(", ", removedGameIDs))); + IEnumerable gameIDStringsWithMultiples = removedGameIDs.GroupBy(x => x).Select(group => group.Count() == 1 ? group.Key.ToString() : String.Format("{0} (x{1})", group.Key, group.Count())); + + return Commands.FormatBotResponse(Bot, String.Format(Strings.QueueRemovalSuccess, removedGameIDs.Count, String.Join(", ", gameIDStringsWithMultiples))); } - internal string GetStatus(bool shortStatus = false) { - if (shortStatus) { - return Commands.FormatBotResponse(Bot, BoosterQueue.GetShortStatus() ?? BoosterQueue.GetStatus()); + internal void UpdateBoosterJobs() { + Jobs.Finished().ToList().ForEach(job => { + job.Finish(); + Jobs.Remove(job); + }); + + BoosterDatabase.UpdateBoosterJobs(Jobs.Limited().Unfinised().SaveState()); + } + + private void CancelBoosterJobs() { + foreach (BoosterJob job in Jobs) { + job.Stop(); } - return Commands.FormatBotResponse(Bot, BoosterQueue.GetStatus()); + Jobs.Clear(); } - internal void PerpareStatusReport(string message, bool suppressDuplicateMessages = false) { - if (suppressDuplicateMessages && LastResponse == message) { - return; + private void RestoreBoosterJobs() { + uint? craftingGameID = BoosterDatabase.CraftingGameID; + DateTime? craftingTime = BoosterDatabase.CraftingTime; + if (craftingGameID != null && craftingTime != null) { + // We were in the middle of crafting a booster when ASF was reset, check to see if that booster was crafted or not + void handler(Dictionary boosterInfos) { + try { + if (!boosterInfos.TryGetValue(craftingGameID.Value, out Steam.BoosterInfo? newBoosterInfo)) { + // No longer have access to craft boosters for this game (game removed from account, or sometimes due to very rare Steam bugs) + + return; + } + + if (newBoosterInfo.Unavailable && newBoosterInfo.AvailableAtTime != null + && newBoosterInfo.AvailableAtTime != craftingTime.Value + && (newBoosterInfo.AvailableAtTime.Value - craftingTime.Value).TotalHours > 2 // Make sure the change in time isn't due to daylight savings + ) { + // Booster was crafted + Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.BoosterUnexpectedlyCrafted, craftingGameID.Value)); + + // Remove 1 of this booster from our jobs + BoosterDatabase.BoosterJobs.Any(jobState => jobState.GameIDs.Remove(craftingGameID.Value)); + BoosterDatabase.PostCraft(); + + foreach (BoosterJobState jobState in BoosterDatabase.BoosterJobs) { + Jobs.Add(new BoosterJob(Bot, BoosterJobType.Limited, jobState)); + } + } + } finally { + BoosterQueue.OnBoosterInfosUpdated -= handler; + } + } + + BoosterQueue.OnBoosterInfosUpdated += handler; + BoosterQueue.Start(); + } else { + foreach (BoosterJobState jobState in BoosterDatabase.BoosterJobs) { + Jobs.Add(new BoosterJob(Bot, BoosterJobType.Limited, jobState)); + } } + } - LastResponse = message; - // Could be that multiple bots will try to respond all at once individually. Start a timer, during which all messages will be logged and sent all together when the timer triggers. - if (StoredResponses.Count == 0) { - message = Commands.FormatBotResponse(Bot, message); + internal string GetStatus(bool shortStatus = false) { + // Queue empty + Booster? nextBooster = Jobs.NextBooster(); + DateTime? limitedLastBoosterCraftTime = Jobs.Limited().LastBoosterCraftTime(); + if (nextBooster == null || (shortStatus && limitedLastBoosterCraftTime == null)) { + if (BoosterQueue.IsUpdatingBoosterInfos()) { + return Commands.FormatBotResponse(Bot, Strings.BoosterInfoUpdating); + } + + return Commands.FormatBotResponse(Bot, Strings.QueueEmpty); } - StoredResponses.Add(message); - if (!ResponseTimers.ContainsKey(RespondingBot.BotName)) { - ResponseTimers[RespondingBot.BotName] = new Timer( - async e => await SendStatusReport(RespondingBot, RecipientSteamID).ConfigureAwait(false), - null, - GetMillisecondsFromNow(DateTime.Now.AddSeconds(5)), - Timeout.Infinite - ); + + // Short status + int limitedNumBoosters = Jobs.Limited().NumBoosters(); + int limitedGemsNeeded = Jobs.Limited().GemsNeeded(); + if (shortStatus) { + if (limitedLastBoosterCraftTime!.Value.Date == DateTime.Today) { + return Commands.FormatBotResponse(Bot, String.Format(Strings.QueueStatusShort, limitedNumBoosters, String.Format("{0:N0}", limitedGemsNeeded), String.Format("{0:t}", limitedLastBoosterCraftTime))); + } else { + return Commands.FormatBotResponse(Bot, String.Format(Strings.QueueStatusShortWithDate, limitedNumBoosters, String.Format("{0:N0}", limitedGemsNeeded), String.Format("{0:d}", limitedLastBoosterCraftTime), String.Format("{0:t}", limitedLastBoosterCraftTime))); + } } - } - private static async Task SendStatusReport(Bot respondingBot, ulong recipientSteamID) { - if (!respondingBot.IsConnectedAndLoggedOn) { - ResponseTimers[respondingBot.BotName].Change(BoosterHandler.GetMillisecondsFromNow(DateTime.Now.AddSeconds(1)), Timeout.Infinite); + // Long status + List responses = new List(); - return; + // Refreshing booster page + if (BoosterQueue.IsUpdatingBoosterInfos()) { + responses.Add(Strings.BoosterInfoUpdating); } - ResponseTimers.TryRemove(respondingBot.BotName, out Timer? _); - List messages = new List(); - List botNames = BoosterHandlers.Keys.ToList(); - botNames.Sort(); - foreach (string botName in botNames) { - if (BoosterHandlers[botName].StoredResponses.Count == 0 - || BoosterHandlers[botName].RespondingBot.BotName != respondingBot.BotName) { - continue; + // Not enough gems + int gemsNeeded = Jobs.GemsNeeded(); + if (gemsNeeded > BoosterQueue.AvailableGems) { + responses.Add(String.Format("{0} :steamsad:", Strings.QueueStatusNotEnoughGems)); + + if (nextBooster.Info.Price > BoosterQueue.AvailableGems) { + responses.Add(String.Format(Strings.QueueStatusGemsNeeded, String.Format("{0:N0}", nextBooster.Info.Price - BoosterQueue.AvailableGems))); } - messages.Add(String.Join(Environment.NewLine, BoosterHandlers[botName].StoredResponses)); - if (BoosterHandlers[botName].StoredResponses.Count > 1) { - messages.Add(""); + if (Jobs.NumUncrafted() > 1) { + responses.Add(String.Format(Strings.QueueStatusTotalGemsNeeded, String.Format("{0:N0}", gemsNeeded - BoosterQueue.AvailableGems))); } - BoosterHandlers[botName].StoredResponses.Clear(); } - - string message = String.Join(Environment.NewLine, messages); - if (recipientSteamID == 0 || !new SteamID(recipientSteamID).IsIndividualAccount) { - ASF.ArchiLogger.LogGenericInfo(message); + // One-time booster status + if (limitedNumBoosters > 0 && limitedLastBoosterCraftTime != null) { + if (limitedLastBoosterCraftTime.Value.Date == DateTime.Today) { + responses.Add(String.Format(Strings.QueueStatusLimitedBoosters, Jobs.Limited().NumCrafted(), limitedNumBoosters, String.Format("{0:t}", limitedLastBoosterCraftTime), String.Format("{0:N0}", limitedGemsNeeded))); + } else { + responses.Add(String.Format(Strings.QueueStatusLimitedBoostersWithDate, Jobs.Limited().NumCrafted(), limitedNumBoosters, String.Format("{0:d}", limitedLastBoosterCraftTime), String.Format("{0:t}", limitedLastBoosterCraftTime), String.Format("{0:N0}", limitedGemsNeeded))); + } + IEnumerable gameIDStringsWithMultiples = Jobs.Limited().UncraftedGameIDs().GroupBy(x => x).Select(group => group.Count() == 1 ? group.Key.ToString() : String.Format("{0} (x{1})", group.Key, group.Count())); + responses.Add(String.Format(Strings.QueueStatusLimitedBoosterList, String.Join(", ", gameIDStringsWithMultiples))); + } + + // Permanent booster status + if (Jobs.Permanent().NumBoosters() > 0) { + responses.Add(String.Format(Strings.QueueStatusPermanentBoosters, String.Format("{0:N0}", Jobs.Permanent().GemsNeeded()), String.Join(", ", Jobs.Permanent().GameIDs()))); + } + + // Next booster to be crafted + if (DateTime.Now > nextBooster.GetAvailableAtTime()) { + responses.Add(String.Format(Strings.QueueStatusNextBoosterCraftingNow, nextBooster.Info.Name, nextBooster.GameID)); } else { - await respondingBot.SendMessage(recipientSteamID, message).ConfigureAwait(false); + responses.Add(String.Format(Strings.QueueStatusNextBoosterCraftingLater, String.Format("{0:t}", nextBooster.GetAvailableAtTime()), nextBooster.Info.Name, nextBooster.GameID)); } + + responses.Add(""); + + return Commands.FormatBotResponse(Bot, String.Join(Environment.NewLine, responses)); } internal uint GetGemsNeeded() { - if (BoosterQueue.GetAvailableGems() > BoosterQueue.GetGemsNeeded(BoosterType.Any, wasCrafted: false)) { + int gemsNeeded = Jobs.GemsNeeded(); + if (BoosterQueue.AvailableGems > gemsNeeded) { return 0; } - return (uint) (BoosterQueue.GetGemsNeeded(BoosterType.Any, wasCrafted: false) - BoosterQueue.GetAvailableGems()); + return (uint) (gemsNeeded - BoosterQueue.AvailableGems); } internal void OnGemsRecieved() { @@ -195,31 +342,24 @@ internal void OnGemsRecieved() { BoosterQueue.Start(); } - internal bool StopMarketTimer() { - if (MarketRepeatTimer == null) { - return false; - } + internal static void GetBoosterInfos(HashSet bots, Action>> callback) { + ConcurrentDictionary> boosterInfos = new(); - MarketRepeatTimer.Change(Timeout.Infinite, Timeout.Infinite); - MarketRepeatTimer.Dispose(); - MarketRepeatTimer = null; + foreach (Bot bot in bots) { + BoosterQueue boosterQueue = BoosterHandlers[bot.BotName].BoosterQueue; - return true; - } + void OnBoosterInfosUpdated(Dictionary boosterInfo) { + boosterQueue.OnBoosterInfosUpdated -= OnBoosterInfosUpdated; + boosterInfos.TryAdd(bot, boosterInfo); - internal void StartMarketTimer(uint minutes) { - StopMarketTimer(); - MarketRepeatTimer = new Timer(async e => await MarketHandler.AcceptMarketConfirmations(Bot).ConfigureAwait(false), - null, - TimeSpan.FromMinutes(minutes), - TimeSpan.FromMinutes(minutes) - ); - } + if (boosterInfos.Count == bots.Count) { + callback(boosterInfos.ToDictionary()); + } + } - internal static bool IsCraftingOneTimeBoosters() { - return BoosterHandlers.Any(handler => handler.Value.BoosterQueue.GetNumBoosters(BoosterType.OneTime, wasCrafted: false) > 0); + boosterQueue.OnBoosterInfosUpdated += OnBoosterInfosUpdated; + boosterQueue.Start(); + } } - - private static int GetMillisecondsFromNow(DateTime then) => Math.Max(0, (int) (then - DateTime.Now).TotalMilliseconds); } } diff --git a/BoosterManager/Handlers/DataHandler.cs b/BoosterManager/Handlers/DataHandler.cs index ca35960..effd96a 100644 --- a/BoosterManager/Handlers/DataHandler.cs +++ b/BoosterManager/Handlers/DataHandler.cs @@ -68,7 +68,7 @@ public static async Task SendBoosterDataOnly(Bot bot) { return Commands.FormatBotResponse(bot, String.Join(Environment.NewLine, responses)); } - public static async Task SendInventoryHistoryOnly(Bot bot, Bot respondingBot, ulong recipientSteamID, uint? numPages = 1, uint? startTime = null, uint? timeFrac = null, string? s = null) { + public static async Task SendInventoryHistoryOnly(Bot bot, StatusReporter rateLimitReporter, uint? numPages = 1, uint? startTime = null, uint? timeFrac = null, string? s = null) { if (InventoryHistoryAPI == null) { return Commands.FormatBotResponse(bot, Strings.InventoryHistoryEndpointUndefined); } @@ -81,9 +81,9 @@ public static async Task SendInventoryHistoryOnly(Bot bot, Bot respondin numPages = numPages ?? 1; if (startTime != null && timeFrac != null && s != null) { Steam.InventoryHistoryCursor cursor = new Steam.InventoryHistoryCursor(startTime.Value, timeFrac.Value, s); - tasks.Add(SendInventoryHistory(bot, tasks, DateTime.Now, cursor: cursor, pagesRemaining: numPages.Value - 1, retryOnRateLimit: true, respondingBot: respondingBot, recipientSteamID: recipientSteamID)); + tasks.Add(SendInventoryHistory(bot, tasks, DateTime.Now, cursor: cursor, pagesRemaining: numPages.Value - 1, retryOnRateLimit: true, rateLimitReporter: rateLimitReporter)); } else { - tasks.Add(SendInventoryHistory(bot, tasks, DateTime.Now, startTime: startTime, pagesRemaining: numPages.Value - 1, retryOnRateLimit: true, respondingBot: respondingBot, recipientSteamID: recipientSteamID)); + tasks.Add(SendInventoryHistory(bot, tasks, DateTime.Now, startTime: startTime, pagesRemaining: numPages.Value - 1, retryOnRateLimit: true, rateLimitReporter: rateLimitReporter)); } while (tasks.Any(task => !task.IsCompleted)) { @@ -208,7 +208,7 @@ public static string StopSend(Bot bot) { return Strings.BoosterDataEndpointSuccess; } - private static async Task SendInventoryHistory(Bot bot, List> tasks, DateTime tasksStartedTime, Steam.InventoryHistoryCursor? cursor = null, uint? startTime = null, uint pagesRemaining = 0, uint delayInMilliseconds = 0, bool retryOnRateLimit = false, bool showRateLimitMessage = true, Bot? respondingBot = null, ulong? recipientSteamID = null) { + private static async Task SendInventoryHistory(Bot bot, List> tasks, DateTime tasksStartedTime, Steam.InventoryHistoryCursor? cursor = null, uint? startTime = null, uint pagesRemaining = 0, uint delayInMilliseconds = 0, bool retryOnRateLimit = false, bool showRateLimitMessage = true, StatusReporter? rateLimitReporter = null) { if (InventoryHistoryAPI == null) { return null; } @@ -230,7 +230,7 @@ public static string StopSend(Bot bot) { } if (!bot.IsConnectedAndLoggedOn) { - return await SendInventoryHistory(bot, tasks, tasksStartedTime, cursor, startTime, pagesRemaining, 60 * 1000, retryOnRateLimit, showRateLimitMessage, respondingBot, recipientSteamID).ConfigureAwait(false); + return await SendInventoryHistory(bot, tasks, tasksStartedTime, cursor, startTime, pagesRemaining, 60 * 1000, retryOnRateLimit, showRateLimitMessage, rateLimitReporter).ConfigureAwait(false); } pageTime = cursor?.Time ?? startTime ?? (uint) DateTimeOffset.Now.ToUnixTimeSeconds(); @@ -241,19 +241,19 @@ public static string StopSend(Bot bot) { } catch (InventoryHistoryException) { if (retryOnRateLimit) { // This API has a very reachable rate limit - if (showRateLimitMessage && respondingBot != null && recipientSteamID != null) { + if (showRateLimitMessage && rateLimitReporter != null) { string message = Strings.InventoryHistoryRateLimitExceeded; - await respondingBot.SendMessage(recipientSteamID.Value, Commands.FormatBotResponse(bot, message)).ConfigureAwait(false); + rateLimitReporter.Report(bot, message); } // Try again in 15 minutes - return await SendInventoryHistory(bot, tasks, tasksStartedTime, cursor, startTime, pagesRemaining, 15 * 60 * 1000, retryOnRateLimit, false, respondingBot, recipientSteamID).ConfigureAwait(false); + return await SendInventoryHistory(bot, tasks, tasksStartedTime, cursor, startTime, pagesRemaining, 15 * 60 * 1000, retryOnRateLimit, false, rateLimitReporter).ConfigureAwait(false); } } if (inventoryHistory == null || !inventoryHistory.Success) { if (!bot.IsConnectedAndLoggedOn) { - return await SendInventoryHistory(bot, tasks, tasksStartedTime, cursor, startTime, pagesRemaining, 60 * 1000, retryOnRateLimit, showRateLimitMessage, respondingBot, recipientSteamID).ConfigureAwait(false); + return await SendInventoryHistory(bot, tasks, tasksStartedTime, cursor, startTime, pagesRemaining, 60 * 1000, retryOnRateLimit, showRateLimitMessage, rateLimitReporter).ConfigureAwait(false); } if (inventoryHistory?.Error != null) { @@ -272,11 +272,11 @@ public static string StopSend(Bot bot) { if (response.Success && pagesRemaining > 0) { if (response.NextCursor != null) { - tasks.Add(SendInventoryHistory(bot, tasks, tasksStartedTime, response.NextCursor, null, pagesRemaining - 1, LogDataPageDelay * 1000, retryOnRateLimit, true, respondingBot, recipientSteamID)); + tasks.Add(SendInventoryHistory(bot, tasks, tasksStartedTime, response.NextCursor, null, pagesRemaining - 1, LogDataPageDelay * 1000, retryOnRateLimit, true, rateLimitReporter)); } else if (response.NextPage != null) { - tasks.Add(SendInventoryHistory(bot, tasks, tasksStartedTime, null, response.NextPage, pagesRemaining - 1, LogDataPageDelay * 1000, retryOnRateLimit, true, respondingBot, recipientSteamID)); + tasks.Add(SendInventoryHistory(bot, tasks, tasksStartedTime, null, response.NextPage, pagesRemaining - 1, LogDataPageDelay * 1000, retryOnRateLimit, true, rateLimitReporter)); } else if (inventoryHistory.Cursor != null) { - tasks.Add(SendInventoryHistory(bot, tasks, tasksStartedTime, inventoryHistory.Cursor, null, pagesRemaining - 1, LogDataPageDelay * 1000, retryOnRateLimit, true, respondingBot, recipientSteamID)); + tasks.Add(SendInventoryHistory(bot, tasks, tasksStartedTime, inventoryHistory.Cursor, null, pagesRemaining - 1, LogDataPageDelay * 1000, retryOnRateLimit, true, rateLimitReporter)); } else { // Inventory History has ended, possibly due to a bug described in ../Docs/InventoryHistory.md List messages = new List(); diff --git a/BoosterManager/Handlers/InventoryHandler.cs b/BoosterManager/Handlers/InventoryHandler.cs index f5a13c4..3c59679 100644 --- a/BoosterManager/Handlers/InventoryHandler.cs +++ b/BoosterManager/Handlers/InventoryHandler.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Steam; using ArchiSteamFarm.Steam.Data; @@ -8,6 +10,8 @@ namespace BoosterManager { internal static class InventoryHandler { + private static ConcurrentDictionary TradeRepeatTimers = new(); + internal static async Task SendItemToMultipleBots(Bot sender, List<(Bot reciever, uint amount)> recievers, uint appID, ulong contextID, ItemIdentifier itemIdentifier) { // Send Amounts A,B,... of Item X to Bots C,D,... from Bot E // Amount A of Item X to Bot C from Bot E @@ -227,5 +231,48 @@ internal static async Task GetItemCount(Bot bot, uint appID, ulong conte return Commands.FormatBotResponse(bot, response); } + + internal static bool StopTradeRepeatTimer(Bot bot) { + if (!TradeRepeatTimers.ContainsKey(bot)) { + return false; + } + + if (TradeRepeatTimers.TryRemove(bot, out (Timer, StatusReporter?) item)) { + (Timer? oldTimer, StatusReporter? statusReporter) = item; + + if (oldTimer != null) { + oldTimer.Change(Timeout.Infinite, Timeout.Infinite); + oldTimer.Dispose(); + } + + if (statusReporter != null) { + statusReporter.ForceSend(); + } + } + + return true; + } + + internal static void StartTradeRepeatTimer(Bot bot, uint minutes, StatusReporter? statusReporter) { + StopTradeRepeatTimer(bot); + + Timer newTimer = new Timer(async _ => await InventoryHandler.AcceptTradeConfirmations(bot, statusReporter).ConfigureAwait(false), null, Timeout.Infinite, Timeout.Infinite); + if (TradeRepeatTimers.TryAdd(bot, (newTimer, statusReporter))) { + newTimer.Change(TimeSpan.FromMinutes(minutes), TimeSpan.FromMinutes(minutes)); + } else { + newTimer.Dispose(); + } + } + + private static async Task AcceptTradeConfirmations(Bot bot, StatusReporter? statusReporter) { + (bool success, _, string message) = await bot.Actions.HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EConfirmationType.Trade).ConfigureAwait(false); + + string report = success ? message : String.Format(ArchiSteamFarm.Localization.Strings.WarningFailedWithError, message); + if (statusReporter != null) { + statusReporter.Report(bot, report); + } else { + bot.ArchiLogger.LogGenericInfo(report); + } + } } } diff --git a/BoosterManager/Handlers/MarketHandler.cs b/BoosterManager/Handlers/MarketHandler.cs index 6553bcb..facab42 100644 --- a/BoosterManager/Handlers/MarketHandler.cs +++ b/BoosterManager/Handlers/MarketHandler.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text.Json.Nodes; +using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Helpers.Json; using ArchiSteamFarm.Steam; @@ -10,6 +12,8 @@ namespace BoosterManager { internal static class MarketHandler { + private static ConcurrentDictionary MarketRepeatTimers = new(); + internal static async Task GetListings(Bot bot) { uint? listingsValue = await GetMarketListingsValue(bot).ConfigureAwait(false); @@ -236,9 +240,86 @@ internal static async Task FindAndRemoveListings(Bot bot, List GetBuyLimit(Bot bot) { + (Steam.MarketListingsResponse? marketListings, _) = await WebRequest.GetMarketListings(bot).ConfigureAwait(false); + + if (marketListings == null || !marketListings.Success) { + return Strings.MarketListingsFetchFailed; + } + + long buyOrderValue = 0; + foreach (JsonNode? listing in marketListings.BuyOrders) { + if (listing == null) { + bot.ArchiLogger.LogNullError(listing); + + return Strings.MarketListingsFetchFailed; + } + + uint? quantity_remaining = listing["quantity_remaining"]?.ToString().ToJsonObject(); + if (quantity_remaining == null) { + bot.ArchiLogger.LogNullError(quantity_remaining); + + return Strings.MarketListingsFetchFailed; + } + + long? price = listing["price"]?.ToString().ToJsonObject(); + if (price == null) { + bot.ArchiLogger.LogNullError(price); + + return Strings.MarketListingsFetchFailed; + } + + buyOrderValue += price.Value * quantity_remaining.Value; + } + + long buyOrderLimit = bot.WalletBalance * 10; + long remainingBuyOrderLimit = buyOrderValue > buyOrderLimit ? 0 : buyOrderLimit - buyOrderValue; + double buyOrderUsagePercent = buyOrderLimit == 0 ? (buyOrderValue == 0 ? 1 : Double.PositiveInfinity) : (double) buyOrderValue / buyOrderLimit; + + return Commands.FormatBotResponse(bot, String.Format(Strings.MarketBuyLimit, String.Format("{0:#,#0.00}", buyOrderValue / 100.0), String.Format("{0:#,#0.00}", buyOrderLimit / 100.0), String.Format("{0:0%}", buyOrderUsagePercent), String.Format("{0:#,#0.00}", remainingBuyOrderLimit / 100.0), bot.WalletCurrency.ToString())); + } + + internal static bool StopMarketRepeatTimer(Bot bot) { + if (!MarketRepeatTimers.ContainsKey(bot)) { + return false; + } + + if (MarketRepeatTimers.TryRemove(bot, out (Timer, StatusReporter?) item)) { + (Timer? oldTimer, StatusReporter? statusReporter) = item; + + if (oldTimer != null) { + oldTimer.Change(Timeout.Infinite, Timeout.Infinite); + oldTimer.Dispose(); + } + + if (statusReporter != null) { + statusReporter.ForceSend(); + } + } + + return true; + } + + internal static void StartMarketRepeatTimer(Bot bot, uint minutes, StatusReporter? statusReporter) { + StopMarketRepeatTimer(bot); + + Timer newTimer = new Timer(async _ => await MarketHandler.AcceptMarketConfirmations(bot, statusReporter).ConfigureAwait(false), null, Timeout.Infinite, Timeout.Infinite); + if (MarketRepeatTimers.TryAdd(bot, (newTimer, statusReporter))) { + newTimer.Change(TimeSpan.FromMinutes(minutes), TimeSpan.FromMinutes(minutes)); + } else { + newTimer.Dispose(); + } + } + + private static async Task AcceptMarketConfirmations(Bot bot, StatusReporter? statusReporter) { (bool success, _, string message) = await bot.Actions.HandleTwoFactorAuthenticationConfirmations(true, Confirmation.EConfirmationType.Market).ConfigureAwait(false); - bot.ArchiLogger.LogGenericInfo(success ? message : String.Format(ArchiSteamFarm.Localization.Strings.WarningFailedWithError, message)); + + string report = success ? message : String.Format(ArchiSteamFarm.Localization.Strings.WarningFailedWithError, message); + if (statusReporter != null) { + statusReporter.Report(bot, report); + } else { + bot.ArchiLogger.LogGenericInfo(report); + } } } } diff --git a/BoosterManager/Handlers/StatusReporter.cs b/BoosterManager/Handlers/StatusReporter.cs new file mode 100644 index 0000000..9c6c87c --- /dev/null +++ b/BoosterManager/Handlers/StatusReporter.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using ArchiSteamFarm.Core; +using ArchiSteamFarm.Steam; +using SteamKit2; + +// For when long-running commands are issued through Steam chat, this is used to send status reports from the bot the command was sent to, to the user who issued the command +// If the commands weren't issued through Steam chat, this just logs the status reports + +namespace BoosterManager { + internal sealed class StatusReporter { + [JsonInclude] + [JsonRequired] + private ulong SenderSteamID; // When we send status reports, they'll come from this SteamID + + [JsonInclude] + [JsonRequired] + private ulong RecipientSteamID; // When we send status reports, they'll go to this SteamID + + private ConcurrentDictionary> Reports = new(); + private ConcurrentDictionary> PreviousReports = new(); + private uint ReportDelaySeconds; + private uint ReportMaxDelaySeconds; + private const uint DefaultReportDelaySeconds = 5; + + private Timer? ReportTimer; + private DateTime? ReportMaxDelayTime = null; + private SemaphoreSlim ReportSemaphore = new SemaphoreSlim(1, 1); + + internal StatusReporter(Bot? sender = null, ulong recipientSteamID = 0, uint reportDelaySeconds = DefaultReportDelaySeconds, uint? reportMaxDelaySeconds = null) { + SenderSteamID = sender?.SteamID ?? 0; + RecipientSteamID = recipientSteamID; + ReportDelaySeconds = reportDelaySeconds; + ReportMaxDelaySeconds = reportMaxDelaySeconds ?? reportDelaySeconds * 5; + } + + [JsonConstructor] + internal StatusReporter(ulong senderSteamID = 0, ulong recipientSteamID = 0) { + SenderSteamID = senderSteamID; + RecipientSteamID = recipientSteamID; + } + + internal static StatusReporter StatusLogger() { + // Create a status reporter that doesn't send messages through chat, it just logs everything + return new StatusReporter(0, 0); + } + + internal void Report(Bot reportingBot, string report, bool suppressDuplicateMessages = false, bool log = false) { + if (log || SenderSteamID == 0 || RecipientSteamID == 0) { + reportingBot.ArchiLogger.LogGenericInfo(report); + + return; + } + + ReportSemaphore.Wait(); + try { + if (suppressDuplicateMessages) { + bool existsInReports = Reports.TryGetValue(reportingBot, out var reports) && reports.Contains(report); + bool existsInPreviousReports = PreviousReports.TryGetValue(reportingBot, out var previousReports) && previousReports.Contains(report); + + if (existsInReports || existsInPreviousReports) { + return; + } + } + + Reports.AddOrUpdate(reportingBot, new List() { report }, (_, reports) => { reports.Add(report); return reports; }); + + // I prefer to send all reports in as few messages as possible + // As long as reports continue to come in, we wait (until some limit, to avoid possibly waiting forever) + + double delayCorrectionSeconds = 0; + if (ReportMaxDelayTime != null) { + if (ReportMaxDelayTime <= DateTime.Now) { + return; + } + + delayCorrectionSeconds = Math.Max(0, (DateTime.Now.AddSeconds(ReportDelaySeconds) - ReportMaxDelayTime.Value).TotalSeconds); + } + + if (ReportTimer != null) { + ReportTimer.Change(Timeout.Infinite, Timeout.Infinite); + ReportTimer.Dispose(); + } + + ReportTimer = new Timer(async _ => await Send().ConfigureAwait(false), null, TimeSpan.FromSeconds(ReportDelaySeconds - delayCorrectionSeconds), Timeout.InfiniteTimeSpan); + + if (ReportMaxDelayTime == null) { + ReportMaxDelayTime = DateTime.Now.AddSeconds(ReportMaxDelaySeconds); + } + } finally { + ReportSemaphore.Release(); + } + } + + internal void ForceSend() { + Utilities.InBackground(async() => await Send().ConfigureAwait(false)); + } + + private async Task Send() { + await ReportSemaphore.WaitAsync().ConfigureAwait(false); + try { + ReportTimer?.Dispose(); + ReportMaxDelayTime = null; + + List messages = new List(); + List bots = Reports.Keys.OrderBy(bot => bot.BotName).ToList(); + + foreach (Bot bot in bots) { + messages.Add(Commands.FormatBotResponse(bot, String.Join(Environment.NewLine, Reports[bot]))); + if (Reports[bot].Count > 1) { + // Add an extra line if there's more than 1 message from a bot + messages.Add(""); + } + + if (Reports.TryRemove(bot, out List? previousReports)) { + if (previousReports != null) { + PreviousReports.AddOrUpdate(bot, previousReports, (_, _) => previousReports); + } + } + } + + if (messages.Count == 0) { + return; + } + + Bot? sender = SenderSteamID == 0 ? null : Bot.BotsReadOnly?.Values.FirstOrDefault(bot => bot.SteamID == SenderSteamID); + if (sender == null + || RecipientSteamID == 0 + || !new SteamID(RecipientSteamID).IsIndividualAccount + || sender.SteamFriends.GetFriendRelationship(RecipientSteamID) != EFriendRelationship.Friend + ) { + // Can't send a chat message through Steam, just log the report + ASF.ArchiLogger.LogGenericInfo(String.Join(Environment.NewLine, messages)); + + return; + } + + try { + if (!await sender.SendMessage(RecipientSteamID, String.Join(Environment.NewLine, messages)).ConfigureAwait(false)) { + ASF.ArchiLogger.LogGenericInfo(String.Join(Environment.NewLine, messages)); + } + } catch (Exception) { + ASF.ArchiLogger.LogGenericInfo(String.Join(Environment.NewLine, messages)); + } + } finally { + ReportSemaphore.Release(); + } + } + } +} diff --git a/BoosterManager/Json.cs b/BoosterManager/Json.cs index e94b4d7..6dcaf2b 100644 --- a/BoosterManager/Json.cs +++ b/BoosterManager/Json.cs @@ -253,6 +253,12 @@ internal sealed class ExchangeGooResponse { private ExchangeGooResponse() { } } + internal enum TradabilityPreference { + Tradable = 1, + Default = 2, + Untradable = 3 + } + // https://stackoverflow.com/a/51319347 // internal sealed class BoosterInfoDateConverter : JsonConverter { internal sealed class BoosterInfoDateConverter : JsonConverter { diff --git a/BoosterManager/Localization/Strings.resx b/BoosterManager/Localization/Strings.resx index f8cb188..6c2d489 100644 --- a/BoosterManager/Localization/Strings.resx +++ b/BoosterManager/Localization/Strings.resx @@ -122,15 +122,15 @@ - {0} more gems are needed to finish crafting boosters. Crafting will resume when more gems are available. + {0} more gems are needed to continue crafting boosters. Crafting will resume when more gems are available. {0} will be replaced by a number of gems - Failed to create booster from {0} + Failed to create booster for {0} {0} will be replaced by an appID - Successfuly created booster from {0} + Successfuly created booster for {0} {0} will be replaced by an appID @@ -142,15 +142,15 @@ {0} will be replaced by an appID - Added {0} to booster queue. + Added {0} to booster queue {0} will be replaced by an appID - Removed {0} from booster queue. + Removed {0} from booster queue {0} will be replaced by an appID - Re-adding permanent {0} to booster queue. + Re-adding permanent {0} to booster queue {0} will be replaced by an appID @@ -178,7 +178,7 @@ {0} will be replaced by a number of boosters - User removed {0} from booster queue. + User removed {0} from booster queue {0} will be replaced by an appID @@ -186,11 +186,11 @@ - {0} boosters from {1} gems will be crafted by {2} + {0} boosters from {1} gems will be crafted by ~{2} {0} will be replaced by a number of boosters, {1} will be replaced by a number of gems, {2} will be replaced by a time - Bot is not crafting any boosters. + Bot is not crafting any boosters @@ -206,7 +206,7 @@ {0} will be replaced by a number of gems - Crafted {0}/{1} boosters. Crafting will finish at {2}, and will use {3} gems. + Crafted {0}/{1} boosters. Crafting will finish at ~{2}, and will use {3} gems. {0} will be replaced by a number of boosters, {1} will be replaced by a number of boosters, {2} will be replaced by a time, {3} will be replaced by a number of gems @@ -238,7 +238,7 @@ - Didn't find any boosters that could be removed. + Didn't find any boosters that could be removed @@ -525,4 +525,40 @@ Won't craft unmarketable boosters for {0} {0} will be replaced by an appID + + Used: {0} {4} out of {1} {4} ({2}); Remaining: {3} {4} + {0} will be replaced by a number, {1} will be replaced by a number, {2} will be replaced by a percentage, {3} will be replaced by a number, {4} will be replaced by a currency name + + + {0} boosters from {1} gems will be crafted by {2} at ~{3} + {0} will be replaced by a number of boosters, {1} will be replaced by a number of gems, {2} will be replaced by a date, {3} will be replaced by a time + + + Next booster will be crafted on {0} at {1}: {2} ({3}) + {2} will be replaced by a date, {1} will be replaced by a time, {2} will be replaced by a game name, {3} will be replaced by an appID + + + Crafted {0}/{1} boosters. Crafting will finish on {2} at ~{3}, and will use {4} gems. + {0} will be replaced by a number of boosters, {1} will be replaced by a number of boosters, {2} will be replaced by a date, {3} will be replaced by a time, {4} will be replaced by a number of gems + + + Number of appIDs ({0}) does not match number of item amounts ({1}) + {0} will be replaced by a number, {1} will be replaced by a number + + + Attempting to assign {0} boosters... + {0} will be replaced by a number of boosters + + + {0} boosters from {1} gems will be crafted + {0} will be replaced by a number of boosters, {1} will be replaced by a number of gems + + + The requested boosters are unmarketable and won't be crafted + + + + No boosters will be crafted. This bot either can't craft the requested boosters, or the requested boosters are unmarketable. The following boosters are unmarketable: {0} + {0} will be replaced by a list of appIDs + \ No newline at end of file diff --git a/BoosterManager/WebRequest.cs b/BoosterManager/WebRequest.cs index 55a0733..c1cd085 100644 --- a/BoosterManager/WebRequest.cs +++ b/BoosterManager/WebRequest.cs @@ -27,7 +27,7 @@ internal static class WebRequest { } } - internal static async Task CreateBooster(Bot bot, uint appID, uint series, TradabilityPreference nTradabilityPreference) { + internal static async Task CreateBooster(Bot bot, uint appID, uint series, Steam.TradabilityPreference nTradabilityPreference) { if (appID == 0) { bot.ArchiLogger.LogNullError(null, nameof(appID)); diff --git a/README.md b/README.md index 8fcd7a8..80ef482 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ Parameters in square brackets are sometimes `[Optional]`, parameters in angle br Command | Access | Description --- | --- | --- -`booster [Bots] `|`Master`|Adds `AppIDs` to the given bot's booster queue. `AppIDs` added to the booster queue this way will be crafted one time as soon as they become available. +`booster [Bots] `|`Master`|Adds `AppIDs` to the given bot's booster queue. +`booster^ [Bots] `|`Master`|Adds `AppIDs` to some or all of given bot's booster queues, selected in a way to minimize the time it takes to craft a total `Amount` of boosters. The `Amounts` specified may be a single amount for all `AppIDs`, or multiple amounts for each `AppID` respectively. `bstatus [Bots]`|`Master`|Prints the status of the given bot's booster queue. -`bstatus^ [Bots]`|`Master`|Prints the condensed status of the given bot's booster queue. +`bstatus^ [Bots]`|`Master`|Prints a shortened status of the given bot's booster queue. `bstop [Bots] `|`Master`|Removes `AppIDs` from the given bot's booster queue. `bstoptime [Bots] `|`Master`|Removes everything from the given bot's booster queue that will take more than the given `Hours` to craft. `bstopall [Bots]`|`Master`|Removes everything from the given bot's booster queue. @@ -34,7 +35,7 @@ Command | Access | Description `bdrops [Bots]`|`Master`|Prints the number of booster eligible games for the given bots > [!NOTE] -> Any `booster` commands that haven't completed when ASF is closed will not automatically restart the next time ASF is run. If you allow ASF to update this plugin, then these updates will be paused until all `booster` commands have finished. +> Any `booster` commands that haven't completed when ASF is closed will automatically resume the next time ASF is ran. ### Inventory Commands @@ -98,7 +99,7 @@ Command | Access | Description Command | Access | Description --- | --- | --- -`trade2faok [Bot]`|`Master`|Accepts all pending 2FA trade confirmations for given bot instances. +`trade2faok [Bot] [Minutes]`|`Master`|Accepts all pending 2FA trade confirmations for given bot instances. Optionally repeat this action once every `Minutes`. To cancel any repetition, set `Minutes` to 0. `tradecheck [Bot]`|`Master`|Attempt to handle any incoming trades for the given bot using ASF's [trading logic](https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Trading#logic). `tradesincoming [Bot] [From]`|`Master`|Displays the number of incoming trades for the given bot, optionally filtered to only count trades `From` the given bot names or 64-bit SteamIDs. @@ -106,6 +107,7 @@ Command | Access | Description Command | Access | Description --- | --- | --- +`buylimit `|`Master`|Displays the value of the given bot's active buy orders, and how close the bot is to hitting the buy order limit. `findlistings `|`Master`|Displays the `ListingIDs` of any market listing belonging to the given bot and matching any of the [`ItemIdentifiers`](#itemidentifiers). `findandremovelistings `|`Master`|Removes any market listing belonging to the given bot and matching any of the [`ItemIdentifiers`](#itemidentifiers). `listings [Bots]`|`Master`|Displays the total value of all market listings owned by the given bot. @@ -156,6 +158,7 @@ Most pluralized commands also have a non-pluralized alias; ex: `lootboosters` ha Command | Alias | --- | --- | +`buylimit`|`bl` `findlistings`|`fl` `findandremovelistings`|`frl` `removelistings`|`rlistings`, `removel` @@ -174,6 +177,7 @@ Command | Alias | `bstatus ASF`|`bsa` `bstatus^ ASF`|`bsa^` `boosters asf`|`ba` +`buylimit ASF`|`bla` `cards asf`|`ca` `foils asf`|`fa` `gems ASF`|`ga` @@ -187,7 +191,7 @@ Command | Alias | `lootkeys ASF`|`lka` `lootsacks ASF`|`lsa` `market2faok ASF [Minutes]`|`m2faoka [Minutes]` -`trade2faok ASF`|`t2faoka` +`trade2faok ASF [Minutes]`|`t2faoka [Minutes]` `tradecheck ASF`|`tca` `tradesincoming ASF [From]`|`tia [From]` `tradesincoming ASF ASF`|`tiaa` @@ -233,21 +237,6 @@ Example: --- -### BoosterDelayBetweenBots - -`uint` type with default value of `0`. This configuration setting can be added to your `ASF.json` config file. It will add a `Seconds` delay between each of your bot's booster crafts. For example: when crafting a booster at 12:00 using a 60 second delay; Bot 1 will craft at 12:00, Bot 2 will craft at 12:01, Bot 3 will craft at 12:02, and so on. - -Example: - -```json -"BoosterDelayBetweenBots": 60, -``` - -> [!NOTE] -> This is not recommended to be used except in the most extreme cases. - ---- - ### BoosterDataAPI `string` type with no default value. This configuration setting can be added to your `ASF.json` config file. When the `logboosterdata` command is used, booster data will be gathered and sent to the API located at the specified url.