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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:14:25 +02:00
parent 5dbb294a96
commit 0b5da797e0
6 changed files with 857 additions and 2 deletions

View file

@ -0,0 +1,325 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Quests;
/// <summary>One line of the server's <c>/myquests</c> answer.</summary>
internal sealed class Quest
{
public string Id { get; init; } = string.Empty;
public int Solves { get; init; }
/// <summary>When the quest was last solved, in Unix seconds.</summary>
public int Timestamp { get; init; }
public string Description { get; init; } = string.Empty;
public int MaxSolves { get; init; }
/// <summary>The quest's cooldown in seconds.</summary>
public int Delta { get; init; }
/// <summary>When the quest can be done again, in Unix seconds.</summary>
public int ExpireTime { get; init; }
}
/// <summary>
/// The character's quest timers, read the way the original read them: ask the
/// server with <c>/myquests</c> 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 <c>quest</c> frames.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class QuestManager : IMmFeature
{
/// <summary>How long after asking the chat lines are read as answers.</summary>
public static readonly TimeSpan RefreshWindow = TimeSpan.FromSeconds(3);
/// <summary>How often the priority quests are streamed.</summary>
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<Quest> _quests = [];
private readonly Dictionary<string, Quest> _questsById = new(StringComparer.Ordinal);
private readonly Action<PluginChatMessage> _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");
}
/// <summary>Raised on the tick thread whenever the quest list changes.</summary>
public event Action? QuestsChanged;
/// <summary>Every parsed answer line, in the order the server sent them.</summary>
public IReadOnlyList<Quest> QuestList => _quests;
/// <summary>True while the answer to a <c>/myquests</c> is being read.</summary>
public bool IsRefreshing { get; private set; }
/// <summary>True while the thirty-second stream is scheduled.</summary>
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();
/// <summary>
/// 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.
/// </summary>
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!);
/// <summary>The priority quests, first answer line per stamp, in answer order.</summary>
public IReadOnlyList<Quest> PriorityQuests() =>
[.. _quests
.Where(quest => QuestStreamingService.IsHighPriorityQuest(quest.Id))
.GroupBy(quest => quest.Id)
.Select(group => group.First())];
/// <summary>Parses one chat line; a line that is not an answer line is ignored.</summary>
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}");
}
}
/// <summary>The now the countdowns are measured from, in Unix seconds.</summary>
internal long UnixNow => new DateTimeOffset(DateTime.SpecifyKind(_context.Clock.UtcNow, DateTimeKind.Utc))
.ToUnixTimeSeconds();
/// <summary>One run of the thirty-second stream. Internal so tests can run it directly.</summary>
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<Quest> 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}");
}
}
/// <summary>Last solved, as the flag tracker shows it: UTC, month first.</summary>
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";
}
}
/// <summary>A duration as "1d 2h 3m 4s", leaving out the zero parts.</summary>
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<Quest> 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}");
}
}
}