diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 697ab4e..448bcf0 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -1,4 +1,5 @@ using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Quests; namespace OpenAC.MosswartMassacre; @@ -10,8 +11,10 @@ internal static class FeatureCatalog { public static void AddFeatures(MmContext context, BackendSession session, List features) { - _ = context; _ = session; - _ = features; + + // Quests, flag tracker, meta sync. + var quests = new QuestManager(context); + features.Add(quests); } } diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs b/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs new file mode 100644 index 0000000..0a3546a --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs @@ -0,0 +1,325 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Quests; + +/// One line of the server's /myquests answer. +internal sealed class Quest +{ + public string Id { get; init; } = string.Empty; + + public int Solves { get; init; } + + /// When the quest was last solved, in Unix seconds. + public int Timestamp { get; init; } + + public string Description { get; init; } = string.Empty; + + public int MaxSolves { get; init; } + + /// The quest's cooldown in seconds. + public int Delta { get; init; } + + /// When the quest can be done again, in Unix seconds. + public int ExpireTime { get; init; } +} + +/// +/// The character's quest timers, read the way the original read them: ask the +/// server with /myquests and, for the next three seconds, parse every +/// chat line that looks like one of its answer lines. Every thirty seconds the +/// timers of the priority quests go to the backend as quest frames. +/// +/// +/// The answer lines stay in the chat window; the original never hid them. +/// Everything here runs on the tick thread: the chat event, the three-second +/// window and the stream are all driven by the host's tick. +/// +internal sealed class QuestManager : IMmFeature +{ + /// How long after asking the chat lines are read as answers. + public static readonly TimeSpan RefreshWindow = TimeSpan.FromSeconds(3); + + /// How often the priority quests are streamed. + public static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(30); + + // One answer line: "stamp - N solves (lastSolved)"message" maxSolves delta". + private static readonly Regex QuestLine = new( + @"([^\-]+) - (\d+) solves \((\d+)\)""([^""]+)"" (-?\d+) (\d+)", + RegexOptions.CultureInvariant); + + private readonly MmContext _context; + private readonly List _quests = []; + private readonly Dictionary _questsById = new(StringComparer.Ordinal); + private readonly Action _onChat; + private IDisposable? _refreshWindow; + private IDisposable? _stream; + private bool _subscribed; + + public QuestManager(MmContext context) + { + _context = context; + _onChat = OnChat; + context.Commands.Register("refreshquests", HandleRefreshQuests, "Refresh quest data"); + context.Commands.Register("queststatus", HandleQuestStatus, "Show quest streaming status"); + } + + /// Raised on the tick thread whenever the quest list changes. + public event Action? QuestsChanged; + + /// Every parsed answer line, in the order the server sent them. + public IReadOnlyList QuestList => _quests; + + /// True while the answer to a /myquests is being read. + public bool IsRefreshing { get; private set; } + + /// True while the thirty-second stream is scheduled. + public bool IsStreaming => _stream is not null; + + public void OnLogin() + { + if (!_subscribed) + { + _context.Automation.Chat.Received += _onChat; + _subscribed = true; + } + ClearQuests(); + RefreshQuests(); + _stream?.Dispose(); + _stream = _context.Scheduler.Every(StreamInterval, StreamPriorityQuests); + } + + public void OnLogoff() + { + _stream?.Dispose(); + _stream = null; + _refreshWindow?.Dispose(); + _refreshWindow = null; + IsRefreshing = false; + if (_subscribed) + { + _context.Automation.Chat.Received -= _onChat; + _subscribed = false; + } + ClearQuests(); + } + + public void Dispose() => OnLogoff(); + + /// + /// Asks the server for the quest list. A request while the previous answer + /// is still being read is ignored, as it was in the original. + /// + public void RefreshQuests() + { + try + { + if (IsRefreshing) + return; + + ClearQuests(); + IsRefreshing = true; + _context.Automation.Chat.Submit("/myquests"); + _refreshWindow = _context.Scheduler.Delay(RefreshWindow, () => + { + IsRefreshing = false; + _refreshWindow = null; + QuestsChanged?.Invoke(); + }); + } + catch (Exception ex) + { + IsRefreshing = false; + _context.Chat.Write($"Error refreshing quests: {ex.Message}"); + } + } + + public void ClearQuests() + { + bool had = _quests.Count > 0; + _quests.Clear(); + _questsById.Clear(); + if (had) + QuestsChanged?.Invoke(); + } + + public bool HasQuestFlag(string questStamp) => _questsById.ContainsKey(questStamp); + + public bool TryGetQuest(string questStamp, out Quest quest) => + _questsById.TryGetValue(questStamp, out quest!); + + /// The priority quests, first answer line per stamp, in answer order. + public IReadOnlyList PriorityQuests() => + [.. _quests + .Where(quest => QuestStreamingService.IsHighPriorityQuest(quest.Id)) + .GroupBy(quest => quest.Id) + .Select(group => group.First())]; + + /// Parses one chat line; a line that is not an answer line is ignored. + internal void ParseQuestLine(string text) + { + try + { + Match match = QuestLine.Match(text); + if (!match.Success) + return; + + int timestamp = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + int delta = int.Parse(match.Groups[6].Value, CultureInfo.InvariantCulture); + var quest = new Quest + { + Id = match.Groups[1].Value.Trim(), + Solves = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture), + Timestamp = timestamp, + Description = match.Groups[4].Value, + MaxSolves = int.Parse(match.Groups[5].Value, CultureInfo.InvariantCulture), + Delta = delta, + // Unchecked, as the original's int sum was. + ExpireTime = unchecked(timestamp + delta), + }; + + _quests.Add(quest); + _questsById[quest.Id] = quest; + QuestsChanged?.Invoke(); + } + catch (Exception ex) + { + _context.Chat.Write($"Error parsing quest line '{text}': {ex.Message}"); + } + } + + /// The now the countdowns are measured from, in Unix seconds. + internal long UnixNow => new DateTimeOffset(DateTime.SpecifyKind(_context.Clock.UtcNow, DateTimeKind.Utc)) + .ToUnixTimeSeconds(); + + /// One run of the thirty-second stream. Internal so tests can run it directly. + internal void StreamPriorityQuests() + { + try + { + _context.Verbose("[QUEST-STREAM] Timer fired, checking conditions..."); + + if (!_context.Settings.Current.WebSocketEnabled) + { + _context.Verbose("[QUEST-STREAM] WebSocket not enabled, skipping"); + return; + } + + if (_quests.Count == 0) + { + _context.Verbose($"[QUEST-STREAM] No quest data available (null: False, count: {_quests.Count})"); + return; + } + + long currentTime = UnixNow; + IReadOnlyList priorityQuests = PriorityQuests(); + _context.Verbose($"[QUEST-STREAM] Found {priorityQuests.Count} priority quests to stream"); + + foreach (Quest quest in priorityQuests) + { + try + { + string questName = QuestNames.GetFriendlyName(quest.Id); + string countdown = QuestStreamingService.FormatCountdown(quest.ExpireTime - currentTime); + _context.Verbose($"[QUEST-STREAM] Sending: {questName} - {countdown}"); + _context.Send(QuestStreamingService.BuildFrame(_context, questName, countdown)); + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST-STREAM] Error streaming quest {quest.Id}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST-STREAM] Error in timer handler: {ex.Message}"); + } + } + + /// Last solved, as the flag tracker shows it: UTC, month first. + public static string FormatTimeStamp(int timestamp) + { + try + { + DateTime dateTime = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; + return dateTime.ToString("MM/dd/yyyy HH:mm:ss", CultureInfo.InvariantCulture); + } + catch (ArgumentOutOfRangeException) + { + return "Invalid"; + } + } + + /// A duration as "1d 2h 3m 4s", leaving out the zero parts. + public static string FormatSeconds(int seconds) + { + if (seconds <= 0) + return "0s"; + + int days = seconds / 86400; + seconds %= 86400; + int hours = seconds / 3600; + seconds %= 3600; + int minutes = seconds / 60; + seconds %= 60; + + string result = string.Empty; + if (days > 0) + result += string.Create(CultureInfo.InvariantCulture, $"{days}d "); + if (hours > 0) + result += string.Create(CultureInfo.InvariantCulture, $"{hours}h "); + if (minutes > 0) + result += string.Create(CultureInfo.InvariantCulture, $"{minutes}m "); + if (seconds > 0 || string.IsNullOrEmpty(result)) + result += string.Create(CultureInfo.InvariantCulture, $"{seconds}s"); + + return result.Trim(); + } + + private void OnChat(PluginChatMessage message) + { + if (!IsRefreshing || string.IsNullOrEmpty(message.Text)) + return; + ParseQuestLine(message.Text); + } + + private void HandleRefreshQuests(string[] args) + { + _ = args; + try + { + _context.Chat.Write("[QUEST] Refreshing quest data..."); + RefreshQuests(); + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST] Refresh failed: {ex.Message}"); + } + } + + private void HandleQuestStatus(string[] args) + { + _ = args; + try + { + _context.Chat.Write("=== Quest Streaming Status ==="); + _context.Chat.Write($"Timer Active: {IsStreaming}"); + _context.Chat.Write($"WebSocket Enabled: {_context.Settings.Current.WebSocketEnabled}"); + _context.Chat.Write($"Quest Manager: {(_context.IsLoggedIn ? "Active" : "Not Active")}"); + _context.Chat.Write($"Quest Count: {_quests.Count}"); + + IReadOnlyList priorityQuests = PriorityQuests(); + _context.Chat.Write($"Priority Quests Found: {priorityQuests.Count}"); + foreach (Quest quest in priorityQuests) + _context.Chat.Write($" - {QuestNames.GetFriendlyName(quest.Id)} ({quest.Id})"); + + _context.Chat.Write($"Verbose Logging: {_context.Settings.Current.VerboseLogging}"); + _context.Chat.Write("Use '/mm verbose' to toggle debug logging"); + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST] Status check failed: {ex.Message}"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs b/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs new file mode 100644 index 0000000..184871a --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs @@ -0,0 +1,211 @@ +namespace OpenAC.MosswartMassacre.Quests; + +/// +/// Friendly names for quest stamps, the table the original plugin shipped. +/// The friendly name is what the backend's quest frame carries as +/// quest_name, so the table is copied as it was, including the stamp +/// listed twice (insatiableeaterjaw): the later entry wins, exactly as +/// the original's indexer initializer made it win. +/// +internal static class QuestNames +{ + public static readonly Dictionary QuestStampToName = new() + { + // Character-specific Quest Stamps (from actual /myquests output) + ["30minattributes"] = "30 Minute Attribute Gems Timer", + ["academeyexittokengiven"] = "Academy Exit Token Received", + ["aerbaxchestkey2pickup"] = "Aerbax Chest Key #2 Pickup", + ["anekshaygemofknowledgetimer_monthly"] = "A'nekshay Gem of Knowledge Monthly Timer", + ["anekshaygemoflesserknowledgecollectedinamonth"] = "A'nekshay Gems of Lesser Knowledge Monthly Count", + ["anekshaygemoflesserknowledgetimer_monthly"] = "A'nekshay Gem of Lesser Knowledge Monthly Timer", + ["attributereset30day"] = "30-Day Attribute Reset Timer", + ["augmentationblankgemacquired"] = "Blank Augmentation Gem Pickup Timer", + ["bellowsnewbieturnedin"] = "Blacksmith's Bellows Turned In", + ["bonecrunchkeypickuptimer"] = "Bonecrunch's Key Pickup Timer", + ["callingstonegiven"] = "Calling Stone Turned Over", + ["defeatedbonecrunch"] = "Bonecrunch Defeated", + ["efmlcentermanafieldused"] = "EF Middle Level Center Mana Field Used", + ["efmleastmanafieldused"] = "EF Middle Level East Mana Field Used", + ["efmlnorthmanafieldused"] = "EF Middle Level North Mana Field Used", + ["efmlsouthmanafieldused"] = "EF Middle Level South Mana Field Used", + ["efmlwestmanafieldused"] = "EF Middle Level West Mana Field Used", + ["efulcentermanafieldused"] = "EF Upper Level Center Mana Field Used", + ["efuleastmanafieldused"] = "EF Upper Level East Mana Field Used", + ["efulnorthmanafieldused"] = "EF Upper Level North Mana Field Used", + ["efulsouthmanafieldused"] = "EF Upper Level South Mana Field Used", + ["efulwestmanafieldused"] = "EF Upper Level West Mana Field Used", + ["insatiableeaterjaw"] = "Insatiable Eater Jaw Collection", + ["pathwardencomplete"] = "Pathwarden Visit Complete", + ["pathwardenfound1111"] = "Pathwarden Greeter Encountered", + ["recallsingularitycaul"] = "Recall Singularity Bore Pickup", + ["stipendscollectedinamonth"] = "Monthly Stipends Collected Count", + ["stipendtimer_0812"] = "Stipend Collection Timer", + ["stipendtimer_monthly"] = "Monthly Stipend Timer", + ["upperinsatiablejaw"] = "Upper Insatiable Eater Jaw Collection", + ["usedattributereset"] = "Attribute Reset Used", + ["usedfreeattributereset"] = "Free Attribute Reset Used", + ["usedfreeskillreset"] = "Free Skill Reset Used", + ["usedskillreset"] = "Skill Reset Used", + ["virindiisland"] = "Singularity Island Visit", + + // Kill Tasks + ["turshscalp"] = "Tursh Scalp", + ["polarursuin"] = "Polar Ursuin Kill Task Main Flag Timer", + ["polarursuinkillcount"] = "Polar Ursuin Kill Counter", + ["polardillotask"] = "Polar Dillo Kill Task Main Flag", + ["polardillokills"] = "Polar Dillo Kill Counter", + ["repugnanteaterkilltask"] = "Repugnant Eater Kill Task", + ["repugeaterkillcount"] = "Repugnant Eater Kill Counter", + ["deathcap"] = "Deathcap Thrungus Kill Task", + ["deathcapkillcount"] = "Deathcap Thrungus Kill Counter", + ["grievverv"] = "Grievver Violator Kill Task", + ["grievvervkillcount"] = "Grievver Violator Kill Counter", + ["tuskerg"] = "Tusker Guard Kill Task Main Flag", + ["tuskergkillcount"] = "Tusker Guard Kill Counter", + + // Quest Timers and Pickups + ["blankaug"] = "Blank Aug Gem Pickup Timer", + ["greatcavepenguinegg"] = "Great Cave Penguin Egg Pickup Timer", + ["deathallurecd"] = "Death's Allure Timer Flag", + ["brewmastercover"] = "Brew Master Quest Pickup Timer Cover", + ["brewmasterback"] = "Brew Master Quest Pickup Timer Back", + ["brewmasterpages"] = "Brew Master Quest Pickup Timer Pages", + ["brewmasterspine"] = "Brew Master Quest Pickup Timer Spine", + ["eleonorasheart"] = "Elanora's Heart Quest Pickup Timer", + ["beacongemobtained"] = "Cooldown for obtaining another beacon gem", + ["beaconcomplete"] = "Beacon Quest Complete Timer", + ["sirginaziosword"] = "Pick up of Sir Ginazio's Sword", + + // Major Quests + ["maraudersjaw"] = "Marauder's Lair Quest", + ["fledgemastertusk"] = "Fledge Master's Tusk Quest", + ["crystallinekiller"] = "Crystalline Killer", + ["darkisledelivery"] = "Dark Isle Delivery", + ["defeatingvaeshok"] = "Defeating Vaeshok", + ["hollyjollyhelperquest"] = "Holly Jolly Helper Quest", + ["moarsmenjailbreak"] = "Moarsmen Jailbreak", + ["shamblingarchivistdestroyer"] = "Shambling Archivist Destroyer", + ["tracingthestone"] = "Tracing The Stone", + ["undeadjawcollection"] = "Undead Jaw Collection", + ["weedingofthederutree"] = "Weeding of the Deru Tree", + ["ironbladecommander"] = "Iron Blade Commander", + ["mumiyahhuntingneftet"] = "Mumiyah Hunting Neftet", + ["torgashstasks"] = "Torgash's Tasks", + + // Thrungus Hovels Items + ["stolenfryingpan"] = "Thrungus Hovels", + ["stolenring"] = "Thrungus Hovels", + ["stolenbrewkettle"] = "Thrungus Hovels", + ["stolenamulet"] = "Thrungus Hovels", + ["stolenewer"] = "Thrungus Hovels", + ["stolennecklace"] = "Thrungus Hovels", + ["stolenplatter"] = "Thrungus Hovels", + ["stolenbracelet"] = "Thrungus Hovels", + + // Special Items and Flags + ["ringofkarlun"] = "Knights of Karlun", + ["trainingacademycomplete"] = "Completion of Training Academy for Exit", + ["cowtipcounter"] = "Counter for Cow Tipping", + ["cowtip"] = "Main Timed Flag for Cow Tipping", + ["skillloweringgempickedup"] = "Picked up a forgetfulness gem", + + // Healing Machine Components + ["orbhealingmachine"] = "Healing Machine Orb", + ["pedestalhealingmachine"] = "Healing Machine Pedestal", + ["tihnhealingmachine"] = "Healing Machine Tihn", + ["lavushealingmachine"] = "Healing Machine Lavus", + ["hookhealingmachine"] = "Healing Machine Hook", + + // Eater Jaws + ["ravenouseaterjaw"] = "Ravenous Eater Jaw", + ["insatiableeaterjaw"] = "Insatiable Eater Jaw", + ["engorgedeaterjaw"] = "Engorged Eater Jaw", + ["voraciouseaterjaw"] = "Voracious Eater Jaw", + ["abhorrenteaterjaw"] = "Abhorrent Eater Jaw", + + // Kill Tasks (Extended) + ["altereddrudgekilltask"] = "Altered Drudge Kill Task", + ["altereddrudgekillcount"] = "Altered Drudge Kill Counter", + ["arcticmattekarkilltask"] = "Arctic Mattekar Kill Task", + ["arcticmattekarkillcount"] = "Arctic Mattekar Kill Counter", + ["armoredillohuntingneftetkilltask"] = "Armoredillo Hunting Neftet Kill Task", + ["armoredillohuntingneftetkillcount"] = "Armoredillo Hunting Neftet Kill Counter", + ["augmenteddrudgekilltask"] = "Augmented Drudge Kill Task", + ["augmenteddrudgekillcount"] = "Augmented Drudge Kill Counter", + ["banishedcreaturekilltask"] = "Banished Creature Kill Task", + ["banishedcreaturekillcount"] = "Banished Creature Kill Counter", + ["benekniffiskilltask"] = "Benek Niffis Kill Task", + ["benekniffiskillcount"] = "Benek Niffis Kill Counter", + ["blackcoralgolemkilltask"] = "Black Coral Golem Kill Task", + ["blackcoralgolemkillcount"] = "Black Coral Golem Kill Counter", + ["blessedmoarsmankilltask"] = "Blessed Moarsman Kill Task", + ["blessedmoarsmankillcount"] = "Blessed Moarsman Kill Counter", + ["blightedcoralgolemkilltask"] = "Blighted Coral Golem Kill Task", + ["blightedcoralgolemkillcount"] = "Blighted Coral Golem Kill Counter", + ["bloodshrethkilltask"] = "Blood Shreth Kill Task", + ["bloodshrethkillcount"] = "Blood Shreth Kill Counter", + ["bronzegauntlettrooperkilltask"] = "Bronze Gauntlet Trooper Kill Task", + ["bronzegauntlettrooperkillcount"] = "Bronze Gauntlet Trooper Kill Counter", + ["coppercogtrooperkilltask"] = "Copper Cog Trooper Kill Task", + ["coppercogtrooperkillcount"] = "Copper Cog Trooper Kill Counter", + ["coppergolemkingpinkilltask"] = "Copper Golem Kingpin Kill Task", + ["coppergolemkingpinkillcount"] = "Copper Golem Kingpin Kill Counter", + ["coralgolemkilltask"] = "Coral Golem Kill Task", + ["coralgolemkillcount"] = "Coral Golem Kill Counter", + ["coralgolemviceroykilltask"] = "Coral Golem Viceroy Kill Task", + ["coralgolemviceroykillcount"] = "Coral Golem Viceroy Kill Counter", + ["corruptedgravestonekilltask"] = "Corrupted Gravestone Kill Task", + ["corruptedgravestonekillcount"] = "Corrupted Gravestone Kill Counter", + ["deathcapthrunguskilltask"] = "Deathcap Thrungus Kill Task", + ["deathcapthrunguskillcount"] = "Deathcap Thrungus Kill Counter", + ["desertcactuskilltask"] = "Desert Cactus Kill Task", + ["desertcactuskillcount"] = "Desert Cactus Kill Counter", + ["devourermargulkilltask"] = "Devourer Margul Kill Task", + ["devourermargulkillcount"] = "Devourer Margul Kill Counter", + + // Society and Faction Quests + ["celestialhandintroductioncomplete"] = "Celestial Hand Introduction Complete", + ["eldrytchwebintroductioncomplete"] = "Eldrytch Web Introduction Complete", + ["radiantbloodintroductioncomplete"] = "Radiant Blood Introduction Complete", + ["celestialhandinitiatetest"] = "Celestial Hand Initiate Test", + ["eldrytchwebinitiatetest"] = "Eldrytch Web Initiate Test", + ["radiantbloodinitiatetest"] = "Radiant Blood Initiate Test", + + // Luminance Aura Related + ["aetheriaredemption"] = "Aetheria Redemption", + ["aegisofmerc"] = "Aegis of Merc", + ["lumaugtradein"] = "Luminance Augmentation Trade In", + + // Common AC Quests + ["holtburgtraderskill"] = "Holtburg Trader Skill Quest", + ["shoushitraderskill"] = "Shoushi Trader Skill Quest", + ["yaraqtraderskill"] = "Yaraq Trader Skill Quest", + ["newbiequests"] = "Newbie Academy Quests", + ["moarsmanraid"] = "Moarsman Raid", + ["virindiparadox"] = "Virindi Paradox", + ["portalspace"] = "Portal Space Exploration" + }; + + /// The friendly name for a stamp, or the stamp itself when the table has none. + public static string GetFriendlyName(string questStamp) + { + if (string.IsNullOrEmpty(questStamp)) + return questStamp; + + return QuestStampToName.TryGetValue(questStamp.ToLower(System.Globalization.CultureInfo.InvariantCulture), out string? friendlyName) + ? friendlyName + : questStamp; + } + + /// The friendly name with the stamp in parentheses, or the stamp alone. + public static string GetDisplayName(string questStamp) + { + if (string.IsNullOrEmpty(questStamp)) + return questStamp; + + string friendlyName = GetFriendlyName(questStamp); + return !string.Equals(friendlyName, questStamp, StringComparison.OrdinalIgnoreCase) + ? $"{friendlyName} ({questStamp})" + : questStamp; + } +} diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs b/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs new file mode 100644 index 0000000..0a2bcb7 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using Newtonsoft.Json; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Quests; + +/// +/// The quest frame: type, timestamp, character_name, +/// quest_name, countdown, in that order, all strings. The +/// original wrote it from an anonymous object with those five members in that +/// order; this class declares them in the same order so Newtonsoft writes the +/// same bytes. +/// +internal sealed class QuestFrame +{ + [JsonProperty("type")] + public string Type { get; } = "quest"; + + [JsonProperty("timestamp")] + public string Timestamp { get; init; } = string.Empty; + + [JsonProperty("character_name")] + public string CharacterName { get; init; } = string.Empty; + + [JsonProperty("quest_name")] + public string QuestName { get; init; } = string.Empty; + + [JsonProperty("countdown")] + public string Countdown { get; init; } = string.Empty; +} + +/// Which quests stream, how their countdown is written, and the frame they go in. +internal static class QuestStreamingService +{ + /// The three stamps the original streamed, and only those. + public static bool IsHighPriorityQuest(string questId) => + questId == "stipendtimer_0812" + || questId == "augmentationblankgemacquired" + || questId == "insatiableeaterjaw"; + + /// + /// The countdown text: READY at or past zero, then the two largest + /// units, the smaller one zero-padded: 1d 02h, 3h 05m, + /// 4m 09s, 59s. The backend shows this string as it is. + /// + public static string FormatCountdown(long seconds) + { + if (seconds <= 0) + return "READY"; + + TimeSpan timeSpan = TimeSpan.FromSeconds(seconds); + + if (timeSpan.TotalDays >= 1) + return string.Create(CultureInfo.InvariantCulture, $"{(int)timeSpan.TotalDays}d {timeSpan.Hours:D2}h"); + if (timeSpan.TotalHours >= 1) + return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Hours}h {timeSpan.Minutes:D2}m"); + if (timeSpan.TotalMinutes >= 1) + return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Minutes}m {timeSpan.Seconds:D2}s"); + return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Seconds}s"); + } + + public static QuestFrame BuildFrame(MmContext context, string questName, string countdown) => + new() + { + Timestamp = Wire.Timestamp(context.Clock), + CharacterName = context.CharacterName, + QuestName = questName, + Countdown = countdown, + }; +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs index 6d188b3..0e42c97 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs @@ -9,6 +9,8 @@ public sealed class BackendSessionTests { using var harness = new PluginHarness("Mossy"); harness.Login(); + // Login itself submits /myquests; these tests count only what the backend ran. + harness.Host.Automation.Chat.Submitted.Clear(); harness.Receive("{\"player_name\":\"mossy\",\"command\":\"/a hello\"}"); Assert.Empty(harness.Host.Automation.Chat.Submitted); @@ -23,6 +25,8 @@ public sealed class BackendSessionTests { using var harness = new PluginHarness("Mossy"); harness.Login(); + // Login itself submits /myquests; these tests count only what the backend ran. + harness.Host.Automation.Chat.Submitted.Clear(); harness.Receive("{\"player_name\":\"Horan\",\"command\":\"/a hello\"}"); harness.Tick(0.01); @@ -35,6 +39,8 @@ public sealed class BackendSessionTests { using var harness = new PluginHarness("Mossy"); harness.Login(); + // Login itself submits /myquests; these tests count only what the backend ran. + harness.Host.Automation.Chat.Submitted.Clear(); harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/one\"}"); harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/two\"}"); @@ -49,6 +55,8 @@ public sealed class BackendSessionTests { using var harness = new PluginHarness("Mossy"); harness.Login(); + // Login itself submits /myquests; these tests count only what the backend ran. + harness.Host.Automation.Chat.Submitted.Clear(); var toggles = new List(); harness.Session.RadarToggle = on => toggles.Add(on); @@ -68,6 +76,8 @@ public sealed class BackendSessionTests var seen = new List(); harness.Session.AddShareHandler(new RecordingShareHandler(seen)); harness.Login(); + // Login itself submits /myquests; these tests count only what the backend ran. + harness.Host.Automation.Chat.Submitted.Clear(); harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"Horan\"}"); @@ -81,6 +91,8 @@ public sealed class BackendSessionTests { using var harness = new PluginHarness("Mossy"); harness.Login(); + // Login itself submits /myquests; these tests count only what the backend ran. + harness.Host.Automation.Chat.Submitted.Clear(); harness.Receive("not json"); harness.Receive("[1,2,3]"); @@ -94,6 +106,8 @@ public sealed class BackendSessionTests { using var harness = new PluginHarness("Mossy"); harness.Login(); + // Login itself submits /myquests; these tests count only what the backend ran. + harness.Host.Automation.Chat.Submitted.Clear(); harness.Session.OnConnected(new DateTime(2026, 9, 25, 8, 5, 9, DateTimeKind.Utc)); diff --git a/tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs new file mode 100644 index 0000000..257c684 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs @@ -0,0 +1,232 @@ +using OpenAC.MosswartMassacre.Quests; + +namespace OpenAC.MosswartMassacre.Tests.Quests; + +public sealed class QuestTests +{ + // Real answer lines in the shape the server writes them (ACE's /myquests: + // "stamp - N solves (last)\"message\" max delta"). The first is the example + // in the original's parser comment, the second the one UtilityBelt quotes. + private const string CommentSample = "SomeQuest - 5 solves (1640995200)\"Quest description here\" 10 3600"; + private const string UbSample = + "regaliamaskuber - 1 solves (1522807913)\"Player has collected the mask from a Virinidi Profatrix.\" -1 72000"; + + private static long UnixNow(PluginHarness harness) => + new DateTimeOffset(harness.Clock.UtcNow).ToUnixTimeSeconds(); + + private static string Line(string stamp, int solves, long lastSolved, int max, long delta) => + $"{stamp} - {solves} solves ({lastSolved})\"A message\" {max} {delta}"; + + [Fact] + public void Login_asks_the_server_and_reads_the_answer_for_three_seconds() + { + using var harness = new PluginHarness(); + harness.Login(); + QuestManager quests = harness.Feature(); + + Assert.Equal(["/myquests"], harness.Host.Automation.Chat.Submitted); + Assert.True(quests.IsRefreshing); + + harness.Host.Automation.Chat.Deliver(CommentSample); + harness.Host.Automation.Chat.Deliver(UbSample); + harness.Host.Automation.Chat.Deliver("You say, \"hello - 3 solves\""); + harness.Run(3.1); + harness.Host.Automation.Chat.Deliver("late - 1 solves (1)\"x\" 1 1"); + + Assert.False(quests.IsRefreshing); + Assert.Equal(2, quests.QuestList.Count); + + Quest first = quests.QuestList[0]; + Assert.Equal("SomeQuest", first.Id); + Assert.Equal(5, first.Solves); + Assert.Equal(1640995200, first.Timestamp); + Assert.Equal("Quest description here", first.Description); + Assert.Equal(10, first.MaxSolves); + Assert.Equal(3600, first.Delta); + Assert.Equal(1640995200 + 3600, first.ExpireTime); + + Quest second = quests.QuestList[1]; + Assert.Equal("regaliamaskuber", second.Id); + Assert.Equal(-1, second.MaxSolves); + Assert.Equal(72000, second.Delta); + Assert.Equal("Player has collected the mask from a Virinidi Profatrix.", second.Description); + } + + [Fact] + public void A_refresh_while_one_is_reading_is_ignored_and_the_answer_stays_in_chat() + { + using var harness = new PluginHarness(); + harness.Login(); + + harness.Context.Commands.Dispatch("refreshquests"); + + Assert.Equal(["/myquests"], harness.Host.Automation.Chat.Submitted); + Assert.Contains("[Mosswart Massacre] [QUEST] Refreshing quest data...", harness.ChatLines); + + harness.Host.Automation.Chat.Deliver(UbSample); + // The original never ate the answer lines. + Assert.Contains(harness.Host.Automation.Chat.Delivered, message => message.Text == UbSample); + + harness.Run(3.1); + harness.Context.Commands.Dispatch("refreshquests"); + Assert.Equal(["/myquests", "/myquests"], harness.Host.Automation.Chat.Submitted); + Assert.Empty(harness.Feature().QuestList); + } + + [Fact] + public void A_line_whose_number_overflows_is_reported_like_the_original() + { + using var harness = new PluginHarness(); + harness.Login(); + + harness.Host.Automation.Chat.Deliver("big - 1 solves (1)\"x\" 1 99999999999"); + + Assert.Empty(harness.Feature().QuestList); + Assert.Contains(harness.ChatLines, line => line.StartsWith( + "[Mosswart Massacre] Error parsing quest line 'big - 1 solves (1)\"x\" 1 99999999999': ", + StringComparison.Ordinal)); + } + + [Fact] + public void The_quest_frame_is_exactly_the_original_one() + { + // The original: WebSocket.SendQuestDataAsync serialized + // new { type = "quest", timestamp = DateTime.UtcNow.ToString("o"), + // character_name = CharacterFilter.Name, quest_name, countdown } + // with JsonConvert.SerializeObject, and QuestStreamingService passed + // QuestManager.GetFriendlyQuestName(quest.Id) and FormatCountdown. + using var harness = new PluginHarness(); + harness.Login(); + long now = UnixNow(harness); + harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 3, now - 100, -1, 100 + 93600)); + + harness.Feature().StreamPriorityQuests(); + + Assert.Equal( + ["{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Stipend Collection Timer\",\"countdown\":\"1d 02h\"}"], + harness.Frames); + } + + [Fact] + public void Only_the_three_priority_quests_stream_once_each_in_answer_order() + { + using var harness = new PluginHarness(); + harness.Login(); + long now = UnixNow(harness); + harness.Host.Automation.Chat.Deliver(Line("insatiableeaterjaw", 1, now - 10, -1, 5)); + harness.Host.Automation.Chat.Deliver(Line("somethingelse", 1, now, -1, 500)); + harness.Host.Automation.Chat.Deliver(Line("augmentationblankgemacquired", 1, now, -1, 3 * 3600 + 5 * 60 + 30)); + harness.Host.Automation.Chat.Deliver(Line("insatiableeaterjaw", 9, now, -1, 99999)); + + harness.Feature().StreamPriorityQuests(); + + // "insatiableeaterjaw" is in the name table twice; the later entry, + // "Insatiable Eater Jaw", is the one the original sent. + Assert.Equal( + [ + "{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Insatiable Eater Jaw\",\"countdown\":\"READY\"}", + "{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Blank Augmentation Gem Pickup Timer\",\"countdown\":\"3h 05m\"}", + ], + harness.Frames); + } + + [Fact] + public void The_stream_runs_every_thirty_seconds_after_login() + { + using var harness = new PluginHarness(); + harness.Login(); + long now = UnixNow(harness); + harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, now, -1, 3600 + 30)); + + harness.Run(29.5); + Assert.Empty(harness.FramesOfType("quest")); + + harness.Run(1); + Assert.Single(harness.FramesOfType("quest")); + Assert.Equal("1h 00m", (string?)harness.FramesOfType("quest").Single()["countdown"]); + + harness.Run(30); + Assert.Equal(2, harness.FramesOfType("quest").Count()); + } + + [Fact] + public void Nothing_streams_while_streaming_is_off_or_after_logoff() + { + using var off = new PluginHarness(webSocketEnabled: false); + off.Login(); + off.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(off), -1, 60)); + off.Feature().StreamPriorityQuests(); + Assert.Empty(off.Frames); + + using var harness = new PluginHarness(); + harness.Login(); + harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(harness), -1, 60)); + harness.Plugin.OnLogoff(); + harness.Run(31); + Assert.Empty(harness.Frames); + Assert.Equal(0, harness.Host.Automation.Chat.ReceivedSubscriberCount); + } + + [Fact] + public void Quest_status_lists_the_priority_quests() + { + using var harness = new PluginHarness(); + harness.Login(); + harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(harness), -1, 60)); + + harness.Context.Commands.Dispatch("queststatus"); + + Assert.Contains("[Mosswart Massacre] Timer Active: True", harness.ChatLines); + Assert.Contains("[Mosswart Massacre] Quest Count: 1", harness.ChatLines); + Assert.Contains("[Mosswart Massacre] Priority Quests Found: 1", harness.ChatLines); + Assert.Contains("[Mosswart Massacre] - Stipend Collection Timer (stipendtimer_0812)", harness.ChatLines); + } + + [Theory] + [InlineData(-5L, "READY")] + [InlineData(0L, "READY")] + [InlineData(1L, "1s")] + [InlineData(59L, "59s")] + [InlineData(60L, "1m 00s")] + [InlineData(69L, "1m 09s")] + [InlineData(3599L, "59m 59s")] + [InlineData(3600L, "1h 00m")] + [InlineData(3660L, "1h 01m")] + [InlineData(86399L, "23h 59m")] + [InlineData(86400L, "1d 00h")] + [InlineData(93600L, "1d 02h")] + [InlineData(10L * 86400 + 23 * 3600 + 3599, "10d 23h")] + public void Countdown_formats_like_the_original(long seconds, string expected) + { + Assert.Equal(expected, QuestStreamingService.FormatCountdown(seconds)); + } + + [Theory] + [InlineData(0, "0s")] + [InlineData(-1, "0s")] + [InlineData(59, "59s")] + [InlineData(60, "1m")] + [InlineData(3600, "1h")] + [InlineData(72000, "20h")] + [InlineData(90061, "1d 1h 1m 1s")] + [InlineData(1209600, "14d")] + public void Cooldown_formats_like_the_original(int seconds, string expected) + { + Assert.Equal(expected, QuestManager.FormatSeconds(seconds)); + } + + [Fact] + public void Last_solved_is_month_first_in_utc_whatever_the_culture() + { + System.Globalization.CultureInfo saved = System.Globalization.CultureInfo.CurrentCulture; + try + { + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("sv-SE"); + Assert.Equal("04/04/2018 05:31:53", QuestManager.FormatTimeStamp(1522819913)); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = saved; + } + } +}