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 @@ + + + + + + + + + + + + + +