Merge slice/flagtracker-metas: quests, Mossy Tracker, meta sync

Resolved FeatureCatalog (both blocks kept; the main window's Tools buttons now
open the Mossy Tracker and Metas panels). Tests from both sides adjusted to
the combined plugin: quest tests look at quest frames only (chat is streamed
too), and the stream tests ignore the /myquests request sent at login.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:29:10 +02:00
commit 2e5d768ca9
19 changed files with 3984 additions and 1 deletions

View file

@ -1,5 +1,8 @@
using OpenAC.MosswartMassacre.Backend;
using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Infrastructure;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Quests;
using OpenAC.MosswartMassacre.Streams;
using OpenAC.MosswartMassacre.Trackers;
@ -27,7 +30,8 @@ internal static class FeatureCatalog
features.Add(new VitalsStream(context));
features.Add(new SpawnPortalStream(context));
features.Add(new BotControl(context));
features.Add(new Views.MainWindow(context, stats));
var mainWindow = new Views.MainWindow(context, stats);
features.Add(mainWindow);
session.AddConnectionListener(telemetry);
// Cross-computer vital and debuff sharing.
@ -47,5 +51,17 @@ internal static class FeatureCatalog
else
radar.Stop();
};
// Quests, flag tracker, meta sync.
var quests = new QuestManager(context);
features.Add(quests);
var flagTracker = new FlagTrackerFeature(context, quests);
features.Add(flagTracker);
var metas = new MetaSyncFeature(context);
features.Add(metas);
if (flagTracker.Panel is { } trackerPanel)
mainWindow.FlagTrackerOpener = () => trackerPanel.IsVisible = true;
if (metas.Panel is { } metasPanel)
mainWindow.MetasOpener = () => metasPanel.IsVisible = true;
}
}

View file

@ -0,0 +1,672 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.FlagTracker;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class FlagTrackerData
{
/// <summary>Row text colours, <c>0xRRGGBB</c>, the named colours the original used.</summary>
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;
}
/// <summary>Icons the original drew: the cantrip lamps, the category star and the portal fallback.</summary>
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; }
/// <summary>The seer's loyalty flag, for the seer auras only.</summary>
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; }
/// <summary>"Minor" through "Legendary", or "N/A" when the character has none.</summary>
public string Value { get; set; } = NotAvailable;
public uint Color { get; set; } = Colors.White;
/// <summary>The green lamp when the cantrip is up, the red one when it is not.</summary>
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<uint, string> 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<string, int> 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<string, List<AugmentationInfo>> AugmentationCategories { get; }
public Dictionary<string, List<LuminanceAuraInfo>> LuminanceAuraCategories { get; }
public List<RecallSpellInfo> RecallSpells { get; }
public Dictionary<string, Dictionary<string, CantripInfo>> Cantrips { get; }
public Dictionary<string, List<TrackedWeaponInfo>> 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<AugmentationInfo> 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<LuminanceAuraInfo> 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<string, CantripInfo> 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<string, CantripInfo> 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<PluginInventoryItem> inventory = _automation.Items.CaptureOwnedItems();
foreach (List<TrackedWeaponInfo> 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;
}
}
}
}
/// <summary>
/// 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.
/// </summary>
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<string, CantripInfo> 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<string, List<AugmentationInfo>> 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<string, List<LuminanceAuraInfo>> 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<RecallSpellInfo> 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<string, Dictionary<string, CantripInfo>> 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<string, List<TrackedWeaponInfo>> 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 },
],
};
}

View file

@ -0,0 +1,159 @@
using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre.FlagTracker;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class FlagTrackerFeature : IMmFeature
{
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
public const string MarkupFile = "mosswartmassacre-flagtracker.xml";
/// <summary>How often the quest countdowns are redrawn.</summary>
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);
}
/// <summary>
/// The window's binding object; set its <c>IsVisible</c> to open the
/// window. Null on a host without a window.
/// </summary>
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();
}
/// <summary>Reads every page again and redraws it.</summary>
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}");
}
}
}

View file

