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:
parent
5dbb294a96
commit
0b5da797e0
6 changed files with 857 additions and 2 deletions
|
|
@ -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<IMmFeature> features)
|
||||
{
|
||||
_ = context;
|
||||
_ = session;
|
||||
_ = features;
|
||||
|
||||
// Quests, flag tracker, meta sync.
|
||||
var quests = new QuestManager(context);
|
||||
features.Add(quests);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
325
src/OpenAC.MosswartMassacre/Quests/QuestManager.cs
Normal file
325
src/OpenAC.MosswartMassacre/Quests/QuestManager.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src/OpenAC.MosswartMassacre/Quests/QuestNames.cs
Normal file
211
src/OpenAC.MosswartMassacre/Quests/QuestNames.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
namespace OpenAC.MosswartMassacre.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// Friendly names for quest stamps, the table the original plugin shipped.
|
||||
/// The friendly name is what the backend's <c>quest</c> frame carries as
|
||||
/// <c>quest_name</c>, so the table is copied as it was, including the stamp
|
||||
/// listed twice (<c>insatiableeaterjaw</c>): the later entry wins, exactly as
|
||||
/// the original's indexer initializer made it win.
|
||||
/// </summary>
|
||||
internal static class QuestNames
|
||||
{
|
||||
public static readonly Dictionary<string, string> 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"
|
||||
};
|
||||
|
||||
/// <summary>The friendly name for a stamp, or the stamp itself when the table has none.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The friendly name with the stamp in parentheses, or the stamp alone.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
70
src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs
Normal file
70
src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System.Globalization;
|
||||
using Newtonsoft.Json;
|
||||
using OpenAC.MosswartMassacre.Backend;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>quest</c> frame: <c>type</c>, <c>timestamp</c>, <c>character_name</c>,
|
||||
/// <c>quest_name</c>, <c>countdown</c>, 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Which quests stream, how their countdown is written, and the frame they go in.</summary>
|
||||
internal static class QuestStreamingService
|
||||
{
|
||||
/// <summary>The three stamps the original streamed, and only those.</summary>
|
||||
public static bool IsHighPriorityQuest(string questId) =>
|
||||
questId == "stipendtimer_0812"
|
||||
|| questId == "augmentationblankgemacquired"
|
||||
|| questId == "insatiableeaterjaw";
|
||||
|
||||
/// <summary>
|
||||
/// The countdown text: <c>READY</c> at or past zero, then the two largest
|
||||
/// units, the smaller one zero-padded: <c>1d 02h</c>, <c>3h 05m</c>,
|
||||
/// <c>4m 09s</c>, <c>59s</c>. The backend shows this string as it is.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<bool>();
|
||||
harness.Session.RadarToggle = on => toggles.Add(on);
|
||||
|
||||
|
|
@ -68,6 +76,8 @@ public sealed class BackendSessionTests
|
|||
var seen = new List<string>();
|
||||
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));
|
||||
|
||||
|
|
|
|||
232
tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
Normal file
232
tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
Normal file
|
|
@ -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<QuestManager>();
|
||||
|
||||
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<QuestManager>().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<QuestManager>().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<QuestManager>().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<QuestManager>().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<QuestManager>().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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue