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