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}"); } } }