diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index 35a62fa..bb63c5d 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -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;
}
}
diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs
new file mode 100644
index 0000000..5c50555
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs
@@ -0,0 +1,672 @@
+using AcDream.Plugin.Abstractions;
+
+namespace OpenAC.MosswartMassacre.FlagTracker;
+
+///
+/// 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.
+///
+///
+/// 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.
+///
+internal sealed class FlagTrackerData
+{
+ /// Row text colours, 0xRRGGBB, the named colours the original used.
+ 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;
+ }
+
+ /// Icons the original drew: the cantrip lamps, the category star and the portal fallback.
+ 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; }
+
+ /// The seer's loyalty flag, for the seer auras only.
+ 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; }
+
+ /// "Minor" through "Legendary", or "N/A" when the character has none.
+ public string Value { get; set; } = NotAvailable;
+
+ public uint Color { get; set; } = Colors.White;
+
+ /// The green lamp when the cantrip is up, the red one when it is not.
+ 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 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 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> AugmentationCategories { get; }
+
+ public Dictionary> LuminanceAuraCategories { get; }
+
+ public List RecallSpells { get; }
+
+ public Dictionary> Cantrips { get; }
+
+ public Dictionary> 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 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 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 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 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 inventory = _automation.Items.CaptureOwnedItems();
+ foreach (List 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;
+ }
+ }
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 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> 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> 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 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> 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> 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 },
+ ],
+ };
+}
diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
new file mode 100644
index 0000000..4087620
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
@@ -0,0 +1,159 @@
+using AcDream.Plugin.Abstractions;
+using OpenAC.MosswartMassacre.Quests;
+
+namespace OpenAC.MosswartMassacre.FlagTracker;
+
+///
+/// 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.
+///
+///
+/// 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.
+///
+internal sealed class FlagTrackerFeature : IMmFeature
+{
+ /// The panel's markup file, beside the plugin assembly.
+ public const string MarkupFile = "mosswartmassacre-flagtracker.xml";
+
+ /// How often the quest countdowns are redrawn.
+ 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);
+ }
+
+ ///
+ /// The window's binding object; set its IsVisible to open the
+ /// window. Null on a host without a window.
+ ///
+ 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();
+ }
+
+ /// Reads every page again and redraws it.
+ 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}");
+ }
+ }
+}
diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs
new file mode 100644
index 0000000..6991390
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs
@@ -0,0 +1,319 @@
+using System.Globalization;
+using OpenAC.MosswartMassacre.Quests;
+
+namespace OpenAC.MosswartMassacre.FlagTracker;
+
+///
+/// The binding object of the "Mossy Tracker" window
+/// (mosswartmassacre-flagtracker.xml). 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.
+///
+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));
+ }
+
+ /// A refresh button was pressed; the argument is the button's binding name.
+ internal event Action? RefreshRequested;
+
+ ///
+ /// 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.
+ ///
+ 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; }
+
+ /// Lists here are read-only views; a click selects nothing.
+ public int NoRow => -1;
+
+ public Action IgnoreRow { get; }
+
+ // ---- Augs --------------------------------------------------------------
+
+ public IReadOnlyList AugNames { get; private set; } = [];
+ public IReadOnlyList AugProgress { get; private set; } = [];
+ public IReadOnlyList AugProgressColors { get; private set; } = [];
+ public IReadOnlyList AugTrainers { get; private set; } = [];
+ public IReadOnlyList AugLocations { get; private set; } = [];
+
+ // ---- Lum ---------------------------------------------------------------
+
+ public IReadOnlyList LumNames { get; private set; } = [];
+ public IReadOnlyList LumProgress { get; private set; } = [];
+ public IReadOnlyList LumProgressColors { get; private set; } = [];
+ public IReadOnlyList LumCategories { get; private set; } = [];
+
+ // ---- Recalls -----------------------------------------------------------
+
+ public IReadOnlyList RecallSpellIds { get; private set; } = [];
+ public IReadOnlyList RecallNames { get; private set; } = [];
+ public IReadOnlyList RecallStatus { get; private set; } = [];
+ public IReadOnlyList RecallStatusColors { get; private set; } = [];
+
+ // ---- Cantrips ----------------------------------------------------------
+
+ public IReadOnlyList CantripIcons { get; private set; } = [];
+ public IReadOnlyList CantripNames { get; private set; } = [];
+ public IReadOnlyList CantripLevels { get; private set; } = [];
+ public IReadOnlyList CantripLevelColors { get; private set; } = [];
+
+ // ---- Weapons -----------------------------------------------------------
+
+ public IReadOnlyList WeaponIcons { get; private set; } = [];
+ public IReadOnlyList WeaponNames { get; private set; } = [];
+ public IReadOnlyList WeaponStatus { get; private set; } = [];
+ public IReadOnlyList WeaponStatusColors { get; private set; } = [];
+
+ // ---- Quests ------------------------------------------------------------
+
+ public IReadOnlyList QuestRowNames { get; private set; } = [];
+ public IReadOnlyList QuestCountdowns { get; private set; } = [];
+ public IReadOnlyList QuestCountdownColors { get; private set; } = [];
+ public IReadOnlyList QuestLastSolved { get; private set; } = [];
+ public IReadOnlyList QuestCooldowns { get; private set; } = [];
+ public IReadOnlyList QuestSolves { get; private set; } = [];
+ public IReadOnlyList QuestSolvesColors { get; private set; } = [];
+
+ /// Rebuilds every page but the quests from the tracker's data.
+ public void ShowData(FlagTrackerData data)
+ {
+ ShowAugmentations(data);
+ ShowLuminance(data);
+ ShowRecallSpells(data);
+ ShowCantripRows(data);
+ ShowWeaponRows(data);
+ }
+
+ public void ShowAugmentations(FlagTrackerData data)
+ {
+ var names = new List();
+ var progress = new List();
+ var colors = new List();
+ var trainers = new List();
+ var locations = new List();
+ foreach ((string category, List 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();
+ var progress = new List();
+ var colors = new List();
+ var categories = new List();
+ foreach ((string category, List 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();
+ var names = new List();
+ var levels = new List();
+ var colors = new List();
+ foreach ((string category, Dictionary 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();
+ var names = new List();
+ var status = new List();
+ var colors = new List();
+ foreach ((string category, List 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public void ShowQuestRows(IReadOnlyList quests, long unixNow)
+ {
+ var names = new List();
+ var countdowns = new List();
+ var countdownColors = new List();
+ var lastSolved = new List();
+ var cooldowns = new List();
+ var solves = new List();
+ var solvesColors = new List();
+
+ IEnumerable 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;
+ }
+
+ /// Empties every page, at logoff.
+ 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);
+ }
+}
diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
new file mode 100644
index 0000000..0fd528c
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
@@ -0,0 +1,337 @@
+using System.Collections.Concurrent;
+using AcDream.Plugin.Abstractions;
+
+namespace OpenAC.MosswartMassacre.Metas;
+
+///
+/// Meta and nav sync from the club's repository: the /mm metasrefresh,
+/// metascheck, metaspull and metaspullfile verbs and, on a
+/// host with a window, the Metas window. Nothing here reaches the backend.
+///
+///
+/// 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.
+///
+internal sealed class MetaSyncFeature : IMmFeature
+{
+ /// The panel's markup file, beside the plugin assembly.
+ public const string MarkupFile = "mosswartmassacre-metas.xml";
+
+ private readonly MmContext _context;
+ private readonly MetaSyncManager _manager;
+ private readonly ConcurrentQueue _onTick = new();
+ private readonly CancellationTokenSource _shutdown = new();
+ private readonly Action _tick;
+ private readonly IDisposable? _panel;
+ private Task _lastOperation = Task.CompletedTask;
+ private bool _disposed;
+
+ public MetaSyncFeature(MmContext context)
+ : this(context, context.Host.VtankProfiles, handler: null)
+ {
+ }
+
+ /// Builds the feature on a given profile storage and HTTP handler; tests pass fakes.
+ 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);
+ }
+
+ ///
+ /// The window's binding object; set its IsVisible to open the
+ /// window. Null on a host without a window.
+ ///
+ public MetasViewModel? Panel { get; }
+
+ internal MetaSyncManager Manager => _manager;
+
+ /// The operation started last; tests await it before pumping the tick.
+ internal Task LastOperation => _lastOperation;
+
+ /// The repository setting, or the original's repository when it is empty.
+ 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);
+ }
+
+ /// Applies finished work on the tick thread. Internal so tests can pump without a host tick.
+ 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? done = null, Action? 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? 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? 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 ");
+ 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);
+ }
+
+ /// Runs on the thread pool and applies its outcome on the next tick.
+ private void Start(Func> work, Action done, Action 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);
+ }
+}
diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
new file mode 100644
index 0000000..c84a798
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
@@ -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,
+}
+
+/// One .met or .nav file of the repository and how the local copy compares.
+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;
+
+ /// The storage key the file is kept under, or empty when there is no storage.
+ 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;
+}
+
+///
+/// Keeps the shared automation profiles in step with the metas repository on
+/// the club's git server: lists its .met and .nav 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 .bak.
+///
+///
+/// The endpoints are the original's: the tree comes from
+/// {server}/api/v1/repos/{owner}/{repo}/git/trees/main?recursive=1 and each
+/// file from {repository}/raw/branch/main/{path}. The repository address is a
+/// setting that defaults to the original's repository.
+/// 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 mosstank/metas, navs under mosstank/navs.
+/// 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.
+/// 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.
+///
+internal sealed class MetaSyncManager : IDisposable
+{
+ public const string DefaultRepositoryUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas";
+ public const string BranchName = "main";
+
+ /// Where the automation plugin looks for dropped metas.
+ public const string MetaFolder = "mosstank/metas";
+
+ /// Where the automation plugin looks for dropped navs.
+ public const string NavFolder = "mosstank/navs";
+
+ private readonly IPluginStorage _storage;
+ private readonly HttpClient _http;
+ private readonly Func _repositoryUrl;
+ private readonly Func _utcNow;
+ private readonly Action _notice;
+ private readonly object _sync = new();
+ private readonly SemaphoreSlim _oneAtATime = new(1, 1);
+ private List _cachedFiles = [];
+
+ public MetaSyncManager(
+ IPluginStorage storage,
+ HttpMessageHandler? handler,
+ Func repositoryUrl,
+ Func utcNow,
+ Action 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;
+
+ /// The storage root the files are written beneath, for the path label; null without storage.
+ public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
+
+ public IReadOnlyList GetCachedFiles()
+ {
+ lock (_sync)
+ return [.. _cachedFiles.Select(static entry => entry.Clone())];
+ }
+
+ /// The API and raw-file addresses for a repository address.
+ 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}");
+ }
+
+ /// The storage key a repository file is kept under, or null for a file that is neither a meta nor a nav.
+ 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 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 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 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 current = CloneCache();
+ if (current.Count == 0)
+ {
+ await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
+ current = CloneCache();
+ }
+
+ List 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 RefreshCatalogCoreAsync(CancellationToken cancellationToken)
+ {
+ List remoteEntries = await FetchRemoteTreeEntriesAsync(cancellationToken).ConfigureAwait(false);
+ HashSet localKeys = ListLocalKeys();
+
+ List 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 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> 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 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 ListLocalKeys()
+ {
+ var keys = new HashSet(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 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";
+ }
+ }
+}
diff --git a/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
new file mode 100644
index 0000000..a72beea
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
@@ -0,0 +1,172 @@
+using System.Globalization;
+
+namespace OpenAC.MosswartMassacre.Metas;
+
+///
+/// The binding object of the Metas window (mosswartmassacre-metas.xml),
+/// 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.
+///
+internal sealed class MetasViewModel
+{
+ private readonly Dictionary _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));
+ }
+
+ /// A button was pressed; the argument is its binding name.
+ internal event Action? ButtonPressed;
+
+ /// The search text changed or was cleared.
+ internal event Action? SearchRequested;
+
+ ///
+ /// 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.
+ ///
+ 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 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 IgnoreRow { get; }
+
+ public IReadOnlyList FileTypes { get; private set; } = [];
+
+ public IReadOnlyList FilePaths { get; private set; } = [];
+
+ public IReadOnlyList FileStatus { get; private set; } = [];
+
+ public IReadOnlyList FileStatusColors { get; private set; } = [];
+
+ public IReadOnlyList SelectionItems { get; private set; } = [];
+
+ public string SelectedLabel { get; set; } = string.Empty;
+
+ public Action SelectionChanged { get; }
+
+ /// The repository path of the file picked in the list, or empty.
+ public string SelectedRelativePath =>
+ _pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty;
+
+ ///
+ /// 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.
+ ///
+ public void ShowFiles(IReadOnlyList all)
+ {
+ IReadOnlyList files = ApplySearch(all);
+
+ List 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();
+ 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}";
+
+ /// The status text colour: green current, orange outdated, blue missing, red failed.
+ public static uint StatusColor(MetaFileSyncStatus status) => status switch
+ {
+ MetaFileSyncStatus.UpToDate => 0x008000u,
+ MetaFileSyncStatus.UpdateAvailable => 0xB46E00u,
+ MetaFileSyncStatus.NotDownloaded => 0x0046A0u,
+ MetaFileSyncStatus.Error => 0xB40000u,
+ _ => 0xFFFFFFu,
+ };
+
+ private IReadOnlyList ApplySearch(IReadOnlyList 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))];
+ }
+}
diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs b/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs
new file mode 100644
index 0000000..0a3546a
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs
@@ -0,0 +1,325 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using AcDream.Plugin.Abstractions;
+
+namespace OpenAC.MosswartMassacre.Quests;
+
+/// One line of the server's /myquests answer.
+internal sealed class Quest
+{
+ public string Id { get; init; } = string.Empty;
+
+ public int Solves { get; init; }
+
+ /// When the quest was last solved, in Unix seconds.
+ public int Timestamp { get; init; }
+
+ public string Description { get; init; } = string.Empty;
+
+ public int MaxSolves { get; init; }
+
+ /// The quest's cooldown in seconds.
+ public int Delta { get; init; }
+
+ /// When the quest can be done again, in Unix seconds.
+ public int ExpireTime { get; init; }
+}
+
+///
+/// The character's quest timers, read the way the original read them: ask the
+/// server with /myquests and, for the next three seconds, parse every
+/// chat line that looks like one of its answer lines. Every thirty seconds the
+/// timers of the priority quests go to the backend as quest frames.
+///
+///
+/// The answer lines stay in the chat window; the original never hid them.
+/// Everything here runs on the tick thread: the chat event, the three-second
+/// window and the stream are all driven by the host's tick.
+///
+internal sealed class QuestManager : IMmFeature
+{
+ /// How long after asking the chat lines are read as answers.
+ public static readonly TimeSpan RefreshWindow = TimeSpan.FromSeconds(3);
+
+ /// How often the priority quests are streamed.
+ public static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(30);
+
+ // One answer line: "stamp - N solves (lastSolved)"message" maxSolves delta".
+ private static readonly Regex QuestLine = new(
+ @"([^\-]+) - (\d+) solves \((\d+)\)""([^""]+)"" (-?\d+) (\d+)",
+ RegexOptions.CultureInvariant);
+
+ private readonly MmContext _context;
+ private readonly List _quests = [];
+ private readonly Dictionary _questsById = new(StringComparer.Ordinal);
+ private readonly Action _onChat;
+ private IDisposable? _refreshWindow;
+ private IDisposable? _stream;
+ private bool _subscribed;
+
+ public QuestManager(MmContext context)
+ {
+ _context = context;
+ _onChat = OnChat;
+ context.Commands.Register("refreshquests", HandleRefreshQuests, "Refresh quest data");
+ context.Commands.Register("queststatus", HandleQuestStatus, "Show quest streaming status");
+ }
+
+ /// Raised on the tick thread whenever the quest list changes.
+ public event Action? QuestsChanged;
+
+ /// Every parsed answer line, in the order the server sent them.
+ public IReadOnlyList QuestList => _quests;
+
+ /// True while the answer to a /myquests is being read.
+ public bool IsRefreshing { get; private set; }
+
+ /// True while the thirty-second stream is scheduled.
+ public bool IsStreaming => _stream is not null;
+
+ public void OnLogin()
+ {
+ if (!_subscribed)
+ {
+ _context.Automation.Chat.Received += _onChat;
+ _subscribed = true;
+ }
+ ClearQuests();
+ RefreshQuests();
+ _stream?.Dispose();
+ _stream = _context.Scheduler.Every(StreamInterval, StreamPriorityQuests);
+ }
+
+ public void OnLogoff()
+ {
+ _stream?.Dispose();
+ _stream = null;
+ _refreshWindow?.Dispose();
+ _refreshWindow = null;
+ IsRefreshing = false;
+ if (_subscribed)
+ {
+ _context.Automation.Chat.Received -= _onChat;
+ _subscribed = false;
+ }
+ ClearQuests();
+ }
+
+ public void Dispose() => OnLogoff();
+
+ ///
+ /// Asks the server for the quest list. A request while the previous answer
+ /// is still being read is ignored, as it was in the original.
+ ///
+ public void RefreshQuests()
+ {
+ try
+ {
+ if (IsRefreshing)
+ return;
+
+ ClearQuests();
+ IsRefreshing = true;
+ _context.Automation.Chat.Submit("/myquests");
+ _refreshWindow = _context.Scheduler.Delay(RefreshWindow, () =>
+ {
+ IsRefreshing = false;
+ _refreshWindow = null;
+ QuestsChanged?.Invoke();
+ });
+ }
+ catch (Exception ex)
+ {
+ IsRefreshing = false;
+ _context.Chat.Write($"Error refreshing quests: {ex.Message}");
+ }
+ }
+
+ public void ClearQuests()
+ {
+ bool had = _quests.Count > 0;
+ _quests.Clear();
+ _questsById.Clear();
+ if (had)
+ QuestsChanged?.Invoke();
+ }
+
+ public bool HasQuestFlag(string questStamp) => _questsById.ContainsKey(questStamp);
+
+ public bool TryGetQuest(string questStamp, out Quest quest) =>
+ _questsById.TryGetValue(questStamp, out quest!);
+
+ /// The priority quests, first answer line per stamp, in answer order.
+ public IReadOnlyList PriorityQuests() =>
+ [.. _quests
+ .Where(quest => QuestStreamingService.IsHighPriorityQuest(quest.Id))
+ .GroupBy(quest => quest.Id)
+ .Select(group => group.First())];
+
+ /// Parses one chat line; a line that is not an answer line is ignored.
+ internal void ParseQuestLine(string text)
+ {
+ try
+ {
+ Match match = QuestLine.Match(text);
+ if (!match.Success)
+ return;
+
+ int timestamp = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
+ int delta = int.Parse(match.Groups[6].Value, CultureInfo.InvariantCulture);
+ var quest = new Quest
+ {
+ Id = match.Groups[1].Value.Trim(),
+ Solves = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture),
+ Timestamp = timestamp,
+ Description = match.Groups[4].Value,
+ MaxSolves = int.Parse(match.Groups[5].Value, CultureInfo.InvariantCulture),
+ Delta = delta,
+ // Unchecked, as the original's int sum was.
+ ExpireTime = unchecked(timestamp + delta),
+ };
+
+ _quests.Add(quest);
+ _questsById[quest.Id] = quest;
+ QuestsChanged?.Invoke();
+ }
+ catch (Exception ex)
+ {
+ _context.Chat.Write($"Error parsing quest line '{text}': {ex.Message}");
+ }
+ }
+
+ /// The now the countdowns are measured from, in Unix seconds.
+ internal long UnixNow => new DateTimeOffset(DateTime.SpecifyKind(_context.Clock.UtcNow, DateTimeKind.Utc))
+ .ToUnixTimeSeconds();
+
+ /// One run of the thirty-second stream. Internal so tests can run it directly.
+ internal void StreamPriorityQuests()
+ {
+ try
+ {
+ _context.Verbose("[QUEST-STREAM] Timer fired, checking conditions...");
+
+ if (!_context.Settings.Current.WebSocketEnabled)
+ {
+ _context.Verbose("[QUEST-STREAM] WebSocket not enabled, skipping");
+ return;
+ }
+
+ if (_quests.Count == 0)
+ {
+ _context.Verbose($"[QUEST-STREAM] No quest data available (null: False, count: {_quests.Count})");
+ return;
+ }
+
+ long currentTime = UnixNow;
+ IReadOnlyList priorityQuests = PriorityQuests();
+ _context.Verbose($"[QUEST-STREAM] Found {priorityQuests.Count} priority quests to stream");
+
+ foreach (Quest quest in priorityQuests)
+ {
+ try
+ {
+ string questName = QuestNames.GetFriendlyName(quest.Id);
+ string countdown = QuestStreamingService.FormatCountdown(quest.ExpireTime - currentTime);
+ _context.Verbose($"[QUEST-STREAM] Sending: {questName} - {countdown}");
+ _context.Send(QuestStreamingService.BuildFrame(_context, questName, countdown));
+ }
+ catch (Exception ex)
+ {
+ _context.Chat.Write($"[QUEST-STREAM] Error streaming quest {quest.Id}: {ex.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _context.Chat.Write($"[QUEST-STREAM] Error in timer handler: {ex.Message}");
+ }
+ }
+
+ /// Last solved, as the flag tracker shows it: UTC, month first.
+ public static string FormatTimeStamp(int timestamp)
+ {
+ try
+ {
+ DateTime dateTime = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
+ return dateTime.ToString("MM/dd/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return "Invalid";
+ }
+ }
+
+ /// A duration as "1d 2h 3m 4s", leaving out the zero parts.
+ public static string FormatSeconds(int seconds)
+ {
+ if (seconds <= 0)
+ return "0s";
+
+ int days = seconds / 86400;
+ seconds %= 86400;
+ int hours = seconds / 3600;
+ seconds %= 3600;
+ int minutes = seconds / 60;
+ seconds %= 60;
+
+ string result = string.Empty;
+ if (days > 0)
+ result += string.Create(CultureInfo.InvariantCulture, $"{days}d ");
+ if (hours > 0)
+ result += string.Create(CultureInfo.InvariantCulture, $"{hours}h ");
+ if (minutes > 0)
+ result += string.Create(CultureInfo.InvariantCulture, $"{minutes}m ");
+ if (seconds > 0 || string.IsNullOrEmpty(result))
+ result += string.Create(CultureInfo.InvariantCulture, $"{seconds}s");
+
+ return result.Trim();
+ }
+
+ private void OnChat(PluginChatMessage message)
+ {
+ if (!IsRefreshing || string.IsNullOrEmpty(message.Text))
+ return;
+ ParseQuestLine(message.Text);
+ }
+
+ private void HandleRefreshQuests(string[] args)
+ {
+ _ = args;
+ try
+ {
+ _context.Chat.Write("[QUEST] Refreshing quest data...");
+ RefreshQuests();
+ }
+ catch (Exception ex)
+ {
+ _context.Chat.Write($"[QUEST] Refresh failed: {ex.Message}");
+ }
+ }
+
+ private void HandleQuestStatus(string[] args)
+ {
+ _ = args;
+ try
+ {
+ _context.Chat.Write("=== Quest Streaming Status ===");
+ _context.Chat.Write($"Timer Active: {IsStreaming}");
+ _context.Chat.Write($"WebSocket Enabled: {_context.Settings.Current.WebSocketEnabled}");
+ _context.Chat.Write($"Quest Manager: {(_context.IsLoggedIn ? "Active" : "Not Active")}");
+ _context.Chat.Write($"Quest Count: {_quests.Count}");
+
+ IReadOnlyList priorityQuests = PriorityQuests();
+ _context.Chat.Write($"Priority Quests Found: {priorityQuests.Count}");
+ foreach (Quest quest in priorityQuests)
+ _context.Chat.Write($" - {QuestNames.GetFriendlyName(quest.Id)} ({quest.Id})");
+
+ _context.Chat.Write($"Verbose Logging: {_context.Settings.Current.VerboseLogging}");
+ _context.Chat.Write("Use '/mm verbose' to toggle debug logging");
+ }
+ catch (Exception ex)
+ {
+ _context.Chat.Write($"[QUEST] Status check failed: {ex.Message}");
+ }
+ }
+}
diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs b/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs
new file mode 100644
index 0000000..184871a
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs
@@ -0,0 +1,211 @@
+namespace OpenAC.MosswartMassacre.Quests;
+
+///
+/// Friendly names for quest stamps, the table the original plugin shipped.
+/// The friendly name is what the backend's quest frame carries as
+/// quest_name, so the table is copied as it was, including the stamp
+/// listed twice (insatiableeaterjaw): the later entry wins, exactly as
+/// the original's indexer initializer made it win.
+///
+internal static class QuestNames
+{
+ public static readonly Dictionary 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"
+ };
+
+ /// The friendly name for a stamp, or the stamp itself when the table has none.
+ 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;
+ }
+
+ /// The friendly name with the stamp in parentheses, or the stamp alone.
+ 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;
+ }
+}
diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs b/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs
new file mode 100644
index 0000000..0a2bcb7
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs
@@ -0,0 +1,70 @@
+using System.Globalization;
+using Newtonsoft.Json;
+using OpenAC.MosswartMassacre.Backend;
+
+namespace OpenAC.MosswartMassacre.Quests;
+
+///
+/// The quest frame: type, timestamp, character_name,
+/// quest_name, countdown, 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.
+///
+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;
+}
+
+/// Which quests stream, how their countdown is written, and the frame they go in.
+internal static class QuestStreamingService
+{
+ /// The three stamps the original streamed, and only those.
+ public static bool IsHighPriorityQuest(string questId) =>
+ questId == "stipendtimer_0812"
+ || questId == "augmentationblankgemacquired"
+ || questId == "insatiableeaterjaw";
+
+ ///
+ /// The countdown text: READY at or past zero, then the two largest
+ /// units, the smaller one zero-padded: 1d 02h, 3h 05m,
+ /// 4m 09s, 59s. The backend shows this string as it is.
+ ///
+ 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,
+ };
+}
diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml
new file mode 100644
index 0000000..1e731de
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
new file mode 100644
index 0000000..8acbffd
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs
index 6d188b3..0e42c97 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs
@@ -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();
harness.Session.RadarToggle = on => toggles.Add(on);
@@ -68,6 +76,8 @@ public sealed class BackendSessionTests
var seen = new List();
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));
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
index bfe98d1..83250bc 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
@@ -452,6 +452,11 @@ public sealed class FakeSpellCatalog : ISpellCatalog
/// The full spell table, independent of what the character knows.
public IReadOnlyList All { get; set; } = [];
+ /// The spells the character knows, for .
+ public HashSet KnownSpellIds { get; } = [];
+
+ public bool IsKnown(uint spellId) => KnownSpellIds.Contains(spellId);
+
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
foreach (PluginSpellInfo spell in KnownSelfBuffs
diff --git a/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs b/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
new file mode 100644
index 0000000..2f1664c
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
@@ -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? arrange = null) =>
+ new(arrange: host =>
+ {
+ host.HasUi = true;
+ arrange?.Invoke(host);
+ });
+
+ private static void SetPlayerInts(FakeHost host, Dictionary ints) =>
+ host.Automation.Objects.Properties[Player] = new PluginItemProperties(
+ ints,
+ new Dictionary(),
+ new Dictionary(),
+ new Dictionary(),
+ new Dictionary(),
+ new Dictionary(),
+ new Dictionary());
+
+ private static FlagTrackerViewModel Panel(PluginHarness harness) =>
+ harness.Feature().Panel!;
+
+ private static int Row(IReadOnlyList 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();
+ 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);
+ }
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
new file mode 100644
index 0000000..6de3e3b
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
@@ -0,0 +1,361 @@
+using System.Net;
+using System.Text;
+using OpenAC.MosswartMassacre.Metas;
+using OpenAC.MosswartMassacre.Tests.Fakes;
+
+namespace OpenAC.MosswartMassacre.Tests.Metas;
+
+/// Answers requests from a table of URLs and records what was asked; no network.
+internal sealed class FakeGitServer : HttpMessageHandler
+{
+ public Dictionary Responses { get; } = new(StringComparer.Ordinal);
+
+ public List 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 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 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();
+ 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 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 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 ",
+ "[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().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));
+ }
+ }
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
new file mode 100644
index 0000000..17e576f
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
@@ -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();
+
+ 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().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().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().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().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().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;
+ }
+ }
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs
index c8152b3..68fe572 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs
@@ -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;
}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
new file mode 100644
index 0000000..93c57ea
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
@@ -0,0 +1,222 @@
+using System.Reflection;
+using System.Xml.Linq;
+using OpenAC.MosswartMassacre.FlagTracker;
+using OpenAC.MosswartMassacre.Metas;
+
+namespace OpenAC.MosswartMassacre.Tests.Ui;
+
+///
+/// Proves every {Binding} 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.
+///
+public sealed class TrackerAndMetasMarkupContractTests
+{
+ private static readonly string[] InteractiveElementNames =
+ [
+ "tab", "button", "toggle", "slider", "field", "menu", "list", "column",
+ ];
+
+ public static TheoryData 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 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 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 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}: {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}: 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 byName)
+ {
+ if (element.Name.LocalName == "column")
+ {
+ AssertBindingType(file, element, "onchange", typeof(Action), byName);
+ AssertBindingType(file, element, "onclick", typeof(Action), 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), 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),
+ "slider" => typeof(Action),
+ "list" => typeof(Action),
+ _ => 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 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 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(document.Root);
+ }
+
+ private static IReadOnlyDictionary PropertiesOf(Type type) =>
+ type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .ToDictionary(static property => property.Name, StringComparer.Ordinal);
+}