From 0b5da797e0ef076cea62b0dd979b39a47b336669 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:14:25 +0200 Subject: [PATCH 1/3] Quests: /myquests parsing and the 30-second quest stream The backend's quest panel shows the stipend, blank aug gem and insatiable jaw timers from the `quest` frame, so the port reads /myquests the way the original did (a three-second window of chat lines matched by its regex) and streams the three priority stamps every 30 s with the same five-field frame and the same countdown strings. The name table is copied as it was, so the duplicate insatiableeaterjaw entry still resolves to the later name. Timers run on the tick scheduler instead of thread-pool timers. The session tests clear the login-time /myquests before counting the commands the backend ran. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 7 +- .../Quests/QuestManager.cs | 325 ++++++++++++++++++ .../Quests/QuestNames.cs | 211 ++++++++++++ .../Quests/QuestStreamingService.cs | 70 ++++ .../Backend/BackendSessionTests.cs | 14 + .../Quests/QuestTests.cs | 232 +++++++++++++ 6 files changed, 857 insertions(+), 2 deletions(-) create mode 100644 src/OpenAC.MosswartMassacre/Quests/QuestManager.cs create mode 100644 src/OpenAC.MosswartMassacre/Quests/QuestNames.cs create mode 100644 src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs 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; + } + } +} From 714fbfa2e803e09e838ac7b3309f66f2577ac30d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:20:52 +0200 Subject: [PATCH 2/3] Flag tracker: the Mossy Tracker window as a markup panel The owner calls the flag tracker important, so the original's pages come across with their tables unchanged: augmentations and luminance auras from the player's own int properties (the object table carries them), recalls from the spellbook with composited spell icons, cantrips matched from the active enchantments to trained skills, attributes and wards, the tracked weapons the original kept data for but never showed, and the quest timers. Read on the tick thread at login and on each page's Refresh; the quest page redraws every five seconds instead of from a thread-pool timer. The root panel's visibility is the settable IsVisible so the main window can open it; the three sections the original left as stubs stay out, and a headless host registers nothing. The fake spell catalog learns IsKnown. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 2 + .../FlagTracker/FlagTrackerData.cs | 672 ++++++++++++++++++ .../FlagTracker/FlagTrackerFeature.cs | 159 +++++ .../FlagTracker/FlagTrackerViewModel.cs | 319 +++++++++ .../mosswartmassacre-flagtracker.xml | 119 ++++ .../Fakes/FakeAutomation.cs | 5 + .../FlagTracker/FlagTrackerTests.cs | 239 +++++++ .../Ui/TrackerAndMetasMarkupContractTests.cs | 220 ++++++ 8 files changed, 1735 insertions(+) create mode 100644 src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs create mode 100644 src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs create mode 100644 src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs create mode 100644 src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml create mode 100644 tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 448bcf0..d8b0610 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -1,4 +1,5 @@ using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.FlagTracker; using OpenAC.MosswartMassacre.Quests; namespace OpenAC.MosswartMassacre; @@ -16,5 +17,6 @@ internal static class FeatureCatalog // Quests, flag tracker, meta sync. var quests = new QuestManager(context); features.Add(quests); + features.Add(new FlagTrackerFeature(context, quests)); } } diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs new file mode 100644 index 0000000..5c50555 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs @@ -0,0 +1,672 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.FlagTracker; + +/// +/// What the flag tracker shows, and how each tab reads it from the character: +/// augmentation and luminance counts from the player's own integer properties, +/// recalls from the spellbook, cantrips from the active enchantments matched +/// against the trained skills, and the tracked weapons from the inventory by +/// name. The tables are the original plugin's, copied as they were. +/// +/// +/// The original's society, facility hub and character flag sections were +/// never implemented there and are not here. Every read happens on the tick +/// thread, when a tab is refreshed. +/// +internal sealed class FlagTrackerData +{ + /// Row text colours, 0xRRGGBB, the named colours the original used. + public static class Colors + { + public const uint White = 0xFFFFFF; + public const uint Red = 0xFF0000; + public const uint Green = 0x008000; + public const uint Yellow = 0xFFFF00; + public const uint Blue = 0x0000FF; + public const uint Purple = 0x800080; + public const uint Orange = 0xFFA500; + } + + /// Icons the original drew: the cantrip lamps, the category star and the portal fallback. + public static class Icons + { + public const uint GreenCircle = 0x060011F9; + public const uint RedCircle = 0x060011F8; + public const uint CategoryStar = 0x06002856; + public const uint Portal = 0x06002D14; + } + + public const string NotAvailable = "N/A"; + + public sealed class AugmentationInfo + { + public required string Name { get; init; } + public uint? IntId { get; init; } + public int Repeatable { get; init; } + public string Trainer { get; init; } = string.Empty; + public string Location { get; init; } = string.Empty; + public int CurrentValue { get; set; } + public bool IsMaxed => CurrentValue >= Repeatable; + } + + public sealed class LuminanceAuraInfo + { + public required string Name { get; init; } + public uint IntId { get; init; } + public int Cap { get; init; } + + /// The seer's loyalty flag, for the seer auras only. + public string? QuestFlag { get; init; } + + public int CurrentValue { get; set; } + } + + public sealed class RecallSpellInfo + { + public required string Name { get; init; } + public uint SpellId { get; init; } + public string Category { get; init; } = string.Empty; + public bool IsKnown { get; set; } + } + + public sealed class CantripInfo + { + public required string Name { get; init; } + + /// "Minor" through "Legendary", or "N/A" when the character has none. + public string Value { get; set; } = NotAvailable; + + public uint Color { get; set; } = Colors.White; + + /// The green lamp when the cantrip is up, the red one when it is not. + public uint ComputedIconId { get; set; } + } + + public sealed class TrackedWeaponInfo + { + public required string Name { get; init; } + public string Category { get; init; } = string.Empty; + public string WeaponType { get; init; } = string.Empty; + public int ItemId { get; init; } + public string Status { get; set; } = "Unknown"; + public bool IsAcquired { get; set; } + public uint IconId { get; set; } + } + + // Cantrip skill names that differ from the skill's own name. + private static readonly Dictionary SkillCantripReplacements = new() + { + [15] = "MagicResistance", + [6] = "Invulnerability", + [7] = "Impgrenability", + [47] = "MissileWeapon", + [44] = "HeavyWeapon", + [45] = "LightWeapon", + [46] = "FinesseWeapon", + }; + + private static readonly (uint Id, string Name)[] SkillIdToName = + [ + (1, "Axe"), (2, "Bow"), (3, "Crossbow"), (4, "Dagger"), (5, "Mace"), + (6, "Melee Defense"), (7, "Missile Defense"), (8, "Sling"), (9, "Spear"), + (10, "Staff"), (11, "Sword"), (12, "Thrown Weapons"), (13, "Unarmed Combat"), + (14, "Arcane Lore"), (15, "Magic Defense"), (16, "Mana Conversion"), + (17, "Spellcraft"), (18, "Item Tinkering"), (19, "Assess Person"), + (20, "Deception"), (21, "Healing"), (22, "Jump"), (23, "Lockpick"), + (24, "Run"), (25, "Awareness"), (26, "Arms and Armor Repair"), + (27, "Assess Creature"), (28, "Weapon Tinkering"), (29, "Armor Tinkering"), + (30, "Magic Item Tinkering"), (31, "Creature Enchantment"), + (32, "Item Enchantment"), (33, "Life Magic"), (34, "War Magic"), + (35, "Leadership"), (36, "Loyalty"), (37, "Fletching"), (38, "Alchemy"), + (39, "Cooking"), (40, "Salvaging"), (41, "Two Handed Combat"), + (42, "Gearcraft"), (43, "Void Magic"), (44, "Heavy Weapons"), + (45, "Light Weapons"), (46, "Finesse Weapons"), (47, "Missile Weapons"), + (48, "Shield"), (49, "Dual Wield"), (50, "Recklessness"), + (51, "Sneak Attack"), (52, "Dirty Fighting"), (53, "Threat Assessment"), + (54, "Summoning"), + ]; + + private static readonly (string Prefix, uint Color)[] CantripPatterns = + [ + ("Minor", Colors.White), + ("Moderate", Colors.Green), + ("Major", Colors.Blue), + ("Epic", Colors.Purple), + ("Legendary", Colors.Orange), + ]; + + private static readonly (string Key, string Aura)[] ProtectionMappings = + [ + ("Armor", "Armor"), + ("Bludgeoning Ward", "Bludgeoning Ward"), + ("Piercing Ward", "Piercing Ward"), + ("Slashing Ward", "Slashing Ward"), + ("Flame Ward", "Flame Ward"), + ("Frost Ward", "Frost Ward"), + ("Cold Ward", "Frost Ward"), + ("Acid Ward", "Acid Ward"), + ("Storm Ward", "Storm Ward"), + ("Lightning Ward", "Storm Ward"), + ("Bludgeoning Protection", "Bludgeoning Ward"), + ("Piercing Protection", "Piercing Ward"), + ("Slashing Protection", "Slashing Ward"), + ("Fire Protection", "Flame Ward"), + ("Cold Protection", "Frost Ward"), + ("Acid Protection", "Acid Ward"), + ("Lightning Protection", "Storm Ward"), + ("Bludgeoning", "Bludgeoning Ward"), + ("Piercing", "Piercing Ward"), + ("Slashing", "Slashing Ward"), + ("Fire", "Flame Ward"), + ("Flame", "Flame Ward"), + ("Cold", "Frost Ward"), + ("Frost", "Frost Ward"), + ("Acid", "Acid Ward"), + ("Lightning", "Storm Ward"), + ("Storm", "Storm Ward"), + ]; + + private static readonly (string Key, string Attribute)[] AttributeMappings = + [ + ("Strength", "Strength"), + ("Endurance", "Endurance"), + ("Coordination", "Coordination"), + ("Quickness", "Quickness"), + ("Focus", "Focus"), + ("Self", "Willpower"), + ("Willpower", "Willpower"), + ]; + + private static readonly (string Key, string Skill)[] SkillMappings = + [ + ("Magic Resistance", "MagicResistance"), + ("Invulnerability", "Invulnerability"), + ("Impregnability", "Impgrenability"), + ("Heavy Weapon Aptitude", "HeavyWeapon"), + ("Light Weapon Aptitude", "LightWeapon"), + ("Finesse Weapon Aptitude", "FinesseWeapon"), + ("Missile Weapon Aptitude", "MissileWeapon"), + ("Alchemical Prowess", "Alchemy"), + ("Arcane Prowess", "Arcane Lore"), + ("Armor Tinkering Expertise", "Armor Tinkering"), + ("Assess Creature", "Assess Creature"), + ("Assess Person", "Assess Person"), + ("Cooking Prowess", "Cooking"), + ("Deception Prowess", "Deception"), + ("Fletching Prowess", "Fletching"), + ("Healing Prowess", "Healing"), + ("Item Tinkering Expertise", "Item Tinkering"), + ("Leadership", "Leadership"), + ("Lockpick Prowess", "Lockpick"), + ("Fealty", "Loyalty"), + ("Magic Item Tinkering Expertise", "Magic Item Tinkering"), + ("Mana Conversion Prowess", "Mana Conversion"), + ("Jumping Prowess", "Jump"), + ("Salvaging Aptitude", "Salvaging"), + ("Weapon Tinkering Expertise", "Weapon Tinkering"), + ("War Magic Aptitude", "War Magic"), + ("Life Magic Aptitude", "Life Magic"), + ("Creature Enchantment Aptitude", "Creature Enchantment"), + ("Item Enchantment Aptitude", "Item Enchantment"), + ("Void Magic Aptitude", "Void Magic"), + ("Summoning Prowess", "Summoning"), + ("Two Handed Combat Aptitude", "Two Handed Combat"), + ("Dual Wield Aptitude", "Dual Wield"), + ("Shield Aptitude", "Shield"), + ("Sneak Attack Prowess", "Sneak Attack"), + ("Dirty Fighting Prowess", "Dirty Fighting"), + ("Recklessness", "Recklessness"), + ]; + + private static readonly Dictionary CantripRank = new(StringComparer.Ordinal) + { + ["Minor"] = 1, + ["Moderate"] = 2, + ["Major"] = 3, + ["Epic"] = 4, + ["Legendary"] = 5, + }; + + private readonly IAutomationSurface _automation; + + public FlagTrackerData(IAutomationSurface automation) + { + _automation = automation; + AugmentationCategories = BuildAugmentations(); + LuminanceAuraCategories = BuildLuminanceAuras(); + RecallSpells = BuildRecalls(); + Cantrips = BuildCantrips(); + WeaponCategories = BuildWeapons(); + } + + public Dictionary> AugmentationCategories { get; } + + public Dictionary> LuminanceAuraCategories { get; } + + public List RecallSpells { get; } + + public Dictionary> Cantrips { get; } + + public Dictionary> WeaponCategories { get; private set; } + + private bool InWorld => _automation.IsAvailable && _automation.Character.IsInWorld; + + public void RefreshAll() + { + RefreshAugmentations(); + RefreshLuminanceAuras(); + RefreshRecallSpells(); + RefreshCantrips(); + RefreshWeapons(); + } + + public void RefreshAugmentations() + { + if (!InWorld) + return; + foreach (List augmentations in AugmentationCategories.Values) + { + foreach (AugmentationInfo aug in augmentations) + { + // An augmentation with no property (the health one) keeps its value, as it did. + if (aug.IntId is uint id) + aug.CurrentValue = ReadCharacterInt(id); + } + } + } + + public void RefreshLuminanceAuras() + { + if (!InWorld) + return; + foreach (List auras in LuminanceAuraCategories.Values) + { + foreach (LuminanceAuraInfo aura in auras) + aura.CurrentValue = ReadCharacterInt(aura.IntId); + } + } + + public void RefreshRecallSpells() + { + if (!InWorld) + return; + foreach (RecallSpellInfo recall in RecallSpells) + recall.IsKnown = _automation.Spells.IsKnown(recall.SpellId); + } + + public void RefreshCantrips() + { + if (!InWorld) + return; + + Cantrips["Specialized Skills"].Clear(); + Cantrips["Trained Skills"].Clear(); + PopulateCharacterSkills(); + + foreach (Dictionary category in Cantrips.Values) + { + foreach (CantripInfo cantrip in category.Values) + { + cantrip.Value = NotAvailable; + cantrip.Color = Colors.White; + } + } + + foreach (PluginActiveEnchantment enchantment in _automation.Character.ActiveEnchantments) + { + if (_automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell) + && !string.IsNullOrEmpty(spell.Name)) + { + DetectCantrip(spell.Name); + } + } + + foreach (Dictionary category in Cantrips.Values) + { + foreach (CantripInfo cantrip in category.Values) + cantrip.ComputedIconId = cantrip.Value != NotAvailable ? Icons.GreenCircle : Icons.RedCircle; + } + } + + public void RefreshWeapons() + { + if (!InWorld) + return; + + WeaponCategories = BuildWeapons(); + IReadOnlyList inventory = _automation.Items.CaptureOwnedItems(); + foreach (List weapons in WeaponCategories.Values) + { + foreach (TrackedWeaponInfo weapon in weapons) + { + // Matched by name either way round, as the original matched + // it; an item with no name matches nothing. + PluginInventoryItem? found = null; + foreach (PluginInventoryItem item in inventory) + { + if (string.IsNullOrEmpty(item.Name)) + continue; + if (item.Name.Contains(weapon.Name, StringComparison.Ordinal) + || weapon.Name.Contains(item.Name, StringComparison.Ordinal)) + { + found = item; + break; + } + } + + if (found is { } match) + { + weapon.IsAcquired = true; + weapon.Status = "Acquired"; + weapon.IconId = match.IconId; + } + else + { + weapon.IsAcquired = false; + weapon.Status = "Not Acquired"; + weapon.IconId = Icons.Portal; + } + } + } + } + + /// + /// Files one active spell under its cantrip row when its name is a + /// cantrip's: "Epic Flame Ward", "Major Strength", "Legendary War Magic + /// Aptitude". A higher level replaces a lower one; anything else is left. + /// + internal void DetectCantrip(string spellName) + { + foreach ((string prefix, uint color) in CantripPatterns) + { + if (!spellName.StartsWith(prefix + " ", StringComparison.Ordinal)) + continue; + + string skillPart = spellName[(prefix.Length + 1)..]; + if (MatchProtectionAura(skillPart, prefix, color)) + return; + if (MatchAttribute(skillPart, prefix, color)) + return; + if (MatchSkill(skillPart, prefix, color)) + return; + } + } + + private int ReadCharacterInt(uint property) => + _automation.Objects.TryGetIntProperty(_automation.Character.ObjectId, property, out int value) + ? value + : 0; + + private void PopulateCharacterSkills() + { + foreach ((uint skillId, string name) in SkillIdToName) + { + if (!_automation.Character.TryGetSkill(skillId, out PluginSkillInfo skill)) + continue; + + string skillName = SkillCantripReplacements.TryGetValue(skillId, out string? replacement) + ? replacement + : name; + + if (skill.Training == PluginSkillTraining.Specialized) + Cantrips["Specialized Skills"][skillName] = new CantripInfo { Name = skillName }; + else if (skill.Training == PluginSkillTraining.Trained) + Cantrips["Trained Skills"][skillName] = new CantripInfo { Name = skillName }; + } + } + + private bool MatchProtectionAura(string skillPart, string level, uint color) + { + foreach ((string key, string aura) in ProtectionMappings) + { + if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + Raise(Cantrips["Protection Auras"], aura, level, color); + return true; + } + return false; + } + + private bool MatchAttribute(string skillPart, string level, uint color) + { + string cleaned = skillPart.Trim(); + foreach ((string key, string attribute) in AttributeMappings) + { + if (!cleaned.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + Raise(Cantrips["Attributes"], attribute, level, color); + return true; + } + + // Then the looser match the original fell back to: the name contains an attribute. + foreach ((string key, string attribute) in AttributeMappings) + { + if (cleaned.IndexOf(key, StringComparison.OrdinalIgnoreCase) < 0) + continue; + Raise(Cantrips["Attributes"], attribute, level, color); + return true; + } + return false; + } + + private bool MatchSkill(string skillPart, string level, uint color) + { + foreach ((string key, string skill) in SkillMappings) + { + if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + foreach (string category in new[] { "Specialized Skills", "Trained Skills" }) + { + if (!Cantrips[category].TryGetValue(skill, out CantripInfo? cantrip)) + continue; + if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value)) + { + cantrip.Value = level; + cantrip.Color = color; + } + return true; + } + } + return false; + } + + private static void Raise(Dictionary category, string name, string level, uint color) + { + if (!category.TryGetValue(name, out CantripInfo? cantrip)) + { + cantrip = new CantripInfo { Name = name }; + category[name] = cantrip; + } + if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value)) + { + cantrip.Value = level; + cantrip.Color = color; + } + } + + private static bool IsHigherCantripLevel(string newLevel, string currentLevel) => + CantripRank.TryGetValue(newLevel, out int next) + && CantripRank.TryGetValue(currentLevel, out int current) + && next > current; + + private static Dictionary> BuildAugmentations() => new() + { + ["Death Augs"] = + [ + new() { Name = "Keep Items", IntId = 231, Repeatable = 3, Trainer = "Rohula bint Ludun", Location = "Ayan Baqur" }, + new() { Name = "Keep Spells", IntId = 232, Repeatable = 1, Trainer = "Erik Festus", Location = "Ayan Baqur" }, + ], + ["Skill Augs"] = + [ + new() { Name = "+5 All Skills", IntId = 326, Repeatable = 1, Trainer = "Arianna the Adept", Location = "Bandit Castle" }, + new() { Name = "+10 Melee Skills", IntId = 300, Repeatable = 1, Trainer = "Carlito Gallo", Location = "Silyun" }, + new() { Name = "+10 Magic Skills", IntId = 302, Repeatable = 1, Trainer = "Rahina bint Zalanis", Location = "Zaikhal" }, + new() { Name = "+10 Missile Skills", IntId = 301, Repeatable = 1, Trainer = "Kilaf", Location = "Zaikhal" }, + ], + ["Rating Augs"] = + [ + new() { Name = "25% Crit Protection", IntId = 233, Repeatable = 1, Trainer = "Piersanti Linante", Location = "Sanamar" }, + new() { Name = "1% Critical Chance", IntId = 298, Repeatable = 1, Trainer = "Anfram Mellow", Location = "Ayan Baqur" }, + new() { Name = "3% Critical Damage", IntId = 299, Repeatable = 1, Trainer = "Alishia bint Aldan", Location = "Ayan Baqur" }, + new() { Name = "3% Damage Rating", IntId = 309, Repeatable = 1, Trainer = "Neela Nashua", Location = "Bandit Castle" }, + new() { Name = "3% Damage Reduction", IntId = 310, Repeatable = 1, Trainer = "Emily Yarow", Location = "Cragstone" }, + ], + ["Burden / Pack Augs"] = + [ + new() { Name = "Extra Carrying Capacity", IntId = 230, Repeatable = 5, Trainer = "Husoon", Location = "Zaikhal" }, + new() { Name = "Extra Pack Slot", IntId = 229, Repeatable = 1, Trainer = "Dumida bint Ruminre", Location = "Zaikhal" }, + new() { Name = "Infused War Magic", IntId = 297, Repeatable = 1, Trainer = "Raphel Detante", Location = "Silyun" }, + new() { Name = "Infused Void Magic", IntId = 328, Repeatable = 1, Trainer = "Morathe", Location = "Candeth Keep" }, + new() { Name = "Infused Creature Magic", IntId = 294, Repeatable = 1, Trainer = "Gustuv Lansdown", Location = "Cragstone" }, + new() { Name = "Infused Life Magic", IntId = 296, Repeatable = 1, Trainer = "Akemi Fei", Location = "Hebian-To" }, + new() { Name = "Infused Item Magic", IntId = 295, Repeatable = 1, Trainer = "Gan Fo", Location = "Hebian-To" }, + ], + ["Misc Augs"] = + [ + new() { Name = "10% Health Increase", IntId = null, Repeatable = 1, Trainer = "Donatello Linante", Location = "Silyun" }, + new() { Name = "Increased Spell Duration", IntId = 238, Repeatable = 5, Trainer = "Nawamara Ujio", Location = "Mayoi" }, + new() { Name = "Faster HP Regen", IntId = 237, Repeatable = 2, Trainer = "Alison Dulane", Location = "Bandit Castle" }, + new() { Name = "5% Experience Increase", IntId = 234, Repeatable = 1, Trainer = "Rickard Dumalia", Location = "Silyun" }, + ], + ["Salvage Augs"] = + [ + new() { Name = "Specialized Weapon Tinkering", IntId = 228, Repeatable = 1, Trainer = "Lenor Turk", Location = "Cragstone" }, + new() { Name = "Specialized Armor Tinkering", IntId = 226, Repeatable = 1, Trainer = "Joshun Felden", Location = "Cragstone" }, + new() { Name = "Specialized Item Tinkering", IntId = 225, Repeatable = 1, Trainer = "Brienne Carlus", Location = "Cragstone" }, + new() { Name = "Specialized Magic Item Tinkering", IntId = 227, Repeatable = 1, Trainer = "Burrell Sammrun", Location = "Cragstone" }, + new() { Name = "Specialized Salvaging", IntId = 224, Repeatable = 1, Trainer = "Robert Crow", Location = "Cragstone" }, + new() { Name = "25% More Salvage", IntId = 235, Repeatable = 4, Trainer = "Kris Cennis", Location = "Cragstone" }, + new() { Name = "5% Imbue Chance", IntId = 236, Repeatable = 1, Trainer = "Lug", Location = "Oolutanga's Refuge" }, + ], + ["Stat Augs"] = + [ + new() { Name = "All Stats", IntId = 217, Repeatable = 10, Trainer = "", Location = "" }, + new() { Name = "Strength", IntId = 218, Repeatable = 10, Trainer = "Fiun Luunere", Location = "Fiun Outpost" }, + new() { Name = "Endurance", IntId = 219, Repeatable = 10, Trainer = "Fiun Ruun", Location = "Fiun Outpost" }, + new() { Name = "Coordination", IntId = 220, Repeatable = 10, Trainer = "Fiun Bayaas", Location = "Fiun Outpost" }, + new() { Name = "Quickness", IntId = 221, Repeatable = 10, Trainer = "Fiun Riish", Location = "Fiun Outpost" }, + new() { Name = "Focus", IntId = 222, Repeatable = 10, Trainer = "Fiun Vasherr", Location = "Fiun Outpost" }, + new() { Name = "Self", IntId = 223, Repeatable = 10, Trainer = "Fiun Noress", Location = "Fiun Outpost" }, + ], + ["Resistance Augs"] = + [ + new() { Name = "All Resistances", IntId = 239, Repeatable = 2, Trainer = "", Location = "" }, + new() { Name = "Blunt", IntId = 242, Repeatable = 2, Trainer = "Nawamara Dia", Location = "Hebian-To" }, + new() { Name = "Pierce", IntId = 241, Repeatable = 2, Trainer = "Kyujo Rujen", Location = "Hebian-To" }, + new() { Name = "Slashing", IntId = 240, Repeatable = 2, Trainer = "Ilin Wis", Location = "Hebian-To" }, + new() { Name = "Fire", IntId = 244, Repeatable = 2, Trainer = "Rikshen Ri", Location = "Hebian-To" }, + new() { Name = "Frost", IntId = 245, Repeatable = 2, Trainer = "Lu Bao", Location = "Hebian-To" }, + new() { Name = "Acid", IntId = 243, Repeatable = 2, Trainer = "Shujio Milao", Location = "Hebian-To" }, + new() { Name = "Lightning", IntId = 246, Repeatable = 2, Trainer = "Enli Yuo", Location = "Hebian-To" }, + ], + }; + + private static Dictionary> BuildLuminanceAuras() => new() + { + ["Nalicana Auras"] = + [ + new() { Name = "+1 Aetheria Proc Rating", IntId = 338, Cap = 5 }, + new() { Name = "+1 Damage Reduction Rating", IntId = 334, Cap = 5 }, + new() { Name = "+1 Crit Reduction Rating", IntId = 336, Cap = 5 }, + new() { Name = "+1 Damage Rating", IntId = 333, Cap = 5 }, + new() { Name = "+1 Crit Damage Rating", IntId = 335, Cap = 5 }, + new() { Name = "+1 Heal Rating", IntId = 342, Cap = 5 }, + new() { Name = "+1 Equipment Mana Rating", IntId = 339, Cap = 5 }, + new() { Name = "+1 Mana Stone Rating", IntId = 340, Cap = 5 }, + new() { Name = "+1 Crafting Skills", IntId = 343, Cap = 5 }, + new() { Name = "+1 All Skills", IntId = 365, Cap = 10 }, + ], + ["Seer Auras"] = + [ + new() { Name = "(Ka'hiri) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToKahiri" }, + new() { Name = "(Ka'hiri) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToKahiri" }, + new() { Name = "(Shade of Lady Adja) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" }, + new() { Name = "(Shade of Lady Adja) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" }, + new() { Name = "(Liam of Gelid) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" }, + new() { Name = "(Liam of Gelid) +1 Crit Damage Rating", IntId = 335, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" }, + new() { Name = "(Lord Tyragar) +1 Crit Reduction Rating", IntId = 336, Cap = 5, QuestFlag = "LoyalToLordTyragar" }, + new() { Name = "(Lord Tyragar) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToLordTyragar" }, + ], + }; + + private static List BuildRecalls() => + [ + new() { Name = "Recall the Sanctuary", SpellId = 2023, Category = "Basic Recalls" }, + new() { Name = "Aerlinthe Recall", SpellId = 2041, Category = "Island Recalls" }, + new() { Name = "Mount Lethe Recall", SpellId = 2813, Category = "Island Recalls" }, + new() { Name = "Recall Aphus Lassel", SpellId = 2931, Category = "Island Recalls" }, + new() { Name = "Ulgrim's Recall", SpellId = 2941, Category = "Special Recalls" }, + new() { Name = "Recall to the Singularity Caul", SpellId = 2943, Category = "Island Recalls" }, + new() { Name = "Glenden Wood Recall", SpellId = 3865, Category = "Town Recalls" }, + new() { Name = "Bur Recall", SpellId = 4084, Category = "Town Recalls" }, + new() { Name = "Call of the Mhoire Forge", SpellId = 4128, Category = "Special Recalls" }, + new() { Name = "Paradox-touched Olthoi Infested Area Recall", SpellId = 4198, Category = "Special Recalls" }, + new() { Name = "Colosseum Recall", SpellId = 4213, Category = "Special Recalls" }, + new() { Name = "Return to the Keep", SpellId = 4214, Category = "Special Recalls" }, + new() { Name = "Gear Knight Invasion Area Camp Recall", SpellId = 5330, Category = "Special Recalls" }, + new() { Name = "Lost City of Neftet Recall", SpellId = 5541, Category = "Special Recalls" }, + new() { Name = "Rynthid Recall", SpellId = 6150, Category = "Special Recalls" }, + new() { Name = "Viridian Rise Recall", SpellId = 6321, Category = "Special Recalls" }, + new() { Name = "Viridian Rise Great Tree Recall", SpellId = 6322, Category = "Special Recalls" }, + ]; + + private static Dictionary> BuildCantrips() => new() + { + ["Specialized Skills"] = [], + ["Trained Skills"] = [], + ["Attributes"] = [], + ["Protection Auras"] = new() + { + // Listed up front so a missing ward shows as missing. + ["Armor"] = new() { Name = "Armor" }, + ["Bludgeoning Ward"] = new() { Name = "Bludgeoning Ward" }, + ["Piercing Ward"] = new() { Name = "Piercing Ward" }, + ["Slashing Ward"] = new() { Name = "Slashing Ward" }, + ["Flame Ward"] = new() { Name = "Flame Ward" }, + ["Frost Ward"] = new() { Name = "Frost Ward" }, + ["Acid Ward"] = new() { Name = "Acid Ward" }, + ["Storm Ward"] = new() { Name = "Storm Ward" }, + }, + }; + + private static Dictionary> BuildWeapons() => new() + { + ["Legendary Weapons"] = + [ + new() { Name = "Bow of the Quiddity", Category = "Legendary Weapons", WeaponType = "Bow", ItemId = 23044 }, + new() { Name = "Sword of the Quiddity", Category = "Legendary Weapons", WeaponType = "Sword", ItemId = 23039 }, + new() { Name = "Mace of the Quiddity", Category = "Legendary Weapons", WeaponType = "Mace", ItemId = 23040 }, + new() { Name = "Atlatl of the Quiddity", Category = "Legendary Weapons", WeaponType = "Atlatl", ItemId = 23043 }, + new() { Name = "Staff of the Quiddity", Category = "Legendary Weapons", WeaponType = "Staff", ItemId = 23041 }, + new() { Name = "Dagger of the Quiddity", Category = "Legendary Weapons", WeaponType = "Dagger", ItemId = 23042 }, + ], + ["Slayer Weapons"] = + [ + new() { Name = "Shadowfire Isparian Bow", Category = "Slayer Weapons", WeaponType = "Bow", ItemId = 20636 }, + new() { Name = "Shadowfire Isparian Sword", Category = "Slayer Weapons", WeaponType = "Sword", ItemId = 20631 }, + new() { Name = "Shadowfire Isparian Staff", Category = "Slayer Weapons", WeaponType = "Staff", ItemId = 20633 }, + new() { Name = "Shadowfire Isparian Dagger", Category = "Slayer Weapons", WeaponType = "Dagger", ItemId = 20634 }, + new() { Name = "Shadowfire Isparian Axe", Category = "Slayer Weapons", WeaponType = "Axe", ItemId = 20635 }, + new() { Name = "Shadowfire Isparian Mace", Category = "Slayer Weapons", WeaponType = "Mace", ItemId = 20632 }, + ], + ["Society Weapons"] = + [ + new() { Name = "Radiant Blood Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32834 }, + new() { Name = "Celestial Hand Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32835 }, + new() { Name = "Eldritch Web Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32836 }, + new() { Name = "Radiant Blood Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32837 }, + new() { Name = "Celestial Hand Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32838 }, + new() { Name = "Eldritch Web Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32839 }, + ], + ["Atlan Weapons"] = + [ + new() { Name = "Atlan Sword", Category = "Atlan Weapons", WeaponType = "Sword", ItemId = 11648 }, + new() { Name = "Atlan Axe", Category = "Atlan Weapons", WeaponType = "Axe", ItemId = 11649 }, + new() { Name = "Atlan Mace", Category = "Atlan Weapons", WeaponType = "Mace", ItemId = 11650 }, + new() { Name = "Atlan Spear", Category = "Atlan Weapons", WeaponType = "Spear", ItemId = 11651 }, + new() { Name = "Atlan Staff", Category = "Atlan Weapons", WeaponType = "Staff", ItemId = 11652 }, + new() { Name = "Atlan Dagger", Category = "Atlan Weapons", WeaponType = "Dagger", ItemId = 11653 }, + ], + }; +} diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs new file mode 100644 index 0000000..4087620 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs @@ -0,0 +1,159 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Quests; + +namespace OpenAC.MosswartMassacre.FlagTracker; + +/// +/// The "Mossy Tracker" window: augmentations, luminance auras, recalls, +/// cantrips, tracked weapons and quest timers for the character in the world. +/// Nothing here reaches the backend. +/// +/// +/// The window exists only on a host that draws one; on a headless host this +/// feature registers nothing and does nothing. The pages are read at login +/// and whenever the player presses a page's Refresh button, as the original +/// read them when its window opened and on Refresh; the quest page follows +/// the quest list and redraws its countdowns every five seconds. +/// +internal sealed class FlagTrackerFeature : IMmFeature +{ + /// The panel's markup file, beside the plugin assembly. + public const string MarkupFile = "mosswartmassacre-flagtracker.xml"; + + /// How often the quest countdowns are redrawn. + public static readonly TimeSpan QuestRedrawInterval = TimeSpan.FromSeconds(5); + + private readonly MmContext _context; + private readonly QuestManager _quests; + private readonly FlagTrackerData _data; + private readonly IDisposable? _panel; + private readonly Action? _onQuestsChanged; + private IDisposable? _questRedraw; + + public FlagTrackerFeature(MmContext context, QuestManager quests) + { + _context = context; + _quests = quests; + _data = new FlagTrackerData(context.Automation); + if (!context.Host.HasUi) + return; + + Panel = new FlagTrackerViewModel(); + Panel.RefreshRequested += OnRefreshRequested; + _onQuestsChanged = ShowQuests; + _quests.QuestsChanged += _onQuestsChanged; + + string directory = Path.GetDirectoryName(typeof(FlagTrackerFeature).Assembly.Location) ?? "."; + _panel = context.Host.Ui.RegisterPanel( + new PluginPanelDescriptor("flagtracker", "Mossy Tracker") + { + IconText = "MT", + // The icon the original window carried (7735). + IconSurfaceId = 0x06001E37, + // Shown while the binding's IsVisible is set; the host's own + // shown state starts true so that flag alone opens it. + StartVisible = true, + ShowInSidePanel = true, + }, + Path.Combine(directory, MarkupFile), + Panel); + } + + /// + /// The window's binding object; set its IsVisible to open the + /// window. Null on a host without a window. + /// + public FlagTrackerViewModel? Panel { get; } + + internal FlagTrackerData Data => _data; + + public void OnLogin() + { + if (Panel is null) + return; + RefreshAll(); + _questRedraw?.Dispose(); + _questRedraw = _context.Scheduler.Every(QuestRedrawInterval, ShowQuests); + } + + public void OnLogoff() + { + _questRedraw?.Dispose(); + _questRedraw = null; + Panel?.Clear(); + } + + public void Dispose() + { + OnLogoff(); + if (_onQuestsChanged is not null) + _quests.QuestsChanged -= _onQuestsChanged; + if (Panel is not null) + Panel.RefreshRequested -= OnRefreshRequested; + _panel?.Dispose(); + } + + /// Reads every page again and redraws it. + public void RefreshAll() + { + if (Panel is null) + return; + try + { + _data.RefreshAll(); + } + catch (Exception ex) + { + _context.Chat.Write($"[MossyTracker] Data refresh failed: {ex.Message}"); + } + Panel.ShowData(_data); + ShowQuests(); + } + + private void ShowQuests() + { + if (Panel is null || !_context.IsLoggedIn) + return; + Panel.ShowQuestRows(_quests.QuestList, _quests.UnixNow); + } + + private void OnRefreshRequested(string button) + { + if (Panel is null || !_context.IsLoggedIn) + return; + try + { + switch (button) + { + case nameof(FlagTrackerViewModel.RefreshAugs): + _data.RefreshAugmentations(); + Panel.ShowAugmentations(_data); + break; + case nameof(FlagTrackerViewModel.RefreshLum): + _data.RefreshLuminanceAuras(); + Panel.ShowLuminance(_data); + break; + case nameof(FlagTrackerViewModel.RefreshRecalls): + _data.RefreshRecallSpells(); + Panel.ShowRecallSpells(_data); + break; + case nameof(FlagTrackerViewModel.RefreshCantrips): + _data.RefreshCantrips(); + Panel.ShowCantripRows(_data); + break; + case nameof(FlagTrackerViewModel.RefreshWeapons): + _data.RefreshWeapons(); + Panel.ShowWeaponRows(_data); + break; + case nameof(FlagTrackerViewModel.RefreshQuests): + _quests.RefreshQuests(); + ShowQuests(); + break; + } + } + catch (Exception ex) + { + _context.Chat.Write($"[MossyTracker] Refresh failed: {ex.Message}"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs new file mode 100644 index 0000000..6991390 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs @@ -0,0 +1,319 @@ +using System.Globalization; +using OpenAC.MosswartMassacre.Quests; + +namespace OpenAC.MosswartMassacre.FlagTracker; + +/// +/// The binding object of the "Mossy Tracker" window +/// (mosswartmassacre-flagtracker.xml). One tab per page of the +/// original window, and each page's list as parallel column lists that are +/// replaced whole on every refresh, so the host re-reading them every frame +/// never sees a half-built page. Everything is assigned on the tick thread. +/// +internal sealed class FlagTrackerViewModel +{ + private enum Page + { + Augs, + Lum, + Recalls, + Cantrips, + Weapons, + Quests, + } + + private Page _page = Page.Augs; + + public FlagTrackerViewModel() + { + IgnoreRow = _ => { }; + ShowAugs = () => _page = Page.Augs; + ShowLum = () => _page = Page.Lum; + ShowRecalls = () => _page = Page.Recalls; + ShowCantrips = () => _page = Page.Cantrips; + ShowWeapons = () => _page = Page.Weapons; + ShowQuests = () => _page = Page.Quests; + RefreshAugs = () => RefreshRequested?.Invoke(nameof(RefreshAugs)); + RefreshLum = () => RefreshRequested?.Invoke(nameof(RefreshLum)); + RefreshRecalls = () => RefreshRequested?.Invoke(nameof(RefreshRecalls)); + RefreshCantrips = () => RefreshRequested?.Invoke(nameof(RefreshCantrips)); + RefreshWeapons = () => RefreshRequested?.Invoke(nameof(RefreshWeapons)); + RefreshQuests = () => RefreshRequested?.Invoke(nameof(RefreshQuests)); + } + + /// A refresh button was pressed; the argument is the button's binding name. + internal event Action? RefreshRequested; + + /// + /// Whether the window is open. The root panel's visibility is bound to it, + /// so the main window opens this one by setting it; it starts closed. + /// + public bool IsVisible { get; set; } + + // ---- tabs -------------------------------------------------------------- + + public bool AugsSelected => _page == Page.Augs; + public bool LumSelected => _page == Page.Lum; + public bool RecallsSelected => _page == Page.Recalls; + public bool CantripsSelected => _page == Page.Cantrips; + public bool WeaponsSelected => _page == Page.Weapons; + public bool QuestsSelected => _page == Page.Quests; + + public Action ShowAugs { get; } + public Action ShowLum { get; } + public Action ShowRecalls { get; } + public Action ShowCantrips { get; } + public Action ShowWeapons { get; } + public Action ShowQuests { get; } + + public Action RefreshAugs { get; } + public Action RefreshLum { get; } + public Action RefreshRecalls { get; } + public Action RefreshCantrips { get; } + public Action RefreshWeapons { get; } + public Action RefreshQuests { get; } + + /// Lists here are read-only views; a click selects nothing. + public int NoRow => -1; + + public Action IgnoreRow { get; } + + // ---- Augs -------------------------------------------------------------- + + public IReadOnlyList AugNames { get; private set; } = []; + public IReadOnlyList AugProgress { get; private set; } = []; + public IReadOnlyList AugProgressColors { get; private set; } = []; + public IReadOnlyList AugTrainers { get; private set; } = []; + public IReadOnlyList AugLocations { get; private set; } = []; + + // ---- Lum --------------------------------------------------------------- + + public IReadOnlyList LumNames { get; private set; } = []; + public IReadOnlyList LumProgress { get; private set; } = []; + public IReadOnlyList LumProgressColors { get; private set; } = []; + public IReadOnlyList LumCategories { get; private set; } = []; + + // ---- Recalls ----------------------------------------------------------- + + public IReadOnlyList RecallSpellIds { get; private set; } = []; + public IReadOnlyList RecallNames { get; private set; } = []; + public IReadOnlyList RecallStatus { get; private set; } = []; + public IReadOnlyList RecallStatusColors { get; private set; } = []; + + // ---- Cantrips ---------------------------------------------------------- + + public IReadOnlyList CantripIcons { get; private set; } = []; + public IReadOnlyList CantripNames { get; private set; } = []; + public IReadOnlyList CantripLevels { get; private set; } = []; + public IReadOnlyList CantripLevelColors { get; private set; } = []; + + // ---- Weapons ----------------------------------------------------------- + + public IReadOnlyList WeaponIcons { get; private set; } = []; + public IReadOnlyList WeaponNames { get; private set; } = []; + public IReadOnlyList WeaponStatus { get; private set; } = []; + public IReadOnlyList WeaponStatusColors { get; private set; } = []; + + // ---- Quests ------------------------------------------------------------ + + public IReadOnlyList QuestRowNames { get; private set; } = []; + public IReadOnlyList QuestCountdowns { get; private set; } = []; + public IReadOnlyList QuestCountdownColors { get; private set; } = []; + public IReadOnlyList QuestLastSolved { get; private set; } = []; + public IReadOnlyList QuestCooldowns { get; private set; } = []; + public IReadOnlyList QuestSolves { get; private set; } = []; + public IReadOnlyList QuestSolvesColors { get; private set; } = []; + + /// Rebuilds every page but the quests from the tracker's data. + public void ShowData(FlagTrackerData data) + { + ShowAugmentations(data); + ShowLuminance(data); + ShowRecallSpells(data); + ShowCantripRows(data); + ShowWeaponRows(data); + } + + public void ShowAugmentations(FlagTrackerData data) + { + var names = new List(); + var progress = new List(); + var colors = new List(); + var trainers = new List(); + var locations = new List(); + foreach ((string category, List augmentations) in data.AugmentationCategories) + { + names.Add($"--- {category} ---"); + progress.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + trainers.Add(string.Empty); + locations.Add(string.Empty); + foreach (FlagTrackerData.AugmentationInfo aug in augmentations) + { + names.Add(aug.Name); + progress.Add(aug.IsMaxed + ? "[MAX]" + : string.Create(CultureInfo.InvariantCulture, $"[{aug.CurrentValue}/{aug.Repeatable}]")); + colors.Add(aug.IsMaxed + ? FlagTrackerData.Colors.Green + : aug.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red); + trainers.Add(aug.Trainer); + locations.Add(aug.Location); + } + } + AugNames = names; + AugProgress = progress; + AugProgressColors = colors; + AugTrainers = trainers; + AugLocations = locations; + } + + public void ShowLuminance(FlagTrackerData data) + { + var names = new List(); + var progress = new List(); + var colors = new List(); + var categories = new List(); + foreach ((string category, List auras) in data.LuminanceAuraCategories) + { + names.Add($"--- {category} ---"); + progress.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + categories.Add(string.Empty); + foreach (FlagTrackerData.LuminanceAuraInfo aura in auras) + { + names.Add(aura.Name); + progress.Add(string.Create(CultureInfo.InvariantCulture, $"{aura.CurrentValue}/{aura.Cap}")); + colors.Add(aura.CurrentValue >= aura.Cap + ? FlagTrackerData.Colors.Green + : aura.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red); + categories.Add(category == "Seer Auras" && !string.IsNullOrEmpty(aura.QuestFlag) + ? aura.QuestFlag + : category); + } + } + LumNames = names; + LumProgress = progress; + LumProgressColors = colors; + LumCategories = categories; + } + + public void ShowRecallSpells(FlagTrackerData data) + { + RecallSpellIds = [.. data.RecallSpells.Select(recall => recall.SpellId)]; + RecallNames = [.. data.RecallSpells.Select(recall => recall.Name)]; + RecallStatus = [.. data.RecallSpells.Select(recall => recall.IsKnown ? "Known" : "Unknown")]; + RecallStatusColors = [.. data.RecallSpells.Select(recall => + recall.IsKnown ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red)]; + } + + public void ShowCantripRows(FlagTrackerData data) + { + var icons = new List(); + var names = new List(); + var levels = new List(); + var colors = new List(); + foreach ((string category, Dictionary cantrips) in data.Cantrips) + { + icons.Add(FlagTrackerData.Icons.CategoryStar); + names.Add($"--- {category} ---"); + levels.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + foreach ((string name, FlagTrackerData.CantripInfo cantrip) in cantrips) + { + icons.Add(cantrip.ComputedIconId); + names.Add(name); + levels.Add(cantrip.Value); + colors.Add(cantrip.Color); + } + } + CantripIcons = icons; + CantripNames = names; + CantripLevels = levels; + CantripLevelColors = colors; + } + + public void ShowWeaponRows(FlagTrackerData data) + { + var icons = new List(); + var names = new List(); + var status = new List(); + var colors = new List(); + foreach ((string category, List weapons) in data.WeaponCategories) + { + icons.Add(FlagTrackerData.Icons.CategoryStar); + names.Add($"--- {category} ---"); + status.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + foreach (FlagTrackerData.TrackedWeaponInfo weapon in weapons) + { + icons.Add(weapon.IconId); + names.Add(weapon.Name); + status.Add(weapon.Status); + colors.Add(weapon.IsAcquired ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red); + } + } + WeaponIcons = icons; + WeaponNames = names; + WeaponStatus = status; + WeaponStatusColors = colors; + } + + /// + /// The quest page: every quest not yet solved its maximum number of times, + /// most solves first, then by name; the countdown green when ready and + /// yellow while cooling down. + /// + public void ShowQuestRows(IReadOnlyList quests, long unixNow) + { + var names = new List(); + var countdowns = new List(); + var countdownColors = new List(); + var lastSolved = new List(); + var cooldowns = new List(); + var solves = new List(); + var solvesColors = new List(); + + IEnumerable visible = quests + .Where(quest => !(quest.MaxSolves > 0 && quest.Solves >= quest.MaxSolves)) + .OrderByDescending(quest => quest.Solves) + .ThenBy(quest => QuestNames.GetFriendlyName(quest.Id), StringComparer.InvariantCulture); + + foreach (Quest quest in visible) + { + names.Add(QuestNames.GetFriendlyName(quest.Id)); + countdowns.Add(QuestStreamingService.FormatCountdown(quest.ExpireTime - unixNow)); + countdownColors.Add(quest.ExpireTime <= unixNow + ? FlagTrackerData.Colors.Green + : FlagTrackerData.Colors.Yellow); + lastSolved.Add(QuestManager.FormatTimeStamp(quest.Timestamp)); + cooldowns.Add(QuestManager.FormatSeconds(quest.Delta)); + solves.Add(quest.Solves.ToString(CultureInfo.InvariantCulture)); + solvesColors.Add(FlagTrackerData.Colors.White); + } + + QuestRowNames = names; + QuestCountdowns = countdowns; + QuestCountdownColors = countdownColors; + QuestLastSolved = lastSolved; + QuestCooldowns = cooldowns; + QuestSolves = solves; + QuestSolvesColors = solvesColors; + } + + /// Empties every page, at logoff. + public void Clear() + { + AugNames = AugProgress = AugTrainers = AugLocations = []; + AugProgressColors = []; + LumNames = LumProgress = LumCategories = []; + LumProgressColors = []; + RecallSpellIds = RecallStatusColors = []; + RecallNames = RecallStatus = []; + CantripIcons = CantripLevelColors = []; + CantripNames = CantripLevels = []; + WeaponIcons = WeaponStatusColors = []; + WeaponNames = WeaponStatus = []; + ShowQuestRows([], 0); + } +} diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml new file mode 100644 index 0000000..1e731de --- /dev/null +++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + +