diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index 448bcf0..d8b0610 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -1,4 +1,5 @@
using OpenAC.MosswartMassacre.Backend;
+using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre;
@@ -16,5 +17,6 @@ internal static class FeatureCatalog
// Quests, flag tracker, meta sync.
var quests = new QuestManager(context);
features.Add(quests);
+ features.Add(new FlagTrackerFeature(context, quests));
}
}
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/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/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
index c23551a..6dc3138 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
@@ -446,6 +446,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..513b766
--- /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);
+ 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/Ui/TrackerAndMetasMarkupContractTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
new file mode 100644
index 0000000..52f7949
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
@@ -0,0 +1,220 @@
+using System.Reflection;
+using System.Xml.Linq;
+using OpenAC.MosswartMassacre.FlagTracker;
+
+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) },
+ };
+
+ [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);
+}