@ -0,0 +1,319 @@
using System.Globalization;
using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre.FlagTracker;
/// <summary>
/// The binding object of the "Mossy Tracker" window
/// (<c>mosswartmassacre-flagtracker.xml</c>). 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.
/// </summary>
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));
}
/// <summary>A refresh button was pressed; the argument is the button's binding name.</summary>
internal event Action<string>? RefreshRequested;
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Lists here are read-only views; a click selects nothing.</summary>
public int NoRow => -1;
public Action<int> IgnoreRow { get; }
// ---- Augs --------------------------------------------------------------
public IReadOnlyList<string> AugNames { get; private set; } = [];
public IReadOnlyList<string> AugProgress { get; private set; } = [];
public IReadOnlyList<uint> AugProgressColors { get; private set; } = [];
public IReadOnlyList<string> AugTrainers { get; private set; } = [];
public IReadOnlyList<string> AugLocations { get; private set; } = [];
// ---- Lum ---------------------------------------------------------------
public IReadOnlyList<string> LumNames { get; private set; } = [];
public IReadOnlyList<string> LumProgress { get; private set; } = [];
public IReadOnlyList<uint> LumProgressColors { get; private set; } = [];
public IReadOnlyList<string> LumCategories { get; private set; } = [];
// ---- Recalls -----------------------------------------------------------
public IReadOnlyList<uint> RecallSpellIds { get; private set; } = [];
public IReadOnlyList<string> RecallNames { get; private set; } = [];
public IReadOnlyList<string> RecallStatus { get; private set; } = [];
public IReadOnlyList<uint> RecallStatusColors { get; private set; } = [];
// ---- Cantrips ----------------------------------------------------------
public IReadOnlyList<uint> CantripIcons { get; private set; } = [];
public IReadOnlyList<string> CantripNames { get; private set; } = [];
public IReadOnlyList<string> CantripLevels { get; private set; } = [];
public IReadOnlyList<uint> CantripLevelColors { get; private set; } = [];
// ---- Weapons -----------------------------------------------------------
public IReadOnlyList<uint> WeaponIcons { get; private set; } = [];
public IReadOnlyList<string> WeaponNames { get; private set; } = [];
public IReadOnlyList<string> WeaponStatus { get; private set; } = [];
public IReadOnlyList<uint> WeaponStatusColors { get; private set; } = [];
// ---- Quests ------------------------------------------------------------
public IReadOnlyList<string> QuestRowNames { get; private set; } = [];
public IReadOnlyList<string> QuestCountdowns { get; private set; } = [];
public IReadOnlyList<uint> QuestCountdownColors { get; private set; } = [];
public IReadOnlyList<string> QuestLastSolved { get; private set; } = [];
public IReadOnlyList<string> QuestCooldowns { get; private set; } = [];
public IReadOnlyList<string> QuestSolves { get; private set; } = [];
public IReadOnlyList<uint> QuestSolvesColors { get; private set; } = [];
/// <summary>Rebuilds every page but the quests from the tracker's data.</summary>
public void ShowData(FlagTrackerData data)
{
ShowAugmentations(data);
ShowLuminance(data);
ShowRecallSpells(data);
ShowCantripRows(data);
ShowWeaponRows(data);
}
public void ShowAugmentations(FlagTrackerData data)
{
var names = new List<string>();
var progress = new List<string>();
var colors = new List<uint>();
var trainers = new List<string>();
var locations = new List<string>();
foreach ((string category, List<FlagTrackerData.AugmentationInfo> 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<string>();
var progress = new List<string>();
var colors = new List<uint>();
var categories = new List<string>();
foreach ((string category, List<FlagTrackerData.LuminanceAuraInfo> 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<uint>();
var names = new List<string>();
var levels = new List<string>();
var colors = new List<uint>();
foreach ((string category, Dictionary<string, FlagTrackerData.CantripInfo> 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<uint>();
var names = new List<string>();
var status = new List<string>();
var colors = new List<uint>();
foreach ((string category, List<FlagTrackerData.TrackedWeaponInfo> 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;
}
/// <summary>
/// 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.
/// </summary>
public void ShowQuestRows(IReadOnlyList<Quest> quests, long unixNow)
{
var names = new List<string>();
var countdowns = new List<string>();
var countdownColors = new List<uint>();
var lastSolved = new List<string>();
var cooldowns = new List<string>();
var solves = new List<string>();
var solvesColors = new List<uint>();
IEnumerable<Quest> 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;
}
/// <summary>Empties every page, at logoff.</summary>
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);
}
}

View file

@ -0,0 +1,337 @@
using System.Collections.Concurrent;
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// Meta and nav sync from the club's repository: the <c>/mm metasrefresh</c>,
/// <c>metascheck</c>, <c>metaspull</c> and <c>metaspullfile</c> verbs and, on a
/// host with a window, the Metas window. Nothing here reaches the backend.
/// </summary>
/// <remarks>
/// Network work runs on the thread pool; every result, chat line and window
/// update is queued and applied on the next tick. The file list is not tied
/// to a character, so it survives a relog.
/// </remarks>
internal sealed class MetaSyncFeature : IMmFeature
{
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
public const string MarkupFile = "mosswartmassacre-metas.xml";
private readonly MmContext _context;
private readonly MetaSyncManager _manager;
private readonly ConcurrentQueue<Action> _onTick = new();
private readonly CancellationTokenSource _shutdown = new();
private readonly Action<double> _tick;
private readonly IDisposable? _panel;
private Task _lastOperation = Task.CompletedTask;
private bool _disposed;
public MetaSyncFeature(MmContext context)
: this(context, context.Host.VtankProfiles, handler: null)
{
}
/// <summary>Builds the feature on a given profile storage and HTTP handler; tests pass fakes.</summary>
internal MetaSyncFeature(MmContext context, IPluginStorage profiles, HttpMessageHandler? handler)
{
_context = context;
_manager = new MetaSyncManager(
profiles,
handler,
() => RepositoryUrl,
() => _context.Clock.UtcNow,
message => _onTick.Enqueue(() => _context.Chat.Write(message)));
_tick = _ => Pump();
context.Host.Events.Tick += _tick;
context.Commands.Register("metasrefresh", _ => RunRefresh(), "Refresh remote metas list");
context.Commands.Register("metascheck", _ => RunCheck(), "Check local metas against remote");
context.Commands.Register("metaspull", _ => RunPullOutdated(), "Download outdated metas/nav files");
context.Commands.Register("metaspullfile", HandlePullFile, "Download one metas/nav file by relative path");
if (!context.Host.HasUi)
return;
Panel = new MetasViewModel();
Panel.ShowPath(_manager.StorageRoot);
Panel.ShowFiles([]);
Panel.ButtonPressed += OnButton;
Panel.SearchRequested += ShowFiles;
string directory = Path.GetDirectoryName(typeof(MetaSyncFeature).Assembly.Location) ?? ".";
_panel = context.Host.Ui.RegisterPanel(
new PluginPanelDescriptor("metas", "Metas")
{
IconText = "ME",
// 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);
}
/// <summary>
/// The window's binding object; set its <c>IsVisible</c> to open the
/// window. Null on a host without a window.
/// </summary>
public MetasViewModel? Panel { get; }
internal MetaSyncManager Manager => _manager;
/// <summary>The operation started last; tests await it before pumping the tick.</summary>
internal Task LastOperation => _lastOperation;
/// <summary>The repository setting, or the original's repository when it is empty.</summary>
private string RepositoryUrl
{
get
{
string? url = _context.Settings.Current.MetasRepositoryUrl;
return string.IsNullOrWhiteSpace(url) ? MetaSyncManager.DefaultRepositoryUrl : url;
}
}
public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot);
public void OnLogoff()
{
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_context.Host.Events.Tick -= _tick;
_shutdown.Cancel();
if (Panel is not null)
{
Panel.ButtonPressed -= OnButton;
Panel.SearchRequested -= ShowFiles;
}
_panel?.Dispose();
_onTick.Clear();
// Operations run one after another, so the manager goes once the
// last one, cancelled above, has finished.
_lastOperation.ContinueWith(
_ => { _manager.Dispose(); _shutdown.Dispose(); },
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
}
/// <summary>Applies finished work on the tick thread. Internal so tests can pump without a host tick.</summary>
internal void Pump()
{
while (_onTick.TryDequeue(out Action? action))
{
try
{
action();
}
catch (Exception ex)
{
_context.Host.Log.Error("A metas update failed.", ex);
}
}
}
private void RunRefresh(Action<int>? done = null, Action<Exception>? failed = null) =>
Start(
token => _manager.RefreshCatalogAsync(token),
count =>
{
ShowFiles();
if (done is null)
_context.Chat.Write($"[Metas] Loaded {count} remote .met/.nav files");
else
done(count);
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Refresh failed: {ex.Message}");
else
failed(ex);
});
private void RunCheck(
Action<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)>? done = null,
Action<Exception>? failed = null) =>
Start(
token => _manager.CheckStatusesAsync(token),
summary =>
{
ShowFiles();
if (done is null)
{
_context.Chat.Write(
$"[Metas] Up-to-date: {summary.UpToDate}, Updates: {summary.NeedsUpdate}, Missing: {summary.NotDownloaded}, Errors: {summary.Errors}");
}
else
{
done(summary);
}
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Check failed: {ex.Message}");
else
failed(ex);
});
private void RunPullOutdated(
Action<(int Downloaded, int Skipped, int Failed, int Backups)>? done = null,
Action<Exception>? failed = null) =>
Start(
token => _manager.DownloadOutdatedAsync(token),
result =>
{
ShowFiles();
if (done is null)
{
_context.Chat.Write(
$"[Metas] Downloaded: {result.Downloaded}, Skipped: {result.Skipped}, Failed: {result.Failed}, Backups: {result.Backups}");
}
else
{
done(result);
}
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Pull failed: {ex.Message}");
else
failed(ex);
});
private void HandlePullFile(string[] args)
{
if (args.Length < 2)
{
_context.Chat.Write("Usage: /mm metaspullfile <relative/path/file.met>");
return;
}
string relativePath = string.Join(" ", args.Skip(1));
Start(
token => _manager.DownloadFileAsync(relativePath, token),
result =>
{
ShowFiles();
_context.Chat.Write($"[Metas] {relativePath}: {result.Message}");
},
ex => _context.Chat.Write($"[Metas] Pull file failed: {ex.Message}"));
}
private void OnButton(string button)
{
MetasViewModel? panel = Panel;
if (panel is null)
return;
switch (button)
{
case nameof(MetasViewModel.RefreshList):
panel.ShowPath(_manager.StorageRoot);
panel.StatusText = "Status: refreshing remote file list...";
RunRefresh(
count => panel.StatusText = $"Status: loaded {count} remote files",
ex =>
{
panel.StatusText = "Status: refresh failed";
_context.Chat.Write($"[Metas] Refresh failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.CheckUpdates):
panel.ShowPath(_manager.StorageRoot);
panel.StatusText = "Status: checking remote updates...";
RunCheck(
summary => panel.StatusText =
$"Status: {summary.UpToDate} current, {summary.NeedsUpdate} updates, {summary.NotDownloaded} missing, {summary.Errors} errors",
ex =>
{
panel.StatusText = "Status: update check failed";
_context.Chat.Write($"[Metas] Update check failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.DownloadOutdated):
panel.StatusText = "Status: downloading outdated files...";
RunPullOutdated(
result => panel.StatusText =
$"Status: downloaded {result.Downloaded}, skipped {result.Skipped}, failed {result.Failed}, backups {result.Backups}",
ex =>
{
panel.StatusText = "Status: pull outdated failed";
_context.Chat.Write($"[Metas] Pull outdated failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.DownloadSelected):
string selection = panel.SelectedRelativePath;
if (string.IsNullOrWhiteSpace(selection))
{
_context.Chat.Write("[Metas] Select a file first");
return;
}
panel.StatusText = $"Status: downloading {selection}...";
Start(
token => _manager.DownloadFileAsync(selection, token),
result =>
{
ShowFiles();
panel.StatusText = $"Status: {result.Message}";
},
ex =>
{
panel.StatusText = "Status: download failed";
_context.Chat.Write($"[Metas] Download failed: {ex.Message}");
});
break;
}
}
private void ShowFiles()
{
if (Panel is null)
return;
Panel.ShowFiles(_manager.GetCachedFiles());
Panel.ShowLastCheck(_manager.LastStatusCheckUtc, _manager.LastCatalogRefreshUtc);
}
/// <summary>Runs <paramref name="work"/> on the thread pool and applies its outcome on the next tick.</summary>
private void Start<T>(Func<CancellationToken, Task<T>> work, Action<T> done, Action<Exception> failed)
{
if (_disposed)
return;
CancellationToken token = _shutdown.Token;
Task previous = _lastOperation;
// Each operation starts when the one before it has finished, the
// order the player asked for them in.
_lastOperation = Task.Run(async () =>
{
await previous.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
try
{
T result = await work(token).ConfigureAwait(false);
_onTick.Enqueue(() => done(result));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// Shutting down: nobody is left to tell.
}
catch (Exception ex)
{
_onTick.Enqueue(() => failed(ex));
}
}, CancellationToken.None);
}
}

View file

@ -0,0 +1,470 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using AcDream.Plugin.Abstractions;
using Newtonsoft.Json.Linq;
namespace OpenAC.MosswartMassacre.Metas;
internal enum MetaFileSyncStatus
{
Unknown,
NotDownloaded,
UpToDate,
UpdateAvailable,
Error,
}
/// <summary>One <c>.met</c> or <c>.nav</c> file of the repository and how the local copy compares.</summary>
internal sealed class MetaFileEntry
{
public string RelativePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Extension { get; set; } = string.Empty;
/// <summary>The storage key the file is kept under, or empty when there is no storage.</summary>
public string LocalPath { get; set; } = string.Empty;
public bool LocalExists { get; set; }
public string LocalSha256 { get; set; } = string.Empty;
public string RemoteSha256 { get; set; } = string.Empty;
public MetaFileSyncStatus Status { get; set; }
public string StatusText { get; set; } = string.Empty;
public MetaFileEntry Clone() => (MetaFileEntry)MemberwiseClone();
}
internal sealed class MetaDownloadResult
{
public string RelativePath { get; set; } = string.Empty;
public bool Downloaded { get; set; }
public bool SkippedAsCurrent { get; set; }
public bool BackupCreated { get; set; }
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// Keeps the shared automation profiles in step with the metas repository on
/// the club's git server: lists its <c>.met</c> and <c>.nav</c> files through
/// the server's API, compares each with the local copy by SHA-256, and
/// downloads the ones that differ, keeping the old copy as <c>.bak</c>.
/// </summary>
/// <remarks>
/// <para>The endpoints are the original's: the tree comes from
/// <c>{server}/api/v1/repos/{owner}/{repo}/git/trees/main?recursive=1</c> and each
/// file from <c>{repository}/raw/branch/main/{path}</c>. The repository address is a
/// setting that defaults to the original's repository.</para>
/// <para>Files land where the automation plugin reads dropped profiles, flattened
/// to their bare names as the original flattened them into its profile
/// folder: metas under <c>mosstank/metas</c>, navs under <c>mosstank/navs</c>.
/// The shared storage holds text, so both sides are compared as the text a
/// read of the stored file gives back (UTF-8, byte-order mark dropped),
/// hashed as UTF-8; for the plain-text files in the repository that is the
/// same as the original's byte comparison.</para>
/// <para>The methods run off the tick thread: they touch only the network, the
/// shared storage and this object's own list, which is locked. One operation
/// runs at a time; a second waits for the first. Notices for the chat
/// window go through the callback, which the owner marshals to the tick.</para>
/// </remarks>
internal sealed class MetaSyncManager : IDisposable
{
public const string DefaultRepositoryUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas";
public const string BranchName = "main";
/// <summary>Where the automation plugin looks for dropped metas.</summary>
public const string MetaFolder = "mosstank/metas";
/// <summary>Where the automation plugin looks for dropped navs.</summary>
public const string NavFolder = "mosstank/navs";
private readonly IPluginStorage _storage;
private readonly HttpClient _http;
private readonly Func<string> _repositoryUrl;
private readonly Func<DateTime> _utcNow;
private readonly Action<string> _notice;
private readonly object _sync = new();
private readonly SemaphoreSlim _oneAtATime = new(1, 1);
private List<MetaFileEntry> _cachedFiles = [];
public MetaSyncManager(
IPluginStorage storage,
HttpMessageHandler? handler,
Func<string> repositoryUrl,
Func<DateTime> utcNow,
Action<string> notice)
{
_storage = storage;
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_http.Timeout = TimeSpan.FromSeconds(30);
_repositoryUrl = repositoryUrl;
_utcNow = utcNow;
_notice = notice;
}
public DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue;
public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue;
/// <summary>The storage root the files are written beneath, for the path label; null without storage.</summary>
public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
public IReadOnlyList<MetaFileEntry> GetCachedFiles()
{
lock (_sync)
return [.. _cachedFiles.Select(static entry => entry.Clone())];
}
/// <summary>The API and raw-file addresses for a repository address.</summary>
public static (string TreeUrl, string RawBaseUrl) ResolveEndpoints(string repositoryUrl)
{
string address = string.IsNullOrWhiteSpace(repositoryUrl) ? DefaultRepositoryUrl : repositoryUrl.Trim();
var uri = new Uri(address, UriKind.Absolute);
string server = uri.GetLeftPart(UriPartial.Authority);
string path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
path = path[..^4];
return (
$"{server}/api/v1/repos/{path}/git/trees/{BranchName}?recursive=1",
$"{server}/{path}/raw/branch/{BranchName}");
}
/// <summary>The storage key a repository file is kept under, or null for a file that is neither a meta nor a nav.</summary>
public static string? LocalKeyFor(string relativePath)
{
string fileName = Path.GetFileName((relativePath ?? string.Empty).Replace('\\', '/'));
if (fileName.Length == 0)
return null;
if (fileName.EndsWith(".met", StringComparison.OrdinalIgnoreCase))
return $"{MetaFolder}/{fileName}";
if (fileName.EndsWith(".nav", StringComparison.OrdinalIgnoreCase))
return $"{NavFolder}/{fileName}";
return null;
}
public async Task<int> RefreshCatalogAsync(CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_oneAtATime.Release();
}
}
public async Task<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)> CheckStatusesAsync(
CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<MetaFileEntry> workingSet = CloneCache();
if (workingSet.Count == 0)
{
await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
workingSet = CloneCache();
}
foreach (MetaFileEntry entry in workingSet)
{
try
{
string? localText = entry.LocalPath.Length == 0 ? null : _storage.ReadText(entry.LocalPath);
if (localText is null)
{
entry.LocalExists = false;
entry.LocalSha256 = string.Empty;
entry.Status = MetaFileSyncStatus.NotDownloaded;
entry.StatusText = "Not downloaded";
continue;
}
entry.LocalExists = true;
entry.LocalSha256 = Sha256(localText);
string remoteText = await DownloadRemoteTextAsync(entry.RelativePath, cancellationToken)
.ConfigureAwait(false);
entry.RemoteSha256 = Sha256(remoteText);
if (string.Equals(entry.LocalSha256, entry.RemoteSha256, StringComparison.OrdinalIgnoreCase))
{
entry.Status = MetaFileSyncStatus.UpToDate;
entry.StatusText = "Up to date";
}
else
{
entry.Status = MetaFileSyncStatus.UpdateAvailable;
entry.StatusText = "Update available";
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
entry.Status = MetaFileSyncStatus.Error;
entry.StatusText = "Error";
_notice($"[Metas] Status check failed for {entry.RelativePath}: {ex.Message}");
}
}
lock (_sync)
{
_cachedFiles = workingSet;
LastStatusCheckUtc = _utcNow();
}
return (
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpToDate),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpdateAvailable),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.NotDownloaded),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.Error));
}
finally
{
_oneAtATime.Release();
}
}
public async Task<MetaDownloadResult> DownloadFileAsync(string relativePath, CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await DownloadFileCoreAsync(relativePath, cancellationToken).ConfigureAwait(false);
}
finally
{
_oneAtATime.Release();
}
}
public async Task<(int Downloaded, int Skipped, int Failed, int Backups)> DownloadOutdatedAsync(
CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<MetaFileEntry> current = CloneCache();
if (current.Count == 0)
{
await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
current = CloneCache();
}
List<MetaFileEntry> candidates = [.. current
.Where(static entry => entry.Status is MetaFileSyncStatus.UpdateAvailable
or MetaFileSyncStatus.NotDownloaded
or MetaFileSyncStatus.Unknown)
.OrderBy(static entry => entry.RelativePath, StringComparer.OrdinalIgnoreCase)];
int downloaded = 0;
int skipped = 0;
int failed = 0;
int backups = 0;
foreach (MetaFileEntry entry in candidates)
{
try
{
MetaDownloadResult result = await DownloadFileCoreAsync(entry.RelativePath, cancellationToken)
.ConfigureAwait(false);
if (result.Downloaded)
downloaded++;
if (result.SkippedAsCurrent)
skipped++;
if (result.BackupCreated)
backups++;
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
failed++;
_notice($"[Metas] Download failed for {entry.RelativePath}: {ex.Message}");
UpdateSingleCachedEntry(
entry.RelativePath, entry.LocalPath, entry.LocalSha256, entry.RemoteSha256,
MetaFileSyncStatus.Error, "Error");
}
}
return (downloaded, skipped, failed, backups);
}
finally
{
_oneAtATime.Release();
}
}
public void Dispose()
{
_http.Dispose();
_oneAtATime.Dispose();
}
private async Task<int> RefreshCatalogCoreAsync(CancellationToken cancellationToken)
{
List<string> remoteEntries = await FetchRemoteTreeEntriesAsync(cancellationToken).ConfigureAwait(false);
HashSet<string> localKeys = ListLocalKeys();
List<MetaFileEntry> result = [.. remoteEntries
.Where(static path => path.EndsWith(".met", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".nav", StringComparison.OrdinalIgnoreCase))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
string localPath = _storage.IsAvailable ? LocalKeyFor(path) ?? string.Empty : string.Empty;
bool exists = localPath.Length > 0 && localKeys.Contains(localPath);
return new MetaFileEntry
{
RelativePath = path,
FileName = Path.GetFileName(path),
Extension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(),
LocalPath = localPath,
LocalExists = exists,
Status = exists ? MetaFileSyncStatus.Unknown : MetaFileSyncStatus.NotDownloaded,
StatusText = exists ? "Unknown" : "Not downloaded",
};
})];
lock (_sync)
{
_cachedFiles = result;
LastCatalogRefreshUtc = _utcNow();
}
return result.Count;
}
private async Task<MetaDownloadResult> DownloadFileCoreAsync(string relativePath, CancellationToken cancellationToken)
{
var result = new MetaDownloadResult { RelativePath = relativePath };
if (string.IsNullOrWhiteSpace(relativePath))
{
result.Message = "No file selected.";
return result;
}
if (!_storage.IsAvailable)
{
result.Message = "VTank path could not be resolved.";
return result;
}
// The original dropped any file into the profile folder; only the
// two kinds the automation plugin reads have a folder here.
string? localPath = LocalKeyFor(relativePath);
if (localPath is null)
{
result.Message = "Only .met and .nav files can be pulled.";
return result;
}
string remoteText = await DownloadRemoteTextAsync(relativePath, cancellationToken).ConfigureAwait(false);
string remoteHash = Sha256(remoteText);
string? localText = _storage.ReadText(localPath);
if (localText is not null)
{
string localHash = Sha256(localText);
if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase))
{
result.SkippedAsCurrent = true;
result.Message = "Already up to date.";
UpdateSingleCachedEntry(relativePath, localPath, localHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
_storage.WriteText(localPath + ".bak", localText);
result.BackupCreated = true;
}
_storage.WriteText(localPath, remoteText);
result.Downloaded = true;
result.Message = result.BackupCreated ? "Downloaded and updated (.bak created)." : "Downloaded.";
UpdateSingleCachedEntry(relativePath, localPath, remoteHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
private async Task<List<string>> FetchRemoteTreeEntriesAsync(CancellationToken cancellationToken)
{
(string treeUrl, _) = ResolveEndpoints(_repositoryUrl());
using HttpResponseMessage response = await _http.GetAsync(treeUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (JObject.Parse(json)["tree"] is not JArray tree)
return [];
return [.. tree
.Where(static node => string.Equals(node["type"]?.ToString(), "blob", StringComparison.OrdinalIgnoreCase))
.Select(static node => node["path"]?.ToString())
.Where(static path => !string.IsNullOrWhiteSpace(path))
.Select(static path => path!)];
}
private async Task<string> DownloadRemoteTextAsync(string relativePath, CancellationToken cancellationToken)
{
(_, string rawBaseUrl) = ResolveEndpoints(_repositoryUrl());
string escapedPath = string.Join(
"/",
(relativePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Select(Uri.EscapeDataString));
byte[] bytes = await _http.GetByteArrayAsync($"{rawBaseUrl}/{escapedPath}", cancellationToken)
.ConfigureAwait(false);
return DecodeAsStored(bytes);
}
/// <summary>
/// The text a read of this file from storage would give back: UTF-8,
/// with a leading byte-order mark taken as the encoding it names and
/// dropped, as the host's own text read does.
/// </summary>
internal static string DecodeAsStored(byte[] bytes)
{
using var reader = new StreamReader(new MemoryStream(bytes), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}
internal static string Sha256(string text) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(CultureInfo.InvariantCulture);
private HashSet<string> ListLocalKeys()
{
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!_storage.IsAvailable)
return keys;
foreach (string key in _storage.List(MetaFolder))
keys.Add(key);
foreach (string key in _storage.List(NavFolder))
keys.Add(key);
return keys;
}
private List<MetaFileEntry> CloneCache()
{
lock (_sync)
return [.. _cachedFiles.Select(static entry => entry.Clone())];
}
private void UpdateSingleCachedEntry(
string relativePath,
string localPath,
string localHash,
string remoteHash,
MetaFileSyncStatus status,
string statusText)
{
lock (_sync)
{
MetaFileEntry? target = _cachedFiles.FirstOrDefault(entry =>
string.Equals(entry.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase));
if (target is null)
return;
target.LocalPath = localPath;
target.LocalExists = status == MetaFileSyncStatus.UpToDate
|| (localPath.Length > 0 && _storage.ReadText(localPath) is not null);
target.LocalSha256 = localHash ?? string.Empty;
target.RemoteSha256 = remoteHash ?? string.Empty;
target.Status = status;
target.StatusText = statusText ?? "Unknown";
}
}
}

View file

@ -0,0 +1,172 @@
using System.Globalization;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// The binding object of the Metas window (<c>mosswartmassacre-metas.xml</c>),
/// the original's Metas tab: the repository's files with their status, a
/// live search, a pick list for one file, and the refresh, check and
/// download buttons. Assigned on the tick thread only.
/// </summary>
internal sealed class MetasViewModel
{
private readonly Dictionary<string, string> _pathByLabel = new(StringComparer.Ordinal);
public MetasViewModel()
{
IgnoreRow = _ => { };
SearchChanged = text =>
{
SearchText = text ?? string.Empty;
SearchRequested?.Invoke();
};
ClearSearch = () =>
{
SearchText = string.Empty;
SearchRequested?.Invoke();
};
SelectionChanged = label => SelectedLabel = label ?? string.Empty;
RefreshList = () => ButtonPressed?.Invoke(nameof(RefreshList));
CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates));
DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated));
DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected));
}
/// <summary>A button was pressed; the argument is its binding name.</summary>
internal event Action<string>? ButtonPressed;
/// <summary>The search text changed or was cleared.</summary>
internal event Action? SearchRequested;
/// <summary>
/// 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.
/// </summary>
public bool IsVisible { get; set; }
public string PathText { get; set; } = "VTank Path: unresolved";
public string LastCheckText { get; set; } = "Last Check: never";
public string StatusText { get; set; } = "Status: idle";
public string SearchText { get; set; } = string.Empty;
public Action<string> SearchChanged { get; }
public Action ClearSearch { get; }
public Action RefreshList { get; }
public Action CheckUpdates { get; }
public Action DownloadOutdated { get; }
public Action DownloadSelected { get; }
public int NoRow => -1;
public Action<int> IgnoreRow { get; }
public IReadOnlyList<string> FileTypes { get; private set; } = [];
public IReadOnlyList<string> FilePaths { get; private set; } = [];
public IReadOnlyList<string> FileStatus { get; private set; } = [];
public IReadOnlyList<uint> FileStatusColors { get; private set; } = [];
public IReadOnlyList<string> SelectionItems { get; private set; } = [];
public string SelectedLabel { get; set; } = string.Empty;
public Action<string> SelectionChanged { get; }
/// <summary>The repository path of the file picked in the list, or empty.</summary>
public string SelectedRelativePath =>
_pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty;
/// <summary>
/// Redraws the list and the pick list from the manager's files, filtered
/// by the search text on path or name. The list puts the files that need
/// attention first: missing, then outdated, then failed, then current.
/// </summary>
public void ShowFiles(IReadOnlyList<MetaFileEntry> all)
{
IReadOnlyList<MetaFileEntry> files = ApplySearch(all);
List<MetaFileEntry> ordered = [.. files
.OrderBy(static file => file.Status switch
{
MetaFileSyncStatus.NotDownloaded => 0,
MetaFileSyncStatus.UpdateAvailable => 1,
MetaFileSyncStatus.Error => 2,
_ => 3,
})
.ThenBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)];
if (ordered.Count == 0)
{
bool searching = !string.IsNullOrWhiteSpace(SearchText);
FileTypes = ["-"];
FilePaths = [searching ? "No matches" : "No files loaded"];
FileStatus = [searching ? "Try a different filter" : "Click Refresh List"];
FileStatusColors = [0xFFFFFFu];
}
else
{
FileTypes = [.. ordered.Select(static file => file.Extension.ToUpperInvariant())];
FilePaths = [.. ordered.Select(static file => file.RelativePath)];
FileStatus = [.. ordered.Select(static file => file.StatusText)];
FileStatusColors = [.. ordered.Select(static file => StatusColor(file.Status))];
}
string previous = SelectedRelativePath;
_pathByLabel.Clear();
var labels = new List<string>();
foreach (MetaFileEntry file in files.OrderBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase))
{
string label = $"[{file.Extension}] {file.RelativePath}";
labels.Add(label);
_pathByLabel[label] = file.RelativePath;
}
SelectionItems = labels;
SelectedLabel = labels.FirstOrDefault(label =>
previous.Length > 0 && _pathByLabel[label].Equals(previous, StringComparison.OrdinalIgnoreCase))
?? labels.FirstOrDefault()
?? string.Empty;
}
public void ShowLastCheck(DateTime lastStatusCheckUtc, DateTime lastCatalogRefreshUtc)
{
DateTime stamp = lastStatusCheckUtc != DateTime.MinValue ? lastStatusCheckUtc : lastCatalogRefreshUtc;
LastCheckText = stamp == DateTime.MinValue
? "Last Check: never"
: string.Create(
CultureInfo.InvariantCulture,
$"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}");
}
public void ShowPath(string? storageRoot) =>
PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}";
/// <summary>The status text colour: green current, orange outdated, blue missing, red failed.</summary>
public static uint StatusColor(MetaFileSyncStatus status) => status switch
{
MetaFileSyncStatus.UpToDate => 0x008000u,
MetaFileSyncStatus.UpdateAvailable => 0xB46E00u,
MetaFileSyncStatus.NotDownloaded => 0x0046A0u,
MetaFileSyncStatus.Error => 0xB40000u,
_ => 0xFFFFFFu,
};
private IReadOnlyList<MetaFileEntry> ApplySearch(IReadOnlyList<MetaFileEntry> files)
{
string filter = SearchText.Trim();
if (filter.Length == 0)
return files;
return [.. files.Where(file =>
file.RelativePath.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| file.FileName.Contains(filter, StringComparison.OrdinalIgnoreCase))];
}
}

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

View 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;
}
}

View 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,
};
}

View file

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The "Mossy Tracker" window. One tab per page of the original window, plus
the tracked weapons it kept data for; each page is a Refresh button over a
read-only list whose header row is the labels above it. -->
<panel x="60" y="60" w="620" h="440" title="Mossy Tracker"
visible="{IsVisible}" resizable="true" minw="620" minh="440">
<tab x="6" y="22" w="50" h="18" text="Augs"
selected="{AugsSelected}" onclick="{ShowAugs}" />
<tab x="56" y="22" w="44" h="18" text="Lum"
selected="{LumSelected}" onclick="{ShowLum}" />
<tab x="100" y="22" w="60" h="18" text="Recalls"
selected="{RecallsSelected}" onclick="{ShowRecalls}" />
<tab x="160" y="22" w="64" h="18" text="Cantrips"
selected="{CantripsSelected}" onclick="{ShowCantrips}" />
<tab x="224" y="22" w="66" h="18" text="Weapons"
selected="{WeaponsSelected}" onclick="{ShowWeapons}" />
<tab x="290" y="22" w="58" h="18" text="Quests"
selected="{QuestsSelected}" onclick="{ShowQuests}" />
<!-- ==================== Augmentations ==================== -->
<group x="4" y="44" w="612" h="392" visible="{AugsSelected}"
anchor="left top right bottom">
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshAugs}" />
<label x="2" y="26" text="Augmentation" />
<label x="202" y="26" text="Progress" />
<label x="282" y="26" text="Trainer" />
<label x="442" y="26" text="Location" />
<list x="0" y="42" w="612" h="348" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="200" items="{AugNames}" />
<column type="text" width="80" items="{AugProgress}" colors="{AugProgressColors}" />
<column type="text" width="160" items="{AugTrainers}" />
<column type="text" width="*" items="{AugLocations}" />
</list>
</group>
<!-- ==================== Luminance auras ==================== -->
<group x="4" y="44" w="612" h="392" visible="{LumSelected}"
anchor="left top right bottom">
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshLum}" />
<label x="2" y="26" text="Luminance Aura" />
<label x="302" y="26" text="Progress" />
<label x="402" y="26" text="Category" />
<list x="0" y="42" w="612" h="348" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="300" items="{LumNames}" />
<column type="text" width="100" items="{LumProgress}" colors="{LumProgressColors}" />
<column type="text" width="*" items="{LumCategories}" />
</list>
</group>
<!-- ==================== Recall spells ==================== -->
<group x="4" y="44" w="612" h="392" visible="{RecallsSelected}"
anchor="left top right bottom">
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshRecalls}" />
<label x="22" y="26" text="Recall Spell" />
<label x="422" y="26" text="Status" />
<list x="0" y="42" w="612" h="348" rowheight="18"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="icon" width="20" iconkind="spell" values="{RecallSpellIds}" onclick="{IgnoreRow}" />
<column type="text" width="400" items="{RecallNames}" />
<column type="text" width="*" items="{RecallStatus}" colors="{RecallStatusColors}" />
</list>
</group>
<!-- ==================== Cantrips ==================== -->
<group x="4" y="44" w="612" h="392" visible="{CantripsSelected}"
anchor="left top right bottom">
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshCantrips}" />
<label x="22" y="26" text="Skill/Effect" />
<label x="422" y="26" text="Cantrip Level" />
<list x="0" y="42" w="612" h="348" rowheight="18"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="icon" width="20" iconkind="did" values="{CantripIcons}" onclick="{IgnoreRow}" />
<column type="text" width="400" items="{CantripNames}" />
<column type="text" width="*" items="{CantripLevels}" colors="{CantripLevelColors}" />
</list>
</group>
<!-- ==================== Weapons ==================== -->
<group x="4" y="44" w="612" h="392" visible="{WeaponsSelected}"
anchor="left top right bottom">
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshWeapons}" />
<label x="22" y="26" text="Weapon" />
<label x="322" y="26" text="Status" />
<list x="0" y="42" w="612" h="348" rowheight="18"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="icon" width="20" iconkind="did" values="{WeaponIcons}" onclick="{IgnoreRow}" />
<column type="text" width="300" items="{WeaponNames}" />
<column type="text" width="*" items="{WeaponStatus}" colors="{WeaponStatusColors}" />
</list>
</group>
<!-- ==================== Quests ==================== -->
<group x="4" y="44" w="612" h="392" visible="{QuestsSelected}"
anchor="left top right bottom">
<button x="0" y="0" w="110" h="20" text="Refresh Quests" onclick="{RefreshQuests}" />
<label x="2" y="26" text="Quest Name" />
<label x="232" y="26" text="Countdown" />
<label x="322" y="26" text="Last Solved" />
<label x="452" y="26" text="Cooldown" />
<label x="552" y="26" text="Solves" />
<list x="0" y="42" w="612" h="348" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="230" items="{QuestRowNames}" />
<column type="text" width="90" items="{QuestCountdowns}" colors="{QuestCountdownColors}" />
<column type="text" width="130" items="{QuestLastSolved}" />
<column type="text" width="100" items="{QuestCooldowns}" />
<column type="text" width="*" items="{QuestSolves}" colors="{QuestSolvesColors}" />
</list>
</group>
</panel>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Metas window: the original's Metas tab in a window of its own. The
repository's .met/.nav files with their status, a live search, a pick list
for one file, and refresh, check and download. -->
<panel x="80" y="80" w="420" h="320" title="Metas"
visible="{IsVisible}" resizable="true" minw="420" minh="320">
<label x="10" y="24" text="Remote .met/.nav files from metas repo" />
<label x="10" y="40" text="{PathText}" />
<button x="10" y="58" w="95" h="22" text="Refresh List" onclick="{RefreshList}" />
<button x="110" y="58" w="95" h="22" text="Check Updates" onclick="{CheckUpdates}" />
<button x="210" y="58" w="120" h="22" text="Download Outdated" onclick="{DownloadOutdated}" />
<label x="10" y="86" text="Search:" />
<field x="58" y="84" w="212" h="20" text="{SearchText}" onchange="{SearchChanged}" />
<button x="275" y="83" w="55" h="22" text="Clear" onclick="{ClearSearch}" />
<list x="10" y="110" w="400" h="128" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="45" items="{FileTypes}" />
<column type="text" width="230" items="{FilePaths}" />
<column type="text" width="*" items="{FileStatus}" colors="{FileStatusColors}" />
</list>
<label x="10" y="246" text="Selected file:" anchor="left bottom" />
<menu x="95" y="244" w="225" h="20" anchor="left bottom"
items="{SelectionItems}" selected="{SelectedLabel}" onchange="{SelectionChanged}" />
<button x="325" y="243" w="85" h="22" text="Download" anchor="left bottom"
onclick="{DownloadSelected}" />
<label x="10" y="272" text="{LastCheckText}" anchor="left bottom" />
<label x="10" y="290" text="{StatusText}" anchor="left bottom" />
</panel>

View file

@ -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));

View file

@ -452,6 +452,11 @@ public sealed class FakeSpellCatalog : ISpellCatalog
/// <summary>The full spell table, independent of what the character knows.</summary>
public IReadOnlyList<PluginSpellInfo> All { get; set; } = [];
/// <summary>The spells the character knows, for <see cref="IsKnown"/>.</summary>
public HashSet<uint> KnownSpellIds { get; } = [];
public bool IsKnown(uint spellId) => KnownSpellIds.Contains(spellId);
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
foreach (PluginSpellInfo spell in KnownSelfBuffs

View file

@ -0,0 +1,239 @@
using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Tests.Fakes;
namespace OpenAC.MosswartMassacre.Tests.FlagTracker;
public sealed class FlagTrackerTests
{
private const uint Player = 0x50000001;
private static PluginHarness WithUi(Action<FakeHost>? arrange = null) =>
new(arrange: host =>
{
host.HasUi = true;
arrange?.Invoke(host);
});
private static void SetPlayerInts(FakeHost host, Dictionary<uint, int> ints) =>
host.Automation.Objects.Properties[Player] = new PluginItemProperties(
ints,
new Dictionary<uint, long>(),
new Dictionary<uint, bool>(),
new Dictionary<uint, double>(),
new Dictionary<uint, string>(),
new Dictionary<uint, uint>(),
new Dictionary<uint, uint>());
private static FlagTrackerViewModel Panel(PluginHarness harness) =>
harness.Feature<FlagTrackerFeature>().Panel!;
private static int Row(IReadOnlyList<string> names, string name)
{
int index = names.ToList().IndexOf(name);
Assert.True(index >= 0, $"no row {name}");
return index;
}
[Fact]
public void A_headless_host_gets_no_window_and_login_does_nothing()
{
using var harness = new PluginHarness();
harness.Login();
harness.Run(6);
FlagTrackerFeature feature = harness.Feature<FlagTrackerFeature>();
Assert.Null(feature.Panel);
Assert.Empty(harness.Host.Ui.Panels);
}
[Fact]
public void A_graphical_host_gets_the_mossy_tracker_window_closed()
{
using var harness = WithUi();
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker");
Assert.Equal("flagtracker", panel.Descriptor.WindowId);
Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
Assert.True(panel.Descriptor.ShowInSidePanel);
Assert.EndsWith(FlagTrackerFeature.MarkupFile, panel.MarkupPath, StringComparison.Ordinal);
Assert.Same(Panel(harness), panel.Binding);
Assert.False(Panel(harness).IsVisible);
Assert.True(Panel(harness).AugsSelected);
Panel(harness).ShowQuests();
Assert.True(Panel(harness).QuestsSelected);
Assert.False(Panel(harness).AugsSelected);
}
[Fact]
public void Augmentations_come_from_the_players_own_properties()
{
using var harness = WithUi(host => SetPlayerInts(host, new() { [231] = 3, [230] = 2 }));
harness.Login();
FlagTrackerViewModel panel = Panel(harness);
Assert.Equal("--- Death Augs ---", panel.AugNames[0]);
int keepItems = Row(panel.AugNames, "Keep Items");
Assert.Equal("[MAX]", panel.AugProgress[keepItems]);
Assert.Equal(0x008000u, panel.AugProgressColors[keepItems]);
Assert.Equal("Rohula bint Ludun", panel.AugTrainers[keepItems]);
Assert.Equal("Ayan Baqur", panel.AugLocations[keepItems]);
int keepSpells = Row(panel.AugNames, "Keep Spells");
Assert.Equal("[0/1]", panel.AugProgress[keepSpells]);
Assert.Equal(0xFF0000u, panel.AugProgressColors[keepSpells]);
int carrying = Row(panel.AugNames, "Extra Carrying Capacity");
Assert.Equal("[2/5]", panel.AugProgress[carrying]);
Assert.Equal(0xFFFF00u, panel.AugProgressColors[carrying]);
// A bought augmentation shows after Refresh.
SetPlayerInts(harness.Host, new() { [232] = 1 });
panel.RefreshAugs();
Assert.Equal("[MAX]", panel.AugProgress[keepSpells]);
}
[Fact]
public void Luminance_auras_show_progress_and_the_seer_they_belong_to()
{
using var harness = WithUi(host => SetPlayerInts(host, new() { [333] = 5, [344] = 2 }));
harness.Login();
FlagTrackerViewModel panel = Panel(harness);
int damage = Row(panel.LumNames, "+1 Damage Rating");
Assert.Equal("5/5", panel.LumProgress[damage]);
Assert.Equal(0x008000u, panel.LumProgressColors[damage]);
Assert.Equal("Nalicana Auras", panel.LumCategories[damage]);
int kahiri = Row(panel.LumNames, "(Ka'hiri) +2 Specialized Skills");
Assert.Equal("2/5", panel.LumProgress[kahiri]);
Assert.Equal(0xFFFF00u, panel.LumProgressColors[kahiri]);
Assert.Equal("LoyalToKahiri", panel.LumCategories[kahiri]);
}
[Fact]
public void Recalls_show_known_and_unknown_with_their_spell_icons()
{
using var harness = WithUi(host => host.Automation.Spells.KnownSpellIds.Add(2023));
harness.Login();
FlagTrackerViewModel panel = Panel(harness);
Assert.Equal(17, panel.RecallNames.Count);
Assert.Equal("Recall the Sanctuary", panel.RecallNames[0]);
Assert.Equal(2023u, panel.RecallSpellIds[0]);
Assert.Equal("Known", panel.RecallStatus[0]);
Assert.Equal(0x008000u, panel.RecallStatusColors[0]);
Assert.Equal("Unknown", panel.RecallStatus[1]);
Assert.Equal(0xFF0000u, panel.RecallStatusColors[1]);
}
[Fact]
public void Cantrips_are_matched_to_trained_skills_attributes_and_wards()
{
using var harness = WithUi(host =>
{
host.Automation.Character.Skills.Add(new PluginSkillInfo(34, "War Magic", PluginSkillTraining.Specialized, 400));
host.Automation.Character.Skills.Add(new PluginSkillInfo(6, "Melee Defense", PluginSkillTraining.Trained, 300));
host.Automation.Character.Skills.Add(new PluginSkillInfo(21, "Healing", PluginSkillTraining.Untrained, 10));
host.Automation.Spells.All =
[
FakeSpellCatalog.Spell(4715, "Epic War Magic Aptitude"),
FakeSpellCatalog.Spell(2248, "Major Invulnerability"),
FakeSpellCatalog.Spell(1, "Minor Invulnerability"),
FakeSpellCatalog.Spell(6099, "Legendary Flame Ward"),
FakeSpellCatalog.Spell(2091, "Major Strength"),
FakeSpellCatalog.Spell(2, "Strength Self VIII"),
];
host.Automation.Character.ActiveEnchantments =
[
new PluginActiveEnchantment(4715, 0, 0, -1),
new PluginActiveEnchantment(2248, 0, 0, -1),
new PluginActiveEnchantment(1, 0, 0, -1),
new PluginActiveEnchantment(6099, 0, 0, -1),
new PluginActiveEnchantment(2091, 0, 0, -1),
new PluginActiveEnchantment(2, 0, 0, 3600),
];
});
harness.Login();
FlagTrackerViewModel panel = Panel(harness);
Assert.Equal(
[
"--- Specialized Skills ---", "War Magic",
"--- Trained Skills ---", "Invulnerability",
"--- Attributes ---", "Strength",
"--- Protection Auras ---", "Armor", "Bludgeoning Ward", "Piercing Ward",
"Slashing Ward", "Flame Ward", "Frost Ward", "Acid Ward", "Storm Ward",
],
panel.CantripNames);
Assert.Equal(FlagTrackerData.Icons.CategoryStar, panel.CantripIcons[0]);
Assert.Equal("Epic", panel.CantripLevels[1]);
Assert.Equal(0x800080u, panel.CantripLevelColors[1]);
Assert.Equal(FlagTrackerData.Icons.GreenCircle, panel.CantripIcons[1]);
// The higher of two cantrips on one skill wins.
Assert.Equal("Major", panel.CantripLevels[3]);
Assert.Equal(0x0000FFu, panel.CantripLevelColors[3]);
Assert.Equal("Major", panel.CantripLevels[5]);
Assert.Equal("N/A", panel.CantripLevels[7]);
Assert.Equal(FlagTrackerData.Icons.RedCircle, panel.CantripIcons[7]);
int flame = Row(panel.CantripNames, "Flame Ward");
Assert.Equal("Legendary", panel.CantripLevels[flame]);
Assert.Equal(0xFFA500u, panel.CantripLevelColors[flame]);
}
[Fact]
public void Tracked_weapons_are_found_in_the_inventory_by_name()
{
using var harness = WithUi(host => host.Automation.Items.Owned.Add(
FakeItems.Item(0x80000001, "Atlan Sword") with { IconId = 0x06001234 }));
harness.Login();
FlagTrackerViewModel panel = Panel(harness);
int atlan = Row(panel.WeaponNames, "Atlan Sword");
Assert.Equal("Acquired", panel.WeaponStatus[atlan]);
Assert.Equal(0x06001234u, panel.WeaponIcons[atlan]);
int axe = Row(panel.WeaponNames, "Atlan Axe");
Assert.Equal("Not Acquired", panel.WeaponStatus[axe]);
Assert.Equal(FlagTrackerData.Icons.Portal, panel.WeaponIcons[axe]);
}
[Fact]
public void The_quest_page_hides_maxed_quests_and_sorts_by_solves()
{
using var harness = WithUi();
harness.Login();
long now = new DateTimeOffset(harness.Clock.UtcNow).ToUnixTimeSeconds();
var chat = harness.Host.Automation.Chat;
chat.Deliver($"stipendtimer_0812 - 2 solves ({now - 10})\"x\" -1 3700");
chat.Deliver($"maxed - 5 solves ({now})\"x\" 5 60");
chat.Deliver($"aaa - 7 solves ({now - 100})\"x\" -1 50");
FlagTrackerViewModel panel = Panel(harness);
Assert.Equal(["aaa", "Stipend Collection Timer"], panel.QuestRowNames);
Assert.Equal(["READY", "1h 01m"], panel.QuestCountdowns);
Assert.Equal([0x008000u, 0xFFFF00u], panel.QuestCountdownColors);
Assert.Equal(["50s", "1h 1m 40s"], panel.QuestCooldowns);
Assert.Equal(["7", "2"], panel.QuestSolves);
// The countdowns move on every five seconds.
harness.Run(5.1);
Assert.Equal("1h 01m", panel.QuestCountdowns[1]);
harness.Run(120);
Assert.StartsWith("59m ", panel.QuestCountdowns[1], StringComparison.Ordinal);
}
[Fact]
public void Logoff_empties_the_pages()
{
using var harness = WithUi();
harness.Login();
Assert.NotEmpty(Panel(harness).AugNames);
harness.Plugin.OnLogoff();
Assert.Empty(Panel(harness).AugNames);
Assert.Empty(Panel(harness).QuestRowNames);
}
}

View file

@ -0,0 +1,361 @@
using System.Net;
using System.Text;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Tests.Fakes;
namespace OpenAC.MosswartMassacre.Tests.Metas;
/// <summary>Answers requests from a table of URLs and records what was asked; no network.</summary>
internal sealed class FakeGitServer : HttpMessageHandler
{
public Dictionary<string, (HttpStatusCode Status, byte[] Body)> Responses { get; } = new(StringComparer.Ordinal);
public List<string> Requests { get; } = [];
public void Text(string url, string body) =>
Responses[url] = (HttpStatusCode.OK, Encoding.UTF8.GetBytes(body));
public void Bytes(string url, byte[] body) => Responses[url] = (HttpStatusCode.OK, body);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string url = request.RequestUri!.AbsoluteUri;
lock (Requests)
Requests.Add(url);
HttpResponseMessage response = Responses.TryGetValue(url, out var answer)
? new HttpResponseMessage(answer.Status) { Content = new ByteArrayContent(answer.Body) }
: new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("not found") };
return Task.FromResult(response);
}
}
public sealed class MetaSyncTests
{
// The original's endpoints, verbatim (MetaSyncManager: ApiBaseUrl + repos/{owner}/{repo}/git/trees/{branch}?recursive=1,
// RawBaseUrl + "/" + escaped path).
private const string TreeUrl =
"https://git.snakedesert.se/api/v1/repos/SawatoMosswartsEnjoyersClub/metas/git/trees/main?recursive=1";
private const string RawBase = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas/raw/branch/main";
// A Gitea tree answer: blobs and trees, only .met/.nav blobs count.
private const string TreeJson = """
{"sha":"abc","url":"x","tree":[
{"path":"README.md","mode":"100644","type":"blob","size":10,"sha":"1"},
{"path":"hunting","mode":"040000","type":"tree","sha":"2"},
{"path":"hunting/Colo.met","mode":"100644","type":"blob","size":10,"sha":"3"},
{"path":"navs/Colo Loop.nav","mode":"100644","type":"blob","size":10,"sha":"4"},
{"path":"x.MET","mode":"100644","type":"blob","size":10,"sha":"5"},
{"path":"folder.nav","mode":"040000","type":"tree","sha":"6"}
],"truncated":false,"page":1,"total_count":6}
""";
private const string ColoMet = "1\r\nCondition\r\nNever\r\n";
private const string LoopNav = "uTank2 NAV 1.2\r\n4\r\n2\r\n";
private const string XMet = "x-meta";
private static (MetaSyncManager Manager, FakeGitServer Server, MemoryStorage Storage, List<string> Notices) Build(
string repositoryUrl = "")
{
var server = new FakeGitServer();
server.Text(TreeUrl, TreeJson);
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
server.Text(RawBase + "/x.MET", XMet);
var storage = new MemoryStorage();
var notices = new List<string>();
var manager = new MetaSyncManager(
storage,
server,
() => repositoryUrl,
() => new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc),
notice => { lock (notices) notices.Add(notice); });
return (manager, server, storage, notices);
}
[Fact]
public void The_default_repository_gives_the_original_endpoints()
{
(string tree, string raw) = MetaSyncManager.ResolveEndpoints(string.Empty);
Assert.Equal(TreeUrl, tree);
Assert.Equal(RawBase, raw);
(tree, raw) = MetaSyncManager.ResolveEndpoints(MetaSyncManager.DefaultRepositoryUrl);
Assert.Equal(TreeUrl, tree);
Assert.Equal(RawBase, raw);
(tree, raw) = MetaSyncManager.ResolveEndpoints("https://git.example.org/me/metas.git/");
Assert.Equal("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", tree);
Assert.Equal("https://git.example.org/me/metas/raw/branch/main", raw);
}
[Fact]
public void Files_land_where_the_automation_plugin_reads_dropped_profiles()
{
Assert.Equal("mosstank/metas/Colo.met", MetaSyncManager.LocalKeyFor("hunting/Colo.met"));
Assert.Equal("mosstank/navs/Colo Loop.nav", MetaSyncManager.LocalKeyFor("navs/Colo Loop.nav"));
Assert.Equal("mosstank/metas/x.MET", MetaSyncManager.LocalKeyFor("x.MET"));
Assert.Null(MetaSyncManager.LocalKeyFor("README.md"));
}
[Fact]
public async Task Refresh_lists_the_met_and_nav_blobs_and_what_is_already_here()
{
var (manager, server, storage, _) = Build();
storage.Seed("mosstank/metas/Colo.met", "old");
int count = await manager.RefreshCatalogAsync();
Assert.Equal(3, count);
Assert.Equal([TreeUrl], server.Requests);
IReadOnlyList<MetaFileEntry> files = manager.GetCachedFiles();
Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], files.Select(file => file.RelativePath));
Assert.Equal(["met", "nav", "met"], files.Select(file => file.Extension));
Assert.Equal(MetaFileSyncStatus.Unknown, files[0].Status);
Assert.Equal("Unknown", files[0].StatusText);
Assert.Equal(MetaFileSyncStatus.NotDownloaded, files[1].Status);
Assert.Equal("Not downloaded", files[1].StatusText);
Assert.Equal("mosstank/navs/Colo Loop.nav", files[1].LocalPath);
Assert.Equal(new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc), manager.LastCatalogRefreshUtc);
}
[Fact]
public async Task Check_compares_the_stored_text_with_the_remote_file()
{
var (manager, server, storage, notices) = Build();
storage.Seed("mosstank/metas/Colo.met", ColoMet);
storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
storage.Seed("mosstank/metas/x.MET", XMet);
server.Responses.Remove(RawBase + "/x.MET");
var summary = await manager.CheckStatusesAsync();
Assert.Equal((1, 1, 0, 1), summary);
IReadOnlyList<MetaFileEntry> files = manager.GetCachedFiles();
Assert.Equal(
["Up to date", "Update available", "Error"],
files.Select(file => file.StatusText));
Assert.Contains(notices, notice => notice.StartsWith(
"[Metas] Status check failed for x.MET: ", StringComparison.Ordinal));
Assert.Contains(RawBase + "/navs/Colo%20Loop.nav", server.Requests);
}
[Fact]
public async Task Pull_downloads_missing_and_outdated_files_and_backs_up_the_old_copy()
{
var (manager, _, storage, _) = Build();
storage.Seed("mosstank/metas/Colo.met", ColoMet);
storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
var result = await manager.DownloadOutdatedAsync();
Assert.Equal((2, 1, 0, 1), result);
Assert.Equal(LoopNav, storage.ReadText("mosstank/navs/Colo Loop.nav"));
Assert.Equal("an older route", storage.ReadText("mosstank/navs/Colo Loop.nav.bak"));
Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
Assert.Null(storage.ReadText("mosstank/metas/Colo.met.bak"));
Assert.All(manager.GetCachedFiles(), file => Assert.Equal(MetaFileSyncStatus.UpToDate, file.Status));
// Nothing left to do the second time.
Assert.Equal((0, 0, 0, 0), await manager.DownloadOutdatedAsync());
}
[Fact]
public async Task A_failed_download_is_counted_and_marked()
{
var (manager, server, _, notices) = Build();
server.Responses.Remove(RawBase + "/x.MET");
await manager.RefreshCatalogAsync();
var result = await manager.DownloadOutdatedAsync();
Assert.Equal((2, 0, 1, 0), result);
Assert.Equal(MetaFileSyncStatus.Error, manager.GetCachedFiles().Single(file => file.RelativePath == "x.MET").Status);
Assert.Contains(notices, notice => notice.StartsWith("[Metas] Download failed for x.MET: ", StringComparison.Ordinal));
}
[Fact]
public async Task A_byte_order_mark_is_dropped_so_the_stored_copy_compares_equal()
{
var (manager, server, storage, _) = Build();
server.Bytes(RawBase + "/hunting/Colo.met", [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes(ColoMet)]);
await manager.RefreshCatalogAsync();
MetaDownloadResult first = await manager.DownloadFileAsync("hunting/Colo.met");
MetaDownloadResult second = await manager.DownloadFileAsync("hunting/Colo.met");
Assert.Equal("Downloaded.", first.Message);
Assert.Equal(ColoMet, storage.ReadText("mosstank/metas/Colo.met"));
Assert.True(second.SkippedAsCurrent);
Assert.Equal("Already up to date.", second.Message);
Assert.Equal((1, 0, 2, 0), await manager.CheckStatusesAsync());
}
[Fact]
public async Task Pulling_one_file_reports_like_the_original()
{
var (manager, _, storage, _) = Build();
storage.Seed("mosstank/metas/Colo.met", "old");
Assert.Equal("No file selected.", (await manager.DownloadFileAsync(" ")).Message);
Assert.Equal("Only .met and .nav files can be pulled.", (await manager.DownloadFileAsync("README.md")).Message);
Assert.Equal("Downloaded and updated (.bak created).", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
storage.IsAvailable = false;
Assert.Equal("VTank path could not be resolved.", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
}
[Fact]
public async Task The_repository_setting_is_honoured()
{
var (manager, server, _, _) = Build("https://git.example.org/me/metas");
server.Text("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", TreeJson);
Assert.Equal(3, await manager.RefreshCatalogAsync());
Assert.Equal(["https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1"], server.Requests);
}
// ---- through the /mm verbs and the window ------------------------------
private static (PluginHarness Harness, MetaSyncFeature Feature, FakeGitServer Server, MemoryStorage Storage) BuildFeature(
bool hasUi = false)
{
var harness = new PluginHarness(arrange: host => host.HasUi = hasUi);
harness.Login();
var server = new FakeGitServer();
server.Text(TreeUrl, TreeJson);
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
server.Text(RawBase + "/x.MET", XMet);
var storage = new MemoryStorage { RootPath = "C:/profiles" };
var feature = new MetaSyncFeature(harness.Context, storage, server);
return (harness, feature, server, storage);
}
private static async Task Settle(MetaSyncFeature feature)
{
await feature.LastOperation;
feature.Pump();
}
[Fact]
public async Task The_mm_verbs_write_the_original_chat_lines_on_the_tick()
{
var (harness, feature, server, storage) = BuildFeature();
using (harness)
using (feature)
{
storage.Seed("mosstank/metas/Colo.met", ColoMet);
harness.Context.Commands.Dispatch("metasrefresh");
await Settle(feature);
harness.Context.Commands.Dispatch("metascheck");
await Settle(feature);
harness.Context.Commands.Dispatch("metaspull");
await Settle(feature);
harness.Context.Commands.Dispatch("metaspullfile navs/Colo Loop.nav");
await Settle(feature);
harness.Context.Commands.Dispatch("metaspullfile");
server.Responses.Remove(TreeUrl);
harness.Context.Commands.Dispatch("metasrefresh");
await Settle(feature);
Assert.Equal(
[
"[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files",
"[Mosswart Massacre] [Metas] Up-to-date: 1, Updates: 0, Missing: 2, Errors: 0",
"[Mosswart Massacre] [Metas] Downloaded: 2, Skipped: 0, Failed: 0, Backups: 0",
"[Mosswart Massacre] [Metas] navs/Colo Loop.nav: Already up to date.",
"[Mosswart Massacre] Usage: /mm metaspullfile <relative/path/file.met>",
"[Mosswart Massacre] [Metas] Refresh failed: Response status code does not indicate success: 404 (Not Found).",
],
harness.ChatLines.Where(line => line.Contains("[Metas]", StringComparison.Ordinal)
|| line.Contains("metaspullfile", StringComparison.Ordinal)));
}
}
[Fact]
public async Task Nothing_reaches_chat_before_the_tick_applies_it()
{
var (harness, feature, _, _) = BuildFeature();
using (harness)
using (feature)
{
harness.Context.Commands.Dispatch("metasrefresh");
await feature.LastOperation;
Assert.DoesNotContain(harness.ChatLines, line => line.Contains("[Metas]", StringComparison.Ordinal));
harness.Tick(0.01);
Assert.Contains("[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files", harness.ChatLines);
}
}
[Fact]
public void A_headless_host_gets_the_verbs_but_no_window()
{
var (harness, feature, _, _) = BuildFeature();
using (harness)
using (feature)
{
Assert.Null(feature.Panel);
Assert.Null(harness.Feature<MetaSyncFeature>().Panel);
Assert.Empty(harness.Host.Ui.Panels);
}
}
[Fact]
public async Task The_window_lists_searches_and_downloads_the_picked_file()
{
var (harness, feature, _, storage) = BuildFeature(hasUi: true);
using (harness)
using (feature)
{
MetasViewModel panel = feature.Panel!;
Assert.False(panel.IsVisible);
Assert.Equal("VTank Path: C:/profiles", panel.PathText);
Assert.Equal(["No files loaded"], panel.FilePaths);
storage.Seed("mosstank/metas/x.MET", "stale");
panel.RefreshList();
Assert.Equal("Status: refreshing remote file list...", panel.StatusText);
await Settle(feature);
Assert.Equal("Status: loaded 3 remote files", panel.StatusText);
// Missing first, then the rest by path.
Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], panel.FilePaths);
Assert.Equal(["MET", "NAV", "MET"], panel.FileTypes);
Assert.Equal(["Not downloaded", "Not downloaded", "Unknown"], panel.FileStatus);
Assert.Equal([0x0046A0u, 0x0046A0u, 0xFFFFFFu], panel.FileStatusColors);
Assert.Equal(["[met] hunting/Colo.met", "[nav] navs/Colo Loop.nav", "[met] x.MET"], panel.SelectionItems);
Assert.Equal("[met] hunting/Colo.met", panel.SelectedLabel);
Assert.StartsWith("Last Check: 2026-09-25 ", panel.LastCheckText, StringComparison.Ordinal);
panel.CheckUpdates();
await Settle(feature);
Assert.Equal("Status: 0 current, 1 updates, 2 missing, 0 errors", panel.StatusText);
Assert.Equal(["Not downloaded", "Not downloaded", "Update available"], panel.FileStatus);
Assert.Equal(0xB46E00u, panel.FileStatusColors[2]);
panel.SearchChanged("loop");
Assert.Equal(["navs/Colo Loop.nav"], panel.FilePaths);
Assert.Equal("[nav] navs/Colo Loop.nav", panel.SelectedLabel);
panel.SearchChanged("zzz");
Assert.Equal(["No matches"], panel.FilePaths);
Assert.Equal(["Try a different filter"], panel.FileStatus);
panel.ClearSearch();
Assert.Equal(3, panel.FilePaths.Count);
panel.SelectionChanged("[met] x.MET");
panel.DownloadSelected();
Assert.Equal("Status: downloading x.MET...", panel.StatusText);
await Settle(feature);
Assert.Equal("Status: Downloaded and updated (.bak created).", panel.StatusText);
Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
Assert.Equal("stale", storage.ReadText("mosstank/metas/x.MET.bak"));
Assert.Equal("[met] x.MET", panel.SelectedLabel);
panel.DownloadOutdated();
await Settle(feature);
Assert.Equal("Status: downloaded 2, skipped 0, failed 0, backups 0", panel.StatusText);
Assert.All(panel.FileStatus, status => Assert.Equal("Up to date", status));
}
}
}

View file

@ -0,0 +1,235 @@
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.Where(IsQuest));
}
[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.Where(IsQuest));
}
[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());
}
private static bool IsQuest(string frame) =>
frame.StartsWith("{\"type\":\"quest\"", StringComparison.Ordinal);
[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.DoesNotContain(harness.Frames, IsQuest);
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;
}
}
}

View file

@ -45,6 +45,8 @@ public sealed class StreamGoldenTests
arrange?.Invoke(harness);
harness.Login();
harness.Frames.Clear();
// Login itself submits /myquests; these tests count only their own commands.
harness.Host.Automation.Chat.Submitted.Clear();
return harness;
}

View file

@ -0,0 +1,222 @@
using System.Reflection;
using System.Xml.Linq;
using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Metas;
namespace OpenAC.MosswartMassacre.Tests.Ui;
/// <summary>
/// Proves every <c>{Binding}</c> in this slice's markup files resolves to a
/// public property of the right shape on its binding object, the check the
/// host would otherwise only make when the window is built in game.
/// </summary>
public sealed class TrackerAndMetasMarkupContractTests
{
private static readonly string[] InteractiveElementNames =
[
"tab", "button", "toggle", "slider", "field", "menu", "list", "column",
];
public static TheoryData<string, Type> MarkupFiles => new()
{
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
{ MetaSyncFeature.MarkupFile, typeof(MetasViewModel) },
};
[Theory]
[MemberData(nameof(MarkupFiles))]
public void EveryBindingResolvesToAPublicProperty(string file, Type bindingType)
{
XElement root = LoadRoot(file);
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
{
string value = attribute.Value;
if (!value.Contains('{', StringComparison.Ordinal))
continue;
Assert.Matches("^\\{[^{}]+\\}$", value);
string name = value[1..^1];
Assert.True(
byName.ContainsKey(name),
$"{file}: binding {value} on <{attribute.Parent?.Name}> has no public {bindingType.Name} property.");
}
}
[Theory]
[MemberData(nameof(MarkupFiles))]
public void EveryBindingHasTheShapeTheHostReads(string file, Type bindingType)
{
XElement root = LoadRoot(file);
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
foreach (XElement element in root.DescendantsAndSelf())
AssertElementBindings(file, element, byName);
}
[Theory]
[MemberData(nameof(MarkupFiles))]
public void EveryInteractiveControlDeclaresAHandler(string file, Type bindingType)
{
_ = bindingType;
XElement root = LoadRoot(file);
HashSet<string> interactive = new(InteractiveElementNames, StringComparer.Ordinal);
XElement[] controls = [.. root.Descendants().Where(element => interactive.Contains(element.Name.LocalName))];
Assert.NotEmpty(controls);
foreach (XElement control in controls)
{
if (control.Name.LocalName == "column" && (string?)control.Attribute("type") == "text")
continue;
XAttribute? handler = control.Attribute("onclick")
?? control.Attribute("onchange")
?? control.Attribute("onsubmit");
Assert.True(handler is not null, $"{file}: <{control.Name.LocalName}> declares no handler.");
}
}
[Theory]
[MemberData(nameof(MarkupFiles))]
public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType)
{
XElement root = LoadRoot(file);
Assert.Equal("panel", root.Name.LocalName);
Assert.Equal("{IsVisible}", (string?)root.Attribute("visible"));
PropertyInfo isVisible = PropertiesOf(bindingType)["IsVisible"];
Assert.Equal(typeof(bool), isVisible.PropertyType);
Assert.True(isVisible.CanWrite && isVisible.SetMethod!.IsPublic);
}
[Theory]
[MemberData(nameof(MarkupFiles))]
public void EveryListColumnHasASaneWidth(string file, Type bindingType)
{
_ = bindingType;
XElement root = LoadRoot(file);
foreach (XElement list in root.Descendants("list"))
{
XElement[] columns = [.. list.Elements("column")];
for (int index = 0; index < columns.Length; index++)
{
string? width = (string?)columns[index].Attribute("width");
Assert.True(width is not null, $"{file}: <column> {index} has no width.");
bool isStar = width == "*";
bool isPositive = double.TryParse(
width,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out double parsed)
&& parsed > 0d;
Assert.True(isStar || isPositive, $"{file}: <column width=\"{width}\"> at {index}.");
}
}
}
[Fact]
public void TheFlagTrackerTabsAreTheOriginalPagesPlusWeapons()
{
XElement root = LoadRoot(FlagTrackerFeature.MarkupFile);
string[] tabs = [.. root.Elements("tab").Select(tab => (string?)tab.Attribute("text") ?? string.Empty)];
Assert.Equal(["Augs", "Lum", "Recalls", "Cantrips", "Weapons", "Quests"], tabs);
Assert.Equal(6, root.Elements("group").Count());
Assert.Equal("Mossy Tracker", (string?)root.Attribute("title"));
}
private static void AssertElementBindings(string file, XElement element, IReadOnlyDictionary<string, PropertyInfo> byName)
{
if (element.Name.LocalName == "column")
{
AssertBindingType(file, element, "onchange", typeof(Action<int>), byName);
AssertBindingType(file, element, "onclick", typeof(Action<int>), byName);
AssertListBinding(file, element, "items", typeof(string), byName);
AssertListBinding(file, element, "colors", typeof(uint), byName);
switch ((string?)element.Attribute("type"))
{
case "check":
AssertListBinding(file, element, "values", typeof(bool), byName);
break;
case "icon":
AssertListBinding(file, element, "values", typeof(uint), byName);
break;
}
return;
}
AssertBindingType(file, element, "onclick", typeof(Action), byName);
AssertBindingType(file, element, "onsubmit", typeof(Action<string>), byName);
AssertBindingType(file, element, "visible", typeof(bool), byName);
AssertBindingType(file, element, "enabled", typeof(bool), byName);
AssertBindingType(file, element, "checked", typeof(bool), byName);
Type? selectedType = element.Name.LocalName switch
{
"tab" => typeof(bool),
"list" => typeof(int),
"menu" => null,
_ => null,
};
AssertBindingType(file, element, "selected", selectedType, byName);
Type? changeType = element.Name.LocalName switch
{
"field" or "menu" => typeof(Action<string>),
"slider" => typeof(Action<float>),
"list" => typeof(Action<int>),
_ => null,
};
AssertBindingType(file, element, "onchange", changeType, byName);
if (element.Name.LocalName is "list" or "menu")
AssertListBinding(file, element, "items", typeof(string), byName);
}
private static void AssertBindingType(
string file,
XElement element,
string attributeName,
Type? expected,
IReadOnlyDictionary<string, PropertyInfo> byName)
{
if (expected is null)
return;
string? value = (string?)element.Attribute(attributeName);
if (value is null || !value.StartsWith('{'))
return;
PropertyInfo property = byName[value[1..^1]];
Assert.True(
expected.IsAssignableFrom(property.PropertyType),
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {property.PropertyType.Name}, expected {expected.Name}.");
}
private static void AssertListBinding(
string file,
XElement element,
string attributeName,
Type elementType,
IReadOnlyDictionary<string, PropertyInfo> byName)
{
string? value = (string?)element.Attribute(attributeName);
if (value is null || !value.StartsWith('{'))
return;
Type type = byName[value[1..^1]].PropertyType;
Assert.True(
typeof(IReadOnlyList<>).MakeGenericType(elementType).IsAssignableFrom(type),
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {type.Name}, expected IReadOnlyList<{elementType.Name}>.");
}
private static XElement LoadRoot(string file)
{
XDocument document = XDocument.Load(Path.Combine(AppContext.BaseDirectory, file));
return Assert.IsType<XElement>(document.Root);
}
private static IReadOnlyDictionary<string, PropertyInfo> PropertiesOf(Type type) =>
type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.ToDictionary(static property => property.Name, StringComparer.Ordinal);
}