diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 35a62fa..bb63c5d 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -1,5 +1,8 @@ using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.FlagTracker; using OpenAC.MosswartMassacre.Infrastructure; +using OpenAC.MosswartMassacre.Metas; +using OpenAC.MosswartMassacre.Quests; using OpenAC.MosswartMassacre.Streams; using OpenAC.MosswartMassacre.Trackers; @@ -27,7 +30,8 @@ internal static class FeatureCatalog features.Add(new VitalsStream(context)); features.Add(new SpawnPortalStream(context)); features.Add(new BotControl(context)); - features.Add(new Views.MainWindow(context, stats)); + var mainWindow = new Views.MainWindow(context, stats); + features.Add(mainWindow); session.AddConnectionListener(telemetry); // Cross-computer vital and debuff sharing. @@ -47,5 +51,17 @@ internal static class FeatureCatalog else radar.Stop(); }; + + // Quests, flag tracker, meta sync. + var quests = new QuestManager(context); + features.Add(quests); + var flagTracker = new FlagTrackerFeature(context, quests); + features.Add(flagTracker); + var metas = new MetaSyncFeature(context); + features.Add(metas); + if (flagTracker.Panel is { } trackerPanel) + mainWindow.FlagTrackerOpener = () => trackerPanel.IsVisible = true; + if (metas.Panel is { } metasPanel) + mainWindow.MetasOpener = () => metasPanel.IsVisible = true; } } diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs new file mode 100644 index 0000000..5c50555 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs @@ -0,0 +1,672 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.FlagTracker; + +/// +/// What the flag tracker shows, and how each tab reads it from the character: +/// augmentation and luminance counts from the player's own integer properties, +/// recalls from the spellbook, cantrips from the active enchantments matched +/// against the trained skills, and the tracked weapons from the inventory by +/// name. The tables are the original plugin's, copied as they were. +/// +/// +/// The original's society, facility hub and character flag sections were +/// never implemented there and are not here. Every read happens on the tick +/// thread, when a tab is refreshed. +/// +internal sealed class FlagTrackerData +{ + /// Row text colours, 0xRRGGBB, the named colours the original used. + public static class Colors + { + public const uint White = 0xFFFFFF; + public const uint Red = 0xFF0000; + public const uint Green = 0x008000; + public const uint Yellow = 0xFFFF00; + public const uint Blue = 0x0000FF; + public const uint Purple = 0x800080; + public const uint Orange = 0xFFA500; + } + + /// Icons the original drew: the cantrip lamps, the category star and the portal fallback. + public static class Icons + { + public const uint GreenCircle = 0x060011F9; + public const uint RedCircle = 0x060011F8; + public const uint CategoryStar = 0x06002856; + public const uint Portal = 0x06002D14; + } + + public const string NotAvailable = "N/A"; + + public sealed class AugmentationInfo + { + public required string Name { get; init; } + public uint? IntId { get; init; } + public int Repeatable { get; init; } + public string Trainer { get; init; } = string.Empty; + public string Location { get; init; } = string.Empty; + public int CurrentValue { get; set; } + public bool IsMaxed => CurrentValue >= Repeatable; + } + + public sealed class LuminanceAuraInfo + { + public required string Name { get; init; } + public uint IntId { get; init; } + public int Cap { get; init; } + + /// The seer's loyalty flag, for the seer auras only. + public string? QuestFlag { get; init; } + + public int CurrentValue { get; set; } + } + + public sealed class RecallSpellInfo + { + public required string Name { get; init; } + public uint SpellId { get; init; } + public string Category { get; init; } = string.Empty; + public bool IsKnown { get; set; } + } + + public sealed class CantripInfo + { + public required string Name { get; init; } + + /// "Minor" through "Legendary", or "N/A" when the character has none. + public string Value { get; set; } = NotAvailable; + + public uint Color { get; set; } = Colors.White; + + /// The green lamp when the cantrip is up, the red one when it is not. + public uint ComputedIconId { get; set; } + } + + public sealed class TrackedWeaponInfo + { + public required string Name { get; init; } + public string Category { get; init; } = string.Empty; + public string WeaponType { get; init; } = string.Empty; + public int ItemId { get; init; } + public string Status { get; set; } = "Unknown"; + public bool IsAcquired { get; set; } + public uint IconId { get; set; } + } + + // Cantrip skill names that differ from the skill's own name. + private static readonly Dictionary SkillCantripReplacements = new() + { + [15] = "MagicResistance", + [6] = "Invulnerability", + [7] = "Impgrenability", + [47] = "MissileWeapon", + [44] = "HeavyWeapon", + [45] = "LightWeapon", + [46] = "FinesseWeapon", + }; + + private static readonly (uint Id, string Name)[] SkillIdToName = + [ + (1, "Axe"), (2, "Bow"), (3, "Crossbow"), (4, "Dagger"), (5, "Mace"), + (6, "Melee Defense"), (7, "Missile Defense"), (8, "Sling"), (9, "Spear"), + (10, "Staff"), (11, "Sword"), (12, "Thrown Weapons"), (13, "Unarmed Combat"), + (14, "Arcane Lore"), (15, "Magic Defense"), (16, "Mana Conversion"), + (17, "Spellcraft"), (18, "Item Tinkering"), (19, "Assess Person"), + (20, "Deception"), (21, "Healing"), (22, "Jump"), (23, "Lockpick"), + (24, "Run"), (25, "Awareness"), (26, "Arms and Armor Repair"), + (27, "Assess Creature"), (28, "Weapon Tinkering"), (29, "Armor Tinkering"), + (30, "Magic Item Tinkering"), (31, "Creature Enchantment"), + (32, "Item Enchantment"), (33, "Life Magic"), (34, "War Magic"), + (35, "Leadership"), (36, "Loyalty"), (37, "Fletching"), (38, "Alchemy"), + (39, "Cooking"), (40, "Salvaging"), (41, "Two Handed Combat"), + (42, "Gearcraft"), (43, "Void Magic"), (44, "Heavy Weapons"), + (45, "Light Weapons"), (46, "Finesse Weapons"), (47, "Missile Weapons"), + (48, "Shield"), (49, "Dual Wield"), (50, "Recklessness"), + (51, "Sneak Attack"), (52, "Dirty Fighting"), (53, "Threat Assessment"), + (54, "Summoning"), + ]; + + private static readonly (string Prefix, uint Color)[] CantripPatterns = + [ + ("Minor", Colors.White), + ("Moderate", Colors.Green), + ("Major", Colors.Blue), + ("Epic", Colors.Purple), + ("Legendary", Colors.Orange), + ]; + + private static readonly (string Key, string Aura)[] ProtectionMappings = + [ + ("Armor", "Armor"), + ("Bludgeoning Ward", "Bludgeoning Ward"), + ("Piercing Ward", "Piercing Ward"), + ("Slashing Ward", "Slashing Ward"), + ("Flame Ward", "Flame Ward"), + ("Frost Ward", "Frost Ward"), + ("Cold Ward", "Frost Ward"), + ("Acid Ward", "Acid Ward"), + ("Storm Ward", "Storm Ward"), + ("Lightning Ward", "Storm Ward"), + ("Bludgeoning Protection", "Bludgeoning Ward"), + ("Piercing Protection", "Piercing Ward"), + ("Slashing Protection", "Slashing Ward"), + ("Fire Protection", "Flame Ward"), + ("Cold Protection", "Frost Ward"), + ("Acid Protection", "Acid Ward"), + ("Lightning Protection", "Storm Ward"), + ("Bludgeoning", "Bludgeoning Ward"), + ("Piercing", "Piercing Ward"), + ("Slashing", "Slashing Ward"), + ("Fire", "Flame Ward"), + ("Flame", "Flame Ward"), + ("Cold", "Frost Ward"), + ("Frost", "Frost Ward"), + ("Acid", "Acid Ward"), + ("Lightning", "Storm Ward"), + ("Storm", "Storm Ward"), + ]; + + private static readonly (string Key, string Attribute)[] AttributeMappings = + [ + ("Strength", "Strength"), + ("Endurance", "Endurance"), + ("Coordination", "Coordination"), + ("Quickness", "Quickness"), + ("Focus", "Focus"), + ("Self", "Willpower"), + ("Willpower", "Willpower"), + ]; + + private static readonly (string Key, string Skill)[] SkillMappings = + [ + ("Magic Resistance", "MagicResistance"), + ("Invulnerability", "Invulnerability"), + ("Impregnability", "Impgrenability"), + ("Heavy Weapon Aptitude", "HeavyWeapon"), + ("Light Weapon Aptitude", "LightWeapon"), + ("Finesse Weapon Aptitude", "FinesseWeapon"), + ("Missile Weapon Aptitude", "MissileWeapon"), + ("Alchemical Prowess", "Alchemy"), + ("Arcane Prowess", "Arcane Lore"), + ("Armor Tinkering Expertise", "Armor Tinkering"), + ("Assess Creature", "Assess Creature"), + ("Assess Person", "Assess Person"), + ("Cooking Prowess", "Cooking"), + ("Deception Prowess", "Deception"), + ("Fletching Prowess", "Fletching"), + ("Healing Prowess", "Healing"), + ("Item Tinkering Expertise", "Item Tinkering"), + ("Leadership", "Leadership"), + ("Lockpick Prowess", "Lockpick"), + ("Fealty", "Loyalty"), + ("Magic Item Tinkering Expertise", "Magic Item Tinkering"), + ("Mana Conversion Prowess", "Mana Conversion"), + ("Jumping Prowess", "Jump"), + ("Salvaging Aptitude", "Salvaging"), + ("Weapon Tinkering Expertise", "Weapon Tinkering"), + ("War Magic Aptitude", "War Magic"), + ("Life Magic Aptitude", "Life Magic"), + ("Creature Enchantment Aptitude", "Creature Enchantment"), + ("Item Enchantment Aptitude", "Item Enchantment"), + ("Void Magic Aptitude", "Void Magic"), + ("Summoning Prowess", "Summoning"), + ("Two Handed Combat Aptitude", "Two Handed Combat"), + ("Dual Wield Aptitude", "Dual Wield"), + ("Shield Aptitude", "Shield"), + ("Sneak Attack Prowess", "Sneak Attack"), + ("Dirty Fighting Prowess", "Dirty Fighting"), + ("Recklessness", "Recklessness"), + ]; + + private static readonly Dictionary CantripRank = new(StringComparer.Ordinal) + { + ["Minor"] = 1, + ["Moderate"] = 2, + ["Major"] = 3, + ["Epic"] = 4, + ["Legendary"] = 5, + }; + + private readonly IAutomationSurface _automation; + + public FlagTrackerData(IAutomationSurface automation) + { + _automation = automation; + AugmentationCategories = BuildAugmentations(); + LuminanceAuraCategories = BuildLuminanceAuras(); + RecallSpells = BuildRecalls(); + Cantrips = BuildCantrips(); + WeaponCategories = BuildWeapons(); + } + + public Dictionary> AugmentationCategories { get; } + + public Dictionary> LuminanceAuraCategories { get; } + + public List RecallSpells { get; } + + public Dictionary> Cantrips { get; } + + public Dictionary> WeaponCategories { get; private set; } + + private bool InWorld => _automation.IsAvailable && _automation.Character.IsInWorld; + + public void RefreshAll() + { + RefreshAugmentations(); + RefreshLuminanceAuras(); + RefreshRecallSpells(); + RefreshCantrips(); + RefreshWeapons(); + } + + public void RefreshAugmentations() + { + if (!InWorld) + return; + foreach (List augmentations in AugmentationCategories.Values) + { + foreach (AugmentationInfo aug in augmentations) + { + // An augmentation with no property (the health one) keeps its value, as it did. + if (aug.IntId is uint id) + aug.CurrentValue = ReadCharacterInt(id); + } + } + } + + public void RefreshLuminanceAuras() + { + if (!InWorld) + return; + foreach (List auras in LuminanceAuraCategories.Values) + { + foreach (LuminanceAuraInfo aura in auras) + aura.CurrentValue = ReadCharacterInt(aura.IntId); + } + } + + public void RefreshRecallSpells() + { + if (!InWorld) + return; + foreach (RecallSpellInfo recall in RecallSpells) + recall.IsKnown = _automation.Spells.IsKnown(recall.SpellId); + } + + public void RefreshCantrips() + { + if (!InWorld) + return; + + Cantrips["Specialized Skills"].Clear(); + Cantrips["Trained Skills"].Clear(); + PopulateCharacterSkills(); + + foreach (Dictionary category in Cantrips.Values) + { + foreach (CantripInfo cantrip in category.Values) + { + cantrip.Value = NotAvailable; + cantrip.Color = Colors.White; + } + } + + foreach (PluginActiveEnchantment enchantment in _automation.Character.ActiveEnchantments) + { + if (_automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell) + && !string.IsNullOrEmpty(spell.Name)) + { + DetectCantrip(spell.Name); + } + } + + foreach (Dictionary category in Cantrips.Values) + { + foreach (CantripInfo cantrip in category.Values) + cantrip.ComputedIconId = cantrip.Value != NotAvailable ? Icons.GreenCircle : Icons.RedCircle; + } + } + + public void RefreshWeapons() + { + if (!InWorld) + return; + + WeaponCategories = BuildWeapons(); + IReadOnlyList inventory = _automation.Items.CaptureOwnedItems(); + foreach (List weapons in WeaponCategories.Values) + { + foreach (TrackedWeaponInfo weapon in weapons) + { + // Matched by name either way round, as the original matched + // it; an item with no name matches nothing. + PluginInventoryItem? found = null; + foreach (PluginInventoryItem item in inventory) + { + if (string.IsNullOrEmpty(item.Name)) + continue; + if (item.Name.Contains(weapon.Name, StringComparison.Ordinal) + || weapon.Name.Contains(item.Name, StringComparison.Ordinal)) + { + found = item; + break; + } + } + + if (found is { } match) + { + weapon.IsAcquired = true; + weapon.Status = "Acquired"; + weapon.IconId = match.IconId; + } + else + { + weapon.IsAcquired = false; + weapon.Status = "Not Acquired"; + weapon.IconId = Icons.Portal; + } + } + } + } + + /// + /// Files one active spell under its cantrip row when its name is a + /// cantrip's: "Epic Flame Ward", "Major Strength", "Legendary War Magic + /// Aptitude". A higher level replaces a lower one; anything else is left. + /// + internal void DetectCantrip(string spellName) + { + foreach ((string prefix, uint color) in CantripPatterns) + { + if (!spellName.StartsWith(prefix + " ", StringComparison.Ordinal)) + continue; + + string skillPart = spellName[(prefix.Length + 1)..]; + if (MatchProtectionAura(skillPart, prefix, color)) + return; + if (MatchAttribute(skillPart, prefix, color)) + return; + if (MatchSkill(skillPart, prefix, color)) + return; + } + } + + private int ReadCharacterInt(uint property) => + _automation.Objects.TryGetIntProperty(_automation.Character.ObjectId, property, out int value) + ? value + : 0; + + private void PopulateCharacterSkills() + { + foreach ((uint skillId, string name) in SkillIdToName) + { + if (!_automation.Character.TryGetSkill(skillId, out PluginSkillInfo skill)) + continue; + + string skillName = SkillCantripReplacements.TryGetValue(skillId, out string? replacement) + ? replacement + : name; + + if (skill.Training == PluginSkillTraining.Specialized) + Cantrips["Specialized Skills"][skillName] = new CantripInfo { Name = skillName }; + else if (skill.Training == PluginSkillTraining.Trained) + Cantrips["Trained Skills"][skillName] = new CantripInfo { Name = skillName }; + } + } + + private bool MatchProtectionAura(string skillPart, string level, uint color) + { + foreach ((string key, string aura) in ProtectionMappings) + { + if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + Raise(Cantrips["Protection Auras"], aura, level, color); + return true; + } + return false; + } + + private bool MatchAttribute(string skillPart, string level, uint color) + { + string cleaned = skillPart.Trim(); + foreach ((string key, string attribute) in AttributeMappings) + { + if (!cleaned.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + Raise(Cantrips["Attributes"], attribute, level, color); + return true; + } + + // Then the looser match the original fell back to: the name contains an attribute. + foreach ((string key, string attribute) in AttributeMappings) + { + if (cleaned.IndexOf(key, StringComparison.OrdinalIgnoreCase) < 0) + continue; + Raise(Cantrips["Attributes"], attribute, level, color); + return true; + } + return false; + } + + private bool MatchSkill(string skillPart, string level, uint color) + { + foreach ((string key, string skill) in SkillMappings) + { + if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + foreach (string category in new[] { "Specialized Skills", "Trained Skills" }) + { + if (!Cantrips[category].TryGetValue(skill, out CantripInfo? cantrip)) + continue; + if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value)) + { + cantrip.Value = level; + cantrip.Color = color; + } + return true; + } + } + return false; + } + + private static void Raise(Dictionary category, string name, string level, uint color) + { + if (!category.TryGetValue(name, out CantripInfo? cantrip)) + { + cantrip = new CantripInfo { Name = name }; + category[name] = cantrip; + } + if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value)) + { + cantrip.Value = level; + cantrip.Color = color; + } + } + + private static bool IsHigherCantripLevel(string newLevel, string currentLevel) => + CantripRank.TryGetValue(newLevel, out int next) + && CantripRank.TryGetValue(currentLevel, out int current) + && next > current; + + private static Dictionary> BuildAugmentations() => new() + { + ["Death Augs"] = + [ + new() { Name = "Keep Items", IntId = 231, Repeatable = 3, Trainer = "Rohula bint Ludun", Location = "Ayan Baqur" }, + new() { Name = "Keep Spells", IntId = 232, Repeatable = 1, Trainer = "Erik Festus", Location = "Ayan Baqur" }, + ], + ["Skill Augs"] = + [ + new() { Name = "+5 All Skills", IntId = 326, Repeatable = 1, Trainer = "Arianna the Adept", Location = "Bandit Castle" }, + new() { Name = "+10 Melee Skills", IntId = 300, Repeatable = 1, Trainer = "Carlito Gallo", Location = "Silyun" }, + new() { Name = "+10 Magic Skills", IntId = 302, Repeatable = 1, Trainer = "Rahina bint Zalanis", Location = "Zaikhal" }, + new() { Name = "+10 Missile Skills", IntId = 301, Repeatable = 1, Trainer = "Kilaf", Location = "Zaikhal" }, + ], + ["Rating Augs"] = + [ + new() { Name = "25% Crit Protection", IntId = 233, Repeatable = 1, Trainer = "Piersanti Linante", Location = "Sanamar" }, + new() { Name = "1% Critical Chance", IntId = 298, Repeatable = 1, Trainer = "Anfram Mellow", Location = "Ayan Baqur" }, + new() { Name = "3% Critical Damage", IntId = 299, Repeatable = 1, Trainer = "Alishia bint Aldan", Location = "Ayan Baqur" }, + new() { Name = "3% Damage Rating", IntId = 309, Repeatable = 1, Trainer = "Neela Nashua", Location = "Bandit Castle" }, + new() { Name = "3% Damage Reduction", IntId = 310, Repeatable = 1, Trainer = "Emily Yarow", Location = "Cragstone" }, + ], + ["Burden / Pack Augs"] = + [ + new() { Name = "Extra Carrying Capacity", IntId = 230, Repeatable = 5, Trainer = "Husoon", Location = "Zaikhal" }, + new() { Name = "Extra Pack Slot", IntId = 229, Repeatable = 1, Trainer = "Dumida bint Ruminre", Location = "Zaikhal" }, + new() { Name = "Infused War Magic", IntId = 297, Repeatable = 1, Trainer = "Raphel Detante", Location = "Silyun" }, + new() { Name = "Infused Void Magic", IntId = 328, Repeatable = 1, Trainer = "Morathe", Location = "Candeth Keep" }, + new() { Name = "Infused Creature Magic", IntId = 294, Repeatable = 1, Trainer = "Gustuv Lansdown", Location = "Cragstone" }, + new() { Name = "Infused Life Magic", IntId = 296, Repeatable = 1, Trainer = "Akemi Fei", Location = "Hebian-To" }, + new() { Name = "Infused Item Magic", IntId = 295, Repeatable = 1, Trainer = "Gan Fo", Location = "Hebian-To" }, + ], + ["Misc Augs"] = + [ + new() { Name = "10% Health Increase", IntId = null, Repeatable = 1, Trainer = "Donatello Linante", Location = "Silyun" }, + new() { Name = "Increased Spell Duration", IntId = 238, Repeatable = 5, Trainer = "Nawamara Ujio", Location = "Mayoi" }, + new() { Name = "Faster HP Regen", IntId = 237, Repeatable = 2, Trainer = "Alison Dulane", Location = "Bandit Castle" }, + new() { Name = "5% Experience Increase", IntId = 234, Repeatable = 1, Trainer = "Rickard Dumalia", Location = "Silyun" }, + ], + ["Salvage Augs"] = + [ + new() { Name = "Specialized Weapon Tinkering", IntId = 228, Repeatable = 1, Trainer = "Lenor Turk", Location = "Cragstone" }, + new() { Name = "Specialized Armor Tinkering", IntId = 226, Repeatable = 1, Trainer = "Joshun Felden", Location = "Cragstone" }, + new() { Name = "Specialized Item Tinkering", IntId = 225, Repeatable = 1, Trainer = "Brienne Carlus", Location = "Cragstone" }, + new() { Name = "Specialized Magic Item Tinkering", IntId = 227, Repeatable = 1, Trainer = "Burrell Sammrun", Location = "Cragstone" }, + new() { Name = "Specialized Salvaging", IntId = 224, Repeatable = 1, Trainer = "Robert Crow", Location = "Cragstone" }, + new() { Name = "25% More Salvage", IntId = 235, Repeatable = 4, Trainer = "Kris Cennis", Location = "Cragstone" }, + new() { Name = "5% Imbue Chance", IntId = 236, Repeatable = 1, Trainer = "Lug", Location = "Oolutanga's Refuge" }, + ], + ["Stat Augs"] = + [ + new() { Name = "All Stats", IntId = 217, Repeatable = 10, Trainer = "", Location = "" }, + new() { Name = "Strength", IntId = 218, Repeatable = 10, Trainer = "Fiun Luunere", Location = "Fiun Outpost" }, + new() { Name = "Endurance", IntId = 219, Repeatable = 10, Trainer = "Fiun Ruun", Location = "Fiun Outpost" }, + new() { Name = "Coordination", IntId = 220, Repeatable = 10, Trainer = "Fiun Bayaas", Location = "Fiun Outpost" }, + new() { Name = "Quickness", IntId = 221, Repeatable = 10, Trainer = "Fiun Riish", Location = "Fiun Outpost" }, + new() { Name = "Focus", IntId = 222, Repeatable = 10, Trainer = "Fiun Vasherr", Location = "Fiun Outpost" }, + new() { Name = "Self", IntId = 223, Repeatable = 10, Trainer = "Fiun Noress", Location = "Fiun Outpost" }, + ], + ["Resistance Augs"] = + [ + new() { Name = "All Resistances", IntId = 239, Repeatable = 2, Trainer = "", Location = "" }, + new() { Name = "Blunt", IntId = 242, Repeatable = 2, Trainer = "Nawamara Dia", Location = "Hebian-To" }, + new() { Name = "Pierce", IntId = 241, Repeatable = 2, Trainer = "Kyujo Rujen", Location = "Hebian-To" }, + new() { Name = "Slashing", IntId = 240, Repeatable = 2, Trainer = "Ilin Wis", Location = "Hebian-To" }, + new() { Name = "Fire", IntId = 244, Repeatable = 2, Trainer = "Rikshen Ri", Location = "Hebian-To" }, + new() { Name = "Frost", IntId = 245, Repeatable = 2, Trainer = "Lu Bao", Location = "Hebian-To" }, + new() { Name = "Acid", IntId = 243, Repeatable = 2, Trainer = "Shujio Milao", Location = "Hebian-To" }, + new() { Name = "Lightning", IntId = 246, Repeatable = 2, Trainer = "Enli Yuo", Location = "Hebian-To" }, + ], + }; + + private static Dictionary> BuildLuminanceAuras() => new() + { + ["Nalicana Auras"] = + [ + new() { Name = "+1 Aetheria Proc Rating", IntId = 338, Cap = 5 }, + new() { Name = "+1 Damage Reduction Rating", IntId = 334, Cap = 5 }, + new() { Name = "+1 Crit Reduction Rating", IntId = 336, Cap = 5 }, + new() { Name = "+1 Damage Rating", IntId = 333, Cap = 5 }, + new() { Name = "+1 Crit Damage Rating", IntId = 335, Cap = 5 }, + new() { Name = "+1 Heal Rating", IntId = 342, Cap = 5 }, + new() { Name = "+1 Equipment Mana Rating", IntId = 339, Cap = 5 }, + new() { Name = "+1 Mana Stone Rating", IntId = 340, Cap = 5 }, + new() { Name = "+1 Crafting Skills", IntId = 343, Cap = 5 }, + new() { Name = "+1 All Skills", IntId = 365, Cap = 10 }, + ], + ["Seer Auras"] = + [ + new() { Name = "(Ka'hiri) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToKahiri" }, + new() { Name = "(Ka'hiri) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToKahiri" }, + new() { Name = "(Shade of Lady Adja) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" }, + new() { Name = "(Shade of Lady Adja) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" }, + new() { Name = "(Liam of Gelid) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" }, + new() { Name = "(Liam of Gelid) +1 Crit Damage Rating", IntId = 335, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" }, + new() { Name = "(Lord Tyragar) +1 Crit Reduction Rating", IntId = 336, Cap = 5, QuestFlag = "LoyalToLordTyragar" }, + new() { Name = "(Lord Tyragar) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToLordTyragar" }, + ], + }; + + private static List BuildRecalls() => + [ + new() { Name = "Recall the Sanctuary", SpellId = 2023, Category = "Basic Recalls" }, + new() { Name = "Aerlinthe Recall", SpellId = 2041, Category = "Island Recalls" }, + new() { Name = "Mount Lethe Recall", SpellId = 2813, Category = "Island Recalls" }, + new() { Name = "Recall Aphus Lassel", SpellId = 2931, Category = "Island Recalls" }, + new() { Name = "Ulgrim's Recall", SpellId = 2941, Category = "Special Recalls" }, + new() { Name = "Recall to the Singularity Caul", SpellId = 2943, Category = "Island Recalls" }, + new() { Name = "Glenden Wood Recall", SpellId = 3865, Category = "Town Recalls" }, + new() { Name = "Bur Recall", SpellId = 4084, Category = "Town Recalls" }, + new() { Name = "Call of the Mhoire Forge", SpellId = 4128, Category = "Special Recalls" }, + new() { Name = "Paradox-touched Olthoi Infested Area Recall", SpellId = 4198, Category = "Special Recalls" }, + new() { Name = "Colosseum Recall", SpellId = 4213, Category = "Special Recalls" }, + new() { Name = "Return to the Keep", SpellId = 4214, Category = "Special Recalls" }, + new() { Name = "Gear Knight Invasion Area Camp Recall", SpellId = 5330, Category = "Special Recalls" }, + new() { Name = "Lost City of Neftet Recall", SpellId = 5541, Category = "Special Recalls" }, + new() { Name = "Rynthid Recall", SpellId = 6150, Category = "Special Recalls" }, + new() { Name = "Viridian Rise Recall", SpellId = 6321, Category = "Special Recalls" }, + new() { Name = "Viridian Rise Great Tree Recall", SpellId = 6322, Category = "Special Recalls" }, + ]; + + private static Dictionary> BuildCantrips() => new() + { + ["Specialized Skills"] = [], + ["Trained Skills"] = [], + ["Attributes"] = [], + ["Protection Auras"] = new() + { + // Listed up front so a missing ward shows as missing. + ["Armor"] = new() { Name = "Armor" }, + ["Bludgeoning Ward"] = new() { Name = "Bludgeoning Ward" }, + ["Piercing Ward"] = new() { Name = "Piercing Ward" }, + ["Slashing Ward"] = new() { Name = "Slashing Ward" }, + ["Flame Ward"] = new() { Name = "Flame Ward" }, + ["Frost Ward"] = new() { Name = "Frost Ward" }, + ["Acid Ward"] = new() { Name = "Acid Ward" }, + ["Storm Ward"] = new() { Name = "Storm Ward" }, + }, + }; + + private static Dictionary> BuildWeapons() => new() + { + ["Legendary Weapons"] = + [ + new() { Name = "Bow of the Quiddity", Category = "Legendary Weapons", WeaponType = "Bow", ItemId = 23044 }, + new() { Name = "Sword of the Quiddity", Category = "Legendary Weapons", WeaponType = "Sword", ItemId = 23039 }, + new() { Name = "Mace of the Quiddity", Category = "Legendary Weapons", WeaponType = "Mace", ItemId = 23040 }, + new() { Name = "Atlatl of the Quiddity", Category = "Legendary Weapons", WeaponType = "Atlatl", ItemId = 23043 }, + new() { Name = "Staff of the Quiddity", Category = "Legendary Weapons", WeaponType = "Staff", ItemId = 23041 }, + new() { Name = "Dagger of the Quiddity", Category = "Legendary Weapons", WeaponType = "Dagger", ItemId = 23042 }, + ], + ["Slayer Weapons"] = + [ + new() { Name = "Shadowfire Isparian Bow", Category = "Slayer Weapons", WeaponType = "Bow", ItemId = 20636 }, + new() { Name = "Shadowfire Isparian Sword", Category = "Slayer Weapons", WeaponType = "Sword", ItemId = 20631 }, + new() { Name = "Shadowfire Isparian Staff", Category = "Slayer Weapons", WeaponType = "Staff", ItemId = 20633 }, + new() { Name = "Shadowfire Isparian Dagger", Category = "Slayer Weapons", WeaponType = "Dagger", ItemId = 20634 }, + new() { Name = "Shadowfire Isparian Axe", Category = "Slayer Weapons", WeaponType = "Axe", ItemId = 20635 }, + new() { Name = "Shadowfire Isparian Mace", Category = "Slayer Weapons", WeaponType = "Mace", ItemId = 20632 }, + ], + ["Society Weapons"] = + [ + new() { Name = "Radiant Blood Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32834 }, + new() { Name = "Celestial Hand Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32835 }, + new() { Name = "Eldritch Web Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32836 }, + new() { Name = "Radiant Blood Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32837 }, + new() { Name = "Celestial Hand Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32838 }, + new() { Name = "Eldritch Web Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32839 }, + ], + ["Atlan Weapons"] = + [ + new() { Name = "Atlan Sword", Category = "Atlan Weapons", WeaponType = "Sword", ItemId = 11648 }, + new() { Name = "Atlan Axe", Category = "Atlan Weapons", WeaponType = "Axe", ItemId = 11649 }, + new() { Name = "Atlan Mace", Category = "Atlan Weapons", WeaponType = "Mace", ItemId = 11650 }, + new() { Name = "Atlan Spear", Category = "Atlan Weapons", WeaponType = "Spear", ItemId = 11651 }, + new() { Name = "Atlan Staff", Category = "Atlan Weapons", WeaponType = "Staff", ItemId = 11652 }, + new() { Name = "Atlan Dagger", Category = "Atlan Weapons", WeaponType = "Dagger", ItemId = 11653 }, + ], + }; +} diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs new file mode 100644 index 0000000..4087620 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs @@ -0,0 +1,159 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Quests; + +namespace OpenAC.MosswartMassacre.FlagTracker; + +/// +/// The "Mossy Tracker" window: augmentations, luminance auras, recalls, +/// cantrips, tracked weapons and quest timers for the character in the world. +/// Nothing here reaches the backend. +/// +/// +/// The window exists only on a host that draws one; on a headless host this +/// feature registers nothing and does nothing. The pages are read at login +/// and whenever the player presses a page's Refresh button, as the original +/// read them when its window opened and on Refresh; the quest page follows +/// the quest list and redraws its countdowns every five seconds. +/// +internal sealed class FlagTrackerFeature : IMmFeature +{ + /// The panel's markup file, beside the plugin assembly. + public const string MarkupFile = "mosswartmassacre-flagtracker.xml"; + + /// How often the quest countdowns are redrawn. + public static readonly TimeSpan QuestRedrawInterval = TimeSpan.FromSeconds(5); + + private readonly MmContext _context; + private readonly QuestManager _quests; + private readonly FlagTrackerData _data; + private readonly IDisposable? _panel; + private readonly Action? _onQuestsChanged; + private IDisposable? _questRedraw; + + public FlagTrackerFeature(MmContext context, QuestManager quests) + { + _context = context; + _quests = quests; + _data = new FlagTrackerData(context.Automation); + if (!context.Host.HasUi) + return; + + Panel = new FlagTrackerViewModel(); + Panel.RefreshRequested += OnRefreshRequested; + _onQuestsChanged = ShowQuests; + _quests.QuestsChanged += _onQuestsChanged; + + string directory = Path.GetDirectoryName(typeof(FlagTrackerFeature).Assembly.Location) ?? "."; + _panel = context.Host.Ui.RegisterPanel( + new PluginPanelDescriptor("flagtracker", "Mossy Tracker") + { + IconText = "MT", + // The icon the original window carried (7735). + IconSurfaceId = 0x06001E37, + // Shown while the binding's IsVisible is set; the host's own + // shown state starts true so that flag alone opens it. + StartVisible = true, + ShowInSidePanel = true, + }, + Path.Combine(directory, MarkupFile), + Panel); + } + + /// + /// The window's binding object; set its IsVisible to open the + /// window. Null on a host without a window. + /// + public FlagTrackerViewModel? Panel { get; } + + internal FlagTrackerData Data => _data; + + public void OnLogin() + { + if (Panel is null) + return; + RefreshAll(); + _questRedraw?.Dispose(); + _questRedraw = _context.Scheduler.Every(QuestRedrawInterval, ShowQuests); + } + + public void OnLogoff() + { + _questRedraw?.Dispose(); + _questRedraw = null; + Panel?.Clear(); + } + + public void Dispose() + { + OnLogoff(); + if (_onQuestsChanged is not null) + _quests.QuestsChanged -= _onQuestsChanged; + if (Panel is not null) + Panel.RefreshRequested -= OnRefreshRequested; + _panel?.Dispose(); + } + + /// Reads every page again and redraws it. + public void RefreshAll() + { + if (Panel is null) + return; + try + { + _data.RefreshAll(); + } + catch (Exception ex) + { + _context.Chat.Write($"[MossyTracker] Data refresh failed: {ex.Message}"); + } + Panel.ShowData(_data); + ShowQuests(); + } + + private void ShowQuests() + { + if (Panel is null || !_context.IsLoggedIn) + return; + Panel.ShowQuestRows(_quests.QuestList, _quests.UnixNow); + } + + private void OnRefreshRequested(string button) + { + if (Panel is null || !_context.IsLoggedIn) + return; + try + { + switch (button) + { + case nameof(FlagTrackerViewModel.RefreshAugs): + _data.RefreshAugmentations(); + Panel.ShowAugmentations(_data); + break; + case nameof(FlagTrackerViewModel.RefreshLum): + _data.RefreshLuminanceAuras(); + Panel.ShowLuminance(_data); + break; + case nameof(FlagTrackerViewModel.RefreshRecalls): + _data.RefreshRecallSpells(); + Panel.ShowRecallSpells(_data); + break; + case nameof(FlagTrackerViewModel.RefreshCantrips): + _data.RefreshCantrips(); + Panel.ShowCantripRows(_data); + break; + case nameof(FlagTrackerViewModel.RefreshWeapons): + _data.RefreshWeapons(); + Panel.ShowWeaponRows(_data); + break; + case nameof(FlagTrackerViewModel.RefreshQuests): + _quests.RefreshQuests(); + ShowQuests(); + break; + } + } + catch (Exception ex) + { + _context.Chat.Write($"[MossyTracker] Refresh failed: {ex.Message}"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs new file mode 100644 index 0000000..6991390 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs @@ -0,0 +1,319 @@ +using System.Globalization; +using OpenAC.MosswartMassacre.Quests; + +namespace OpenAC.MosswartMassacre.FlagTracker; + +/// +/// The binding object of the "Mossy Tracker" window +/// (mosswartmassacre-flagtracker.xml). One tab per page of the +/// original window, and each page's list as parallel column lists that are +/// replaced whole on every refresh, so the host re-reading them every frame +/// never sees a half-built page. Everything is assigned on the tick thread. +/// +internal sealed class FlagTrackerViewModel +{ + private enum Page + { + Augs, + Lum, + Recalls, + Cantrips, + Weapons, + Quests, + } + + private Page _page = Page.Augs; + + public FlagTrackerViewModel() + { + IgnoreRow = _ => { }; + ShowAugs = () => _page = Page.Augs; + ShowLum = () => _page = Page.Lum; + ShowRecalls = () => _page = Page.Recalls; + ShowCantrips = () => _page = Page.Cantrips; + ShowWeapons = () => _page = Page.Weapons; + ShowQuests = () => _page = Page.Quests; + RefreshAugs = () => RefreshRequested?.Invoke(nameof(RefreshAugs)); + RefreshLum = () => RefreshRequested?.Invoke(nameof(RefreshLum)); + RefreshRecalls = () => RefreshRequested?.Invoke(nameof(RefreshRecalls)); + RefreshCantrips = () => RefreshRequested?.Invoke(nameof(RefreshCantrips)); + RefreshWeapons = () => RefreshRequested?.Invoke(nameof(RefreshWeapons)); + RefreshQuests = () => RefreshRequested?.Invoke(nameof(RefreshQuests)); + } + + /// A refresh button was pressed; the argument is the button's binding name. + internal event Action? RefreshRequested; + + /// + /// Whether the window is open. The root panel's visibility is bound to it, + /// so the main window opens this one by setting it; it starts closed. + /// + public bool IsVisible { get; set; } + + // ---- tabs -------------------------------------------------------------- + + public bool AugsSelected => _page == Page.Augs; + public bool LumSelected => _page == Page.Lum; + public bool RecallsSelected => _page == Page.Recalls; + public bool CantripsSelected => _page == Page.Cantrips; + public bool WeaponsSelected => _page == Page.Weapons; + public bool QuestsSelected => _page == Page.Quests; + + public Action ShowAugs { get; } + public Action ShowLum { get; } + public Action ShowRecalls { get; } + public Action ShowCantrips { get; } + public Action ShowWeapons { get; } + public Action ShowQuests { get; } + + public Action RefreshAugs { get; } + public Action RefreshLum { get; } + public Action RefreshRecalls { get; } + public Action RefreshCantrips { get; } + public Action RefreshWeapons { get; } + public Action RefreshQuests { get; } + + /// Lists here are read-only views; a click selects nothing. + public int NoRow => -1; + + public Action IgnoreRow { get; } + + // ---- Augs -------------------------------------------------------------- + + public IReadOnlyList AugNames { get; private set; } = []; + public IReadOnlyList AugProgress { get; private set; } = []; + public IReadOnlyList AugProgressColors { get; private set; } = []; + public IReadOnlyList AugTrainers { get; private set; } = []; + public IReadOnlyList AugLocations { get; private set; } = []; + + // ---- Lum --------------------------------------------------------------- + + public IReadOnlyList LumNames { get; private set; } = []; + public IReadOnlyList LumProgress { get; private set; } = []; + public IReadOnlyList LumProgressColors { get; private set; } = []; + public IReadOnlyList LumCategories { get; private set; } = []; + + // ---- Recalls ----------------------------------------------------------- + + public IReadOnlyList RecallSpellIds { get; private set; } = []; + public IReadOnlyList RecallNames { get; private set; } = []; + public IReadOnlyList RecallStatus { get; private set; } = []; + public IReadOnlyList RecallStatusColors { get; private set; } = []; + + // ---- Cantrips ---------------------------------------------------------- + + public IReadOnlyList CantripIcons { get; private set; } = []; + public IReadOnlyList CantripNames { get; private set; } = []; + public IReadOnlyList CantripLevels { get; private set; } = []; + public IReadOnlyList CantripLevelColors { get; private set; } = []; + + // ---- Weapons ----------------------------------------------------------- + + public IReadOnlyList WeaponIcons { get; private set; } = []; + public IReadOnlyList WeaponNames { get; private set; } = []; + public IReadOnlyList WeaponStatus { get; private set; } = []; + public IReadOnlyList WeaponStatusColors { get; private set; } = []; + + // ---- Quests ------------------------------------------------------------ + + public IReadOnlyList QuestRowNames { get; private set; } = []; + public IReadOnlyList QuestCountdowns { get; private set; } = []; + public IReadOnlyList QuestCountdownColors { get; private set; } = []; + public IReadOnlyList QuestLastSolved { get; private set; } = []; + public IReadOnlyList QuestCooldowns { get; private set; } = []; + public IReadOnlyList QuestSolves { get; private set; } = []; + public IReadOnlyList QuestSolvesColors { get; private set; } = []; + + /// Rebuilds every page but the quests from the tracker's data. + public void ShowData(FlagTrackerData data) + { + ShowAugmentations(data); + ShowLuminance(data); + ShowRecallSpells(data); + ShowCantripRows(data); + ShowWeaponRows(data); + } + + public void ShowAugmentations(FlagTrackerData data) + { + var names = new List(); + var progress = new List(); + var colors = new List(); + var trainers = new List(); + var locations = new List(); + foreach ((string category, List augmentations) in data.AugmentationCategories) + { + names.Add($"--- {category} ---"); + progress.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + trainers.Add(string.Empty); + locations.Add(string.Empty); + foreach (FlagTrackerData.AugmentationInfo aug in augmentations) + { + names.Add(aug.Name); + progress.Add(aug.IsMaxed + ? "[MAX]" + : string.Create(CultureInfo.InvariantCulture, $"[{aug.CurrentValue}/{aug.Repeatable}]")); + colors.Add(aug.IsMaxed + ? FlagTrackerData.Colors.Green + : aug.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red); + trainers.Add(aug.Trainer); + locations.Add(aug.Location); + } + } + AugNames = names; + AugProgress = progress; + AugProgressColors = colors; + AugTrainers = trainers; + AugLocations = locations; + } + + public void ShowLuminance(FlagTrackerData data) + { + var names = new List(); + var progress = new List(); + var colors = new List(); + var categories = new List(); + foreach ((string category, List auras) in data.LuminanceAuraCategories) + { + names.Add($"--- {category} ---"); + progress.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + categories.Add(string.Empty); + foreach (FlagTrackerData.LuminanceAuraInfo aura in auras) + { + names.Add(aura.Name); + progress.Add(string.Create(CultureInfo.InvariantCulture, $"{aura.CurrentValue}/{aura.Cap}")); + colors.Add(aura.CurrentValue >= aura.Cap + ? FlagTrackerData.Colors.Green + : aura.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red); + categories.Add(category == "Seer Auras" && !string.IsNullOrEmpty(aura.QuestFlag) + ? aura.QuestFlag + : category); + } + } + LumNames = names; + LumProgress = progress; + LumProgressColors = colors; + LumCategories = categories; + } + + public void ShowRecallSpells(FlagTrackerData data) + { + RecallSpellIds = [.. data.RecallSpells.Select(recall => recall.SpellId)]; + RecallNames = [.. data.RecallSpells.Select(recall => recall.Name)]; + RecallStatus = [.. data.RecallSpells.Select(recall => recall.IsKnown ? "Known" : "Unknown")]; + RecallStatusColors = [.. data.RecallSpells.Select(recall => + recall.IsKnown ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red)]; + } + + public void ShowCantripRows(FlagTrackerData data) + { + var icons = new List(); + var names = new List(); + var levels = new List(); + var colors = new List(); + foreach ((string category, Dictionary cantrips) in data.Cantrips) + { + icons.Add(FlagTrackerData.Icons.CategoryStar); + names.Add($"--- {category} ---"); + levels.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + foreach ((string name, FlagTrackerData.CantripInfo cantrip) in cantrips) + { + icons.Add(cantrip.ComputedIconId); + names.Add(name); + levels.Add(cantrip.Value); + colors.Add(cantrip.Color); + } + } + CantripIcons = icons; + CantripNames = names; + CantripLevels = levels; + CantripLevelColors = colors; + } + + public void ShowWeaponRows(FlagTrackerData data) + { + var icons = new List(); + var names = new List(); + var status = new List(); + var colors = new List(); + foreach ((string category, List weapons) in data.WeaponCategories) + { + icons.Add(FlagTrackerData.Icons.CategoryStar); + names.Add($"--- {category} ---"); + status.Add(string.Empty); + colors.Add(FlagTrackerData.Colors.White); + foreach (FlagTrackerData.TrackedWeaponInfo weapon in weapons) + { + icons.Add(weapon.IconId); + names.Add(weapon.Name); + status.Add(weapon.Status); + colors.Add(weapon.IsAcquired ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red); + } + } + WeaponIcons = icons; + WeaponNames = names; + WeaponStatus = status; + WeaponStatusColors = colors; + } + + /// + /// The quest page: every quest not yet solved its maximum number of times, + /// most solves first, then by name; the countdown green when ready and + /// yellow while cooling down. + /// + public void ShowQuestRows(IReadOnlyList quests, long unixNow) + { + var names = new List(); + var countdowns = new List(); + var countdownColors = new List(); + var lastSolved = new List(); + var cooldowns = new List(); + var solves = new List(); + var solvesColors = new List(); + + IEnumerable visible = quests + .Where(quest => !(quest.MaxSolves > 0 && quest.Solves >= quest.MaxSolves)) + .OrderByDescending(quest => quest.Solves) + .ThenBy(quest => QuestNames.GetFriendlyName(quest.Id), StringComparer.InvariantCulture); + + foreach (Quest quest in visible) + { + names.Add(QuestNames.GetFriendlyName(quest.Id)); + countdowns.Add(QuestStreamingService.FormatCountdown(quest.ExpireTime - unixNow)); + countdownColors.Add(quest.ExpireTime <= unixNow + ? FlagTrackerData.Colors.Green + : FlagTrackerData.Colors.Yellow); + lastSolved.Add(QuestManager.FormatTimeStamp(quest.Timestamp)); + cooldowns.Add(QuestManager.FormatSeconds(quest.Delta)); + solves.Add(quest.Solves.ToString(CultureInfo.InvariantCulture)); + solvesColors.Add(FlagTrackerData.Colors.White); + } + + QuestRowNames = names; + QuestCountdowns = countdowns; + QuestCountdownColors = countdownColors; + QuestLastSolved = lastSolved; + QuestCooldowns = cooldowns; + QuestSolves = solves; + QuestSolvesColors = solvesColors; + } + + /// Empties every page, at logoff. + public void Clear() + { + AugNames = AugProgress = AugTrainers = AugLocations = []; + AugProgressColors = []; + LumNames = LumProgress = LumCategories = []; + LumProgressColors = []; + RecallSpellIds = RecallStatusColors = []; + RecallNames = RecallStatus = []; + CantripIcons = CantripLevelColors = []; + CantripNames = CantripLevels = []; + WeaponIcons = WeaponStatusColors = []; + WeaponNames = WeaponStatus = []; + ShowQuestRows([], 0); + } +} diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs new file mode 100644 index 0000000..0fd528c --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs @@ -0,0 +1,337 @@ +using System.Collections.Concurrent; +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Metas; + +/// +/// Meta and nav sync from the club's repository: the /mm metasrefresh, +/// metascheck, metaspull and metaspullfile verbs and, on a +/// host with a window, the Metas window. Nothing here reaches the backend. +/// +/// +/// Network work runs on the thread pool; every result, chat line and window +/// update is queued and applied on the next tick. The file list is not tied +/// to a character, so it survives a relog. +/// +internal sealed class MetaSyncFeature : IMmFeature +{ + /// The panel's markup file, beside the plugin assembly. + public const string MarkupFile = "mosswartmassacre-metas.xml"; + + private readonly MmContext _context; + private readonly MetaSyncManager _manager; + private readonly ConcurrentQueue _onTick = new(); + private readonly CancellationTokenSource _shutdown = new(); + private readonly Action _tick; + private readonly IDisposable? _panel; + private Task _lastOperation = Task.CompletedTask; + private bool _disposed; + + public MetaSyncFeature(MmContext context) + : this(context, context.Host.VtankProfiles, handler: null) + { + } + + /// Builds the feature on a given profile storage and HTTP handler; tests pass fakes. + internal MetaSyncFeature(MmContext context, IPluginStorage profiles, HttpMessageHandler? handler) + { + _context = context; + _manager = new MetaSyncManager( + profiles, + handler, + () => RepositoryUrl, + () => _context.Clock.UtcNow, + message => _onTick.Enqueue(() => _context.Chat.Write(message))); + + _tick = _ => Pump(); + context.Host.Events.Tick += _tick; + + context.Commands.Register("metasrefresh", _ => RunRefresh(), "Refresh remote metas list"); + context.Commands.Register("metascheck", _ => RunCheck(), "Check local metas against remote"); + context.Commands.Register("metaspull", _ => RunPullOutdated(), "Download outdated metas/nav files"); + context.Commands.Register("metaspullfile", HandlePullFile, "Download one metas/nav file by relative path"); + + if (!context.Host.HasUi) + return; + + Panel = new MetasViewModel(); + Panel.ShowPath(_manager.StorageRoot); + Panel.ShowFiles([]); + Panel.ButtonPressed += OnButton; + Panel.SearchRequested += ShowFiles; + + string directory = Path.GetDirectoryName(typeof(MetaSyncFeature).Assembly.Location) ?? "."; + _panel = context.Host.Ui.RegisterPanel( + new PluginPanelDescriptor("metas", "Metas") + { + IconText = "ME", + // Shown while the binding's IsVisible is set; the host's own + // shown state starts true so that flag alone opens it. + StartVisible = true, + ShowInSidePanel = true, + }, + Path.Combine(directory, MarkupFile), + Panel); + } + + /// + /// The window's binding object; set its IsVisible to open the + /// window. Null on a host without a window. + /// + public MetasViewModel? Panel { get; } + + internal MetaSyncManager Manager => _manager; + + /// The operation started last; tests await it before pumping the tick. + internal Task LastOperation => _lastOperation; + + /// The repository setting, or the original's repository when it is empty. + private string RepositoryUrl + { + get + { + string? url = _context.Settings.Current.MetasRepositoryUrl; + return string.IsNullOrWhiteSpace(url) ? MetaSyncManager.DefaultRepositoryUrl : url; + } + } + + public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot); + + public void OnLogoff() + { + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _context.Host.Events.Tick -= _tick; + _shutdown.Cancel(); + if (Panel is not null) + { + Panel.ButtonPressed -= OnButton; + Panel.SearchRequested -= ShowFiles; + } + _panel?.Dispose(); + _onTick.Clear(); + // Operations run one after another, so the manager goes once the + // last one, cancelled above, has finished. + _lastOperation.ContinueWith( + _ => { _manager.Dispose(); _shutdown.Dispose(); }, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + + /// Applies finished work on the tick thread. Internal so tests can pump without a host tick. + internal void Pump() + { + while (_onTick.TryDequeue(out Action? action)) + { + try + { + action(); + } + catch (Exception ex) + { + _context.Host.Log.Error("A metas update failed.", ex); + } + } + } + + private void RunRefresh(Action? done = null, Action? failed = null) => + Start( + token => _manager.RefreshCatalogAsync(token), + count => + { + ShowFiles(); + if (done is null) + _context.Chat.Write($"[Metas] Loaded {count} remote .met/.nav files"); + else + done(count); + }, + ex => + { + if (failed is null) + _context.Chat.Write($"[Metas] Refresh failed: {ex.Message}"); + else + failed(ex); + }); + + private void RunCheck( + Action<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)>? done = null, + Action? failed = null) => + Start( + token => _manager.CheckStatusesAsync(token), + summary => + { + ShowFiles(); + if (done is null) + { + _context.Chat.Write( + $"[Metas] Up-to-date: {summary.UpToDate}, Updates: {summary.NeedsUpdate}, Missing: {summary.NotDownloaded}, Errors: {summary.Errors}"); + } + else + { + done(summary); + } + }, + ex => + { + if (failed is null) + _context.Chat.Write($"[Metas] Check failed: {ex.Message}"); + else + failed(ex); + }); + + private void RunPullOutdated( + Action<(int Downloaded, int Skipped, int Failed, int Backups)>? done = null, + Action? failed = null) => + Start( + token => _manager.DownloadOutdatedAsync(token), + result => + { + ShowFiles(); + if (done is null) + { + _context.Chat.Write( + $"[Metas] Downloaded: {result.Downloaded}, Skipped: {result.Skipped}, Failed: {result.Failed}, Backups: {result.Backups}"); + } + else + { + done(result); + } + }, + ex => + { + if (failed is null) + _context.Chat.Write($"[Metas] Pull failed: {ex.Message}"); + else + failed(ex); + }); + + private void HandlePullFile(string[] args) + { + if (args.Length < 2) + { + _context.Chat.Write("Usage: /mm metaspullfile "); + return; + } + + string relativePath = string.Join(" ", args.Skip(1)); + Start( + token => _manager.DownloadFileAsync(relativePath, token), + result => + { + ShowFiles(); + _context.Chat.Write($"[Metas] {relativePath}: {result.Message}"); + }, + ex => _context.Chat.Write($"[Metas] Pull file failed: {ex.Message}")); + } + + private void OnButton(string button) + { + MetasViewModel? panel = Panel; + if (panel is null) + return; + + switch (button) + { + case nameof(MetasViewModel.RefreshList): + panel.ShowPath(_manager.StorageRoot); + panel.StatusText = "Status: refreshing remote file list..."; + RunRefresh( + count => panel.StatusText = $"Status: loaded {count} remote files", + ex => + { + panel.StatusText = "Status: refresh failed"; + _context.Chat.Write($"[Metas] Refresh failed: {ex.Message}"); + }); + break; + + case nameof(MetasViewModel.CheckUpdates): + panel.ShowPath(_manager.StorageRoot); + panel.StatusText = "Status: checking remote updates..."; + RunCheck( + summary => panel.StatusText = + $"Status: {summary.UpToDate} current, {summary.NeedsUpdate} updates, {summary.NotDownloaded} missing, {summary.Errors} errors", + ex => + { + panel.StatusText = "Status: update check failed"; + _context.Chat.Write($"[Metas] Update check failed: {ex.Message}"); + }); + break; + + case nameof(MetasViewModel.DownloadOutdated): + panel.StatusText = "Status: downloading outdated files..."; + RunPullOutdated( + result => panel.StatusText = + $"Status: downloaded {result.Downloaded}, skipped {result.Skipped}, failed {result.Failed}, backups {result.Backups}", + ex => + { + panel.StatusText = "Status: pull outdated failed"; + _context.Chat.Write($"[Metas] Pull outdated failed: {ex.Message}"); + }); + break; + + case nameof(MetasViewModel.DownloadSelected): + string selection = panel.SelectedRelativePath; + if (string.IsNullOrWhiteSpace(selection)) + { + _context.Chat.Write("[Metas] Select a file first"); + return; + } + panel.StatusText = $"Status: downloading {selection}..."; + Start( + token => _manager.DownloadFileAsync(selection, token), + result => + { + ShowFiles(); + panel.StatusText = $"Status: {result.Message}"; + }, + ex => + { + panel.StatusText = "Status: download failed"; + _context.Chat.Write($"[Metas] Download failed: {ex.Message}"); + }); + break; + } + } + + private void ShowFiles() + { + if (Panel is null) + return; + Panel.ShowFiles(_manager.GetCachedFiles()); + Panel.ShowLastCheck(_manager.LastStatusCheckUtc, _manager.LastCatalogRefreshUtc); + } + + /// Runs on the thread pool and applies its outcome on the next tick. + private void Start(Func> work, Action done, Action failed) + { + if (_disposed) + return; + CancellationToken token = _shutdown.Token; + Task previous = _lastOperation; + // Each operation starts when the one before it has finished, the + // order the player asked for them in. + _lastOperation = Task.Run(async () => + { + await previous.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + try + { + T result = await work(token).ConfigureAwait(false); + _onTick.Enqueue(() => done(result)); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Shutting down: nobody is left to tell. + } + catch (Exception ex) + { + _onTick.Enqueue(() => failed(ex)); + } + }, CancellationToken.None); + } +} diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs new file mode 100644 index 0000000..c84a798 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs @@ -0,0 +1,470 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; + +namespace OpenAC.MosswartMassacre.Metas; + +internal enum MetaFileSyncStatus +{ + Unknown, + NotDownloaded, + UpToDate, + UpdateAvailable, + Error, +} + +/// One .met or .nav file of the repository and how the local copy compares. +internal sealed class MetaFileEntry +{ + public string RelativePath { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string Extension { get; set; } = string.Empty; + + /// The storage key the file is kept under, or empty when there is no storage. + public string LocalPath { get; set; } = string.Empty; + + public bool LocalExists { get; set; } + public string LocalSha256 { get; set; } = string.Empty; + public string RemoteSha256 { get; set; } = string.Empty; + public MetaFileSyncStatus Status { get; set; } + public string StatusText { get; set; } = string.Empty; + + public MetaFileEntry Clone() => (MetaFileEntry)MemberwiseClone(); +} + +internal sealed class MetaDownloadResult +{ + public string RelativePath { get; set; } = string.Empty; + public bool Downloaded { get; set; } + public bool SkippedAsCurrent { get; set; } + public bool BackupCreated { get; set; } + public string Message { get; set; } = string.Empty; +} + +/// +/// Keeps the shared automation profiles in step with the metas repository on +/// the club's git server: lists its .met and .nav files through +/// the server's API, compares each with the local copy by SHA-256, and +/// downloads the ones that differ, keeping the old copy as .bak. +/// +/// +/// The endpoints are the original's: the tree comes from +/// {server}/api/v1/repos/{owner}/{repo}/git/trees/main?recursive=1 and each +/// file from {repository}/raw/branch/main/{path}. The repository address is a +/// setting that defaults to the original's repository. +/// Files land where the automation plugin reads dropped profiles, flattened +/// to their bare names as the original flattened them into its profile +/// folder: metas under mosstank/metas, navs under mosstank/navs. +/// The shared storage holds text, so both sides are compared as the text a +/// read of the stored file gives back (UTF-8, byte-order mark dropped), +/// hashed as UTF-8; for the plain-text files in the repository that is the +/// same as the original's byte comparison. +/// The methods run off the tick thread: they touch only the network, the +/// shared storage and this object's own list, which is locked. One operation +/// runs at a time; a second waits for the first. Notices for the chat +/// window go through the callback, which the owner marshals to the tick. +/// +internal sealed class MetaSyncManager : IDisposable +{ + public const string DefaultRepositoryUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas"; + public const string BranchName = "main"; + + /// Where the automation plugin looks for dropped metas. + public const string MetaFolder = "mosstank/metas"; + + /// Where the automation plugin looks for dropped navs. + public const string NavFolder = "mosstank/navs"; + + private readonly IPluginStorage _storage; + private readonly HttpClient _http; + private readonly Func _repositoryUrl; + private readonly Func _utcNow; + private readonly Action _notice; + private readonly object _sync = new(); + private readonly SemaphoreSlim _oneAtATime = new(1, 1); + private List _cachedFiles = []; + + public MetaSyncManager( + IPluginStorage storage, + HttpMessageHandler? handler, + Func repositoryUrl, + Func utcNow, + Action notice) + { + _storage = storage; + _http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false); + _http.Timeout = TimeSpan.FromSeconds(30); + _repositoryUrl = repositoryUrl; + _utcNow = utcNow; + _notice = notice; + } + + public DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue; + + public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue; + + /// The storage root the files are written beneath, for the path label; null without storage. + public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null; + + public IReadOnlyList GetCachedFiles() + { + lock (_sync) + return [.. _cachedFiles.Select(static entry => entry.Clone())]; + } + + /// The API and raw-file addresses for a repository address. + public static (string TreeUrl, string RawBaseUrl) ResolveEndpoints(string repositoryUrl) + { + string address = string.IsNullOrWhiteSpace(repositoryUrl) ? DefaultRepositoryUrl : repositoryUrl.Trim(); + var uri = new Uri(address, UriKind.Absolute); + string server = uri.GetLeftPart(UriPartial.Authority); + string path = uri.AbsolutePath.Trim('/'); + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + path = path[..^4]; + return ( + $"{server}/api/v1/repos/{path}/git/trees/{BranchName}?recursive=1", + $"{server}/{path}/raw/branch/{BranchName}"); + } + + /// The storage key a repository file is kept under, or null for a file that is neither a meta nor a nav. + public static string? LocalKeyFor(string relativePath) + { + string fileName = Path.GetFileName((relativePath ?? string.Empty).Replace('\\', '/')); + if (fileName.Length == 0) + return null; + if (fileName.EndsWith(".met", StringComparison.OrdinalIgnoreCase)) + return $"{MetaFolder}/{fileName}"; + if (fileName.EndsWith(".nav", StringComparison.OrdinalIgnoreCase)) + return $"{NavFolder}/{fileName}"; + return null; + } + + public async Task RefreshCatalogAsync(CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _oneAtATime.Release(); + } + } + + public async Task<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)> CheckStatusesAsync( + CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + List workingSet = CloneCache(); + if (workingSet.Count == 0) + { + await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false); + workingSet = CloneCache(); + } + + foreach (MetaFileEntry entry in workingSet) + { + try + { + string? localText = entry.LocalPath.Length == 0 ? null : _storage.ReadText(entry.LocalPath); + if (localText is null) + { + entry.LocalExists = false; + entry.LocalSha256 = string.Empty; + entry.Status = MetaFileSyncStatus.NotDownloaded; + entry.StatusText = "Not downloaded"; + continue; + } + + entry.LocalExists = true; + entry.LocalSha256 = Sha256(localText); + string remoteText = await DownloadRemoteTextAsync(entry.RelativePath, cancellationToken) + .ConfigureAwait(false); + entry.RemoteSha256 = Sha256(remoteText); + + if (string.Equals(entry.LocalSha256, entry.RemoteSha256, StringComparison.OrdinalIgnoreCase)) + { + entry.Status = MetaFileSyncStatus.UpToDate; + entry.StatusText = "Up to date"; + } + else + { + entry.Status = MetaFileSyncStatus.UpdateAvailable; + entry.StatusText = "Update available"; + } + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + entry.Status = MetaFileSyncStatus.Error; + entry.StatusText = "Error"; + _notice($"[Metas] Status check failed for {entry.RelativePath}: {ex.Message}"); + } + } + + lock (_sync) + { + _cachedFiles = workingSet; + LastStatusCheckUtc = _utcNow(); + } + + return ( + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpToDate), + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpdateAvailable), + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.NotDownloaded), + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.Error)); + } + finally + { + _oneAtATime.Release(); + } + } + + public async Task DownloadFileAsync(string relativePath, CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await DownloadFileCoreAsync(relativePath, cancellationToken).ConfigureAwait(false); + } + finally + { + _oneAtATime.Release(); + } + } + + public async Task<(int Downloaded, int Skipped, int Failed, int Backups)> DownloadOutdatedAsync( + CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + List current = CloneCache(); + if (current.Count == 0) + { + await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false); + current = CloneCache(); + } + + List candidates = [.. current + .Where(static entry => entry.Status is MetaFileSyncStatus.UpdateAvailable + or MetaFileSyncStatus.NotDownloaded + or MetaFileSyncStatus.Unknown) + .OrderBy(static entry => entry.RelativePath, StringComparer.OrdinalIgnoreCase)]; + + int downloaded = 0; + int skipped = 0; + int failed = 0; + int backups = 0; + foreach (MetaFileEntry entry in candidates) + { + try + { + MetaDownloadResult result = await DownloadFileCoreAsync(entry.RelativePath, cancellationToken) + .ConfigureAwait(false); + if (result.Downloaded) + downloaded++; + if (result.SkippedAsCurrent) + skipped++; + if (result.BackupCreated) + backups++; + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + failed++; + _notice($"[Metas] Download failed for {entry.RelativePath}: {ex.Message}"); + UpdateSingleCachedEntry( + entry.RelativePath, entry.LocalPath, entry.LocalSha256, entry.RemoteSha256, + MetaFileSyncStatus.Error, "Error"); + } + } + + return (downloaded, skipped, failed, backups); + } + finally + { + _oneAtATime.Release(); + } + } + + public void Dispose() + { + _http.Dispose(); + _oneAtATime.Dispose(); + } + + private async Task RefreshCatalogCoreAsync(CancellationToken cancellationToken) + { + List remoteEntries = await FetchRemoteTreeEntriesAsync(cancellationToken).ConfigureAwait(false); + HashSet localKeys = ListLocalKeys(); + + List result = [.. remoteEntries + .Where(static path => path.EndsWith(".met", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".nav", StringComparison.OrdinalIgnoreCase)) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) + .Select(path => + { + string localPath = _storage.IsAvailable ? LocalKeyFor(path) ?? string.Empty : string.Empty; + bool exists = localPath.Length > 0 && localKeys.Contains(localPath); + return new MetaFileEntry + { + RelativePath = path, + FileName = Path.GetFileName(path), + Extension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(), + LocalPath = localPath, + LocalExists = exists, + Status = exists ? MetaFileSyncStatus.Unknown : MetaFileSyncStatus.NotDownloaded, + StatusText = exists ? "Unknown" : "Not downloaded", + }; + })]; + + lock (_sync) + { + _cachedFiles = result; + LastCatalogRefreshUtc = _utcNow(); + } + return result.Count; + } + + private async Task DownloadFileCoreAsync(string relativePath, CancellationToken cancellationToken) + { + var result = new MetaDownloadResult { RelativePath = relativePath }; + + if (string.IsNullOrWhiteSpace(relativePath)) + { + result.Message = "No file selected."; + return result; + } + + if (!_storage.IsAvailable) + { + result.Message = "VTank path could not be resolved."; + return result; + } + + // The original dropped any file into the profile folder; only the + // two kinds the automation plugin reads have a folder here. + string? localPath = LocalKeyFor(relativePath); + if (localPath is null) + { + result.Message = "Only .met and .nav files can be pulled."; + return result; + } + + string remoteText = await DownloadRemoteTextAsync(relativePath, cancellationToken).ConfigureAwait(false); + string remoteHash = Sha256(remoteText); + + string? localText = _storage.ReadText(localPath); + if (localText is not null) + { + string localHash = Sha256(localText); + if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase)) + { + result.SkippedAsCurrent = true; + result.Message = "Already up to date."; + UpdateSingleCachedEntry(relativePath, localPath, localHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date"); + return result; + } + + _storage.WriteText(localPath + ".bak", localText); + result.BackupCreated = true; + } + + _storage.WriteText(localPath, remoteText); + + result.Downloaded = true; + result.Message = result.BackupCreated ? "Downloaded and updated (.bak created)." : "Downloaded."; + UpdateSingleCachedEntry(relativePath, localPath, remoteHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date"); + return result; + } + + private async Task> FetchRemoteTreeEntriesAsync(CancellationToken cancellationToken) + { + (string treeUrl, _) = ResolveEndpoints(_repositoryUrl()); + using HttpResponseMessage response = await _http.GetAsync(treeUrl, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (JObject.Parse(json)["tree"] is not JArray tree) + return []; + + return [.. tree + .Where(static node => string.Equals(node["type"]?.ToString(), "blob", StringComparison.OrdinalIgnoreCase)) + .Select(static node => node["path"]?.ToString()) + .Where(static path => !string.IsNullOrWhiteSpace(path)) + .Select(static path => path!)]; + } + + private async Task DownloadRemoteTextAsync(string relativePath, CancellationToken cancellationToken) + { + (_, string rawBaseUrl) = ResolveEndpoints(_repositoryUrl()); + string escapedPath = string.Join( + "/", + (relativePath ?? string.Empty) + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + byte[] bytes = await _http.GetByteArrayAsync($"{rawBaseUrl}/{escapedPath}", cancellationToken) + .ConfigureAwait(false); + return DecodeAsStored(bytes); + } + + /// + /// The text a read of this file from storage would give back: UTF-8, + /// with a leading byte-order mark taken as the encoding it names and + /// dropped, as the host's own text read does. + /// + internal static string DecodeAsStored(byte[] bytes) + { + using var reader = new StreamReader(new MemoryStream(bytes), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + internal static string Sha256(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(CultureInfo.InvariantCulture); + + private HashSet ListLocalKeys() + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!_storage.IsAvailable) + return keys; + foreach (string key in _storage.List(MetaFolder)) + keys.Add(key); + foreach (string key in _storage.List(NavFolder)) + keys.Add(key); + return keys; + } + + private List CloneCache() + { + lock (_sync) + return [.. _cachedFiles.Select(static entry => entry.Clone())]; + } + + private void UpdateSingleCachedEntry( + string relativePath, + string localPath, + string localHash, + string remoteHash, + MetaFileSyncStatus status, + string statusText) + { + lock (_sync) + { + MetaFileEntry? target = _cachedFiles.FirstOrDefault(entry => + string.Equals(entry.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)); + if (target is null) + return; + + target.LocalPath = localPath; + target.LocalExists = status == MetaFileSyncStatus.UpToDate + || (localPath.Length > 0 && _storage.ReadText(localPath) is not null); + target.LocalSha256 = localHash ?? string.Empty; + target.RemoteSha256 = remoteHash ?? string.Empty; + target.Status = status; + target.StatusText = statusText ?? "Unknown"; + } + } +} diff --git a/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs new file mode 100644 index 0000000..a72beea --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs @@ -0,0 +1,172 @@ +using System.Globalization; + +namespace OpenAC.MosswartMassacre.Metas; + +/// +/// The binding object of the Metas window (mosswartmassacre-metas.xml), +/// the original's Metas tab: the repository's files with their status, a +/// live search, a pick list for one file, and the refresh, check and +/// download buttons. Assigned on the tick thread only. +/// +internal sealed class MetasViewModel +{ + private readonly Dictionary _pathByLabel = new(StringComparer.Ordinal); + + public MetasViewModel() + { + IgnoreRow = _ => { }; + SearchChanged = text => + { + SearchText = text ?? string.Empty; + SearchRequested?.Invoke(); + }; + ClearSearch = () => + { + SearchText = string.Empty; + SearchRequested?.Invoke(); + }; + SelectionChanged = label => SelectedLabel = label ?? string.Empty; + RefreshList = () => ButtonPressed?.Invoke(nameof(RefreshList)); + CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates)); + DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated)); + DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected)); + } + + /// A button was pressed; the argument is its binding name. + internal event Action? ButtonPressed; + + /// The search text changed or was cleared. + internal event Action? SearchRequested; + + /// + /// Whether the window is open. The root panel's visibility is bound to it, + /// so the main window opens this one by setting it; it starts closed. + /// + public bool IsVisible { get; set; } + + public string PathText { get; set; } = "VTank Path: unresolved"; + + public string LastCheckText { get; set; } = "Last Check: never"; + + public string StatusText { get; set; } = "Status: idle"; + + public string SearchText { get; set; } = string.Empty; + + public Action SearchChanged { get; } + + public Action ClearSearch { get; } + + public Action RefreshList { get; } + + public Action CheckUpdates { get; } + + public Action DownloadOutdated { get; } + + public Action DownloadSelected { get; } + + public int NoRow => -1; + + public Action IgnoreRow { get; } + + public IReadOnlyList FileTypes { get; private set; } = []; + + public IReadOnlyList FilePaths { get; private set; } = []; + + public IReadOnlyList FileStatus { get; private set; } = []; + + public IReadOnlyList FileStatusColors { get; private set; } = []; + + public IReadOnlyList SelectionItems { get; private set; } = []; + + public string SelectedLabel { get; set; } = string.Empty; + + public Action SelectionChanged { get; } + + /// The repository path of the file picked in the list, or empty. + public string SelectedRelativePath => + _pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty; + + /// + /// Redraws the list and the pick list from the manager's files, filtered + /// by the search text on path or name. The list puts the files that need + /// attention first: missing, then outdated, then failed, then current. + /// + public void ShowFiles(IReadOnlyList all) + { + IReadOnlyList files = ApplySearch(all); + + List ordered = [.. files + .OrderBy(static file => file.Status switch + { + MetaFileSyncStatus.NotDownloaded => 0, + MetaFileSyncStatus.UpdateAvailable => 1, + MetaFileSyncStatus.Error => 2, + _ => 3, + }) + .ThenBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)]; + + if (ordered.Count == 0) + { + bool searching = !string.IsNullOrWhiteSpace(SearchText); + FileTypes = ["-"]; + FilePaths = [searching ? "No matches" : "No files loaded"]; + FileStatus = [searching ? "Try a different filter" : "Click Refresh List"]; + FileStatusColors = [0xFFFFFFu]; + } + else + { + FileTypes = [.. ordered.Select(static file => file.Extension.ToUpperInvariant())]; + FilePaths = [.. ordered.Select(static file => file.RelativePath)]; + FileStatus = [.. ordered.Select(static file => file.StatusText)]; + FileStatusColors = [.. ordered.Select(static file => StatusColor(file.Status))]; + } + + string previous = SelectedRelativePath; + _pathByLabel.Clear(); + var labels = new List(); + foreach (MetaFileEntry file in files.OrderBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)) + { + string label = $"[{file.Extension}] {file.RelativePath}"; + labels.Add(label); + _pathByLabel[label] = file.RelativePath; + } + SelectionItems = labels; + SelectedLabel = labels.FirstOrDefault(label => + previous.Length > 0 && _pathByLabel[label].Equals(previous, StringComparison.OrdinalIgnoreCase)) + ?? labels.FirstOrDefault() + ?? string.Empty; + } + + public void ShowLastCheck(DateTime lastStatusCheckUtc, DateTime lastCatalogRefreshUtc) + { + DateTime stamp = lastStatusCheckUtc != DateTime.MinValue ? lastStatusCheckUtc : lastCatalogRefreshUtc; + LastCheckText = stamp == DateTime.MinValue + ? "Last Check: never" + : string.Create( + CultureInfo.InvariantCulture, + $"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}"); + } + + public void ShowPath(string? storageRoot) => + PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}"; + + /// The status text colour: green current, orange outdated, blue missing, red failed. + public static uint StatusColor(MetaFileSyncStatus status) => status switch + { + MetaFileSyncStatus.UpToDate => 0x008000u, + MetaFileSyncStatus.UpdateAvailable => 0xB46E00u, + MetaFileSyncStatus.NotDownloaded => 0x0046A0u, + MetaFileSyncStatus.Error => 0xB40000u, + _ => 0xFFFFFFu, + }; + + private IReadOnlyList ApplySearch(IReadOnlyList files) + { + string filter = SearchText.Trim(); + if (filter.Length == 0) + return files; + return [.. files.Where(file => + file.RelativePath.Contains(filter, StringComparison.OrdinalIgnoreCase) + || file.FileName.Contains(filter, StringComparison.OrdinalIgnoreCase))]; + } +} diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs b/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs new file mode 100644 index 0000000..0a3546a --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Quests/QuestManager.cs @@ -0,0 +1,325 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Quests; + +/// One line of the server's /myquests answer. +internal sealed class Quest +{ + public string Id { get; init; } = string.Empty; + + public int Solves { get; init; } + + /// When the quest was last solved, in Unix seconds. + public int Timestamp { get; init; } + + public string Description { get; init; } = string.Empty; + + public int MaxSolves { get; init; } + + /// The quest's cooldown in seconds. + public int Delta { get; init; } + + /// When the quest can be done again, in Unix seconds. + public int ExpireTime { get; init; } +} + +/// +/// The character's quest timers, read the way the original read them: ask the +/// server with /myquests and, for the next three seconds, parse every +/// chat line that looks like one of its answer lines. Every thirty seconds the +/// timers of the priority quests go to the backend as quest frames. +/// +/// +/// The answer lines stay in the chat window; the original never hid them. +/// Everything here runs on the tick thread: the chat event, the three-second +/// window and the stream are all driven by the host's tick. +/// +internal sealed class QuestManager : IMmFeature +{ + /// How long after asking the chat lines are read as answers. + public static readonly TimeSpan RefreshWindow = TimeSpan.FromSeconds(3); + + /// How often the priority quests are streamed. + public static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(30); + + // One answer line: "stamp - N solves (lastSolved)"message" maxSolves delta". + private static readonly Regex QuestLine = new( + @"([^\-]+) - (\d+) solves \((\d+)\)""([^""]+)"" (-?\d+) (\d+)", + RegexOptions.CultureInvariant); + + private readonly MmContext _context; + private readonly List _quests = []; + private readonly Dictionary _questsById = new(StringComparer.Ordinal); + private readonly Action _onChat; + private IDisposable? _refreshWindow; + private IDisposable? _stream; + private bool _subscribed; + + public QuestManager(MmContext context) + { + _context = context; + _onChat = OnChat; + context.Commands.Register("refreshquests", HandleRefreshQuests, "Refresh quest data"); + context.Commands.Register("queststatus", HandleQuestStatus, "Show quest streaming status"); + } + + /// Raised on the tick thread whenever the quest list changes. + public event Action? QuestsChanged; + + /// Every parsed answer line, in the order the server sent them. + public IReadOnlyList QuestList => _quests; + + /// True while the answer to a /myquests is being read. + public bool IsRefreshing { get; private set; } + + /// True while the thirty-second stream is scheduled. + public bool IsStreaming => _stream is not null; + + public void OnLogin() + { + if (!_subscribed) + { + _context.Automation.Chat.Received += _onChat; + _subscribed = true; + } + ClearQuests(); + RefreshQuests(); + _stream?.Dispose(); + _stream = _context.Scheduler.Every(StreamInterval, StreamPriorityQuests); + } + + public void OnLogoff() + { + _stream?.Dispose(); + _stream = null; + _refreshWindow?.Dispose(); + _refreshWindow = null; + IsRefreshing = false; + if (_subscribed) + { + _context.Automation.Chat.Received -= _onChat; + _subscribed = false; + } + ClearQuests(); + } + + public void Dispose() => OnLogoff(); + + /// + /// Asks the server for the quest list. A request while the previous answer + /// is still being read is ignored, as it was in the original. + /// + public void RefreshQuests() + { + try + { + if (IsRefreshing) + return; + + ClearQuests(); + IsRefreshing = true; + _context.Automation.Chat.Submit("/myquests"); + _refreshWindow = _context.Scheduler.Delay(RefreshWindow, () => + { + IsRefreshing = false; + _refreshWindow = null; + QuestsChanged?.Invoke(); + }); + } + catch (Exception ex) + { + IsRefreshing = false; + _context.Chat.Write($"Error refreshing quests: {ex.Message}"); + } + } + + public void ClearQuests() + { + bool had = _quests.Count > 0; + _quests.Clear(); + _questsById.Clear(); + if (had) + QuestsChanged?.Invoke(); + } + + public bool HasQuestFlag(string questStamp) => _questsById.ContainsKey(questStamp); + + public bool TryGetQuest(string questStamp, out Quest quest) => + _questsById.TryGetValue(questStamp, out quest!); + + /// The priority quests, first answer line per stamp, in answer order. + public IReadOnlyList PriorityQuests() => + [.. _quests + .Where(quest => QuestStreamingService.IsHighPriorityQuest(quest.Id)) + .GroupBy(quest => quest.Id) + .Select(group => group.First())]; + + /// Parses one chat line; a line that is not an answer line is ignored. + internal void ParseQuestLine(string text) + { + try + { + Match match = QuestLine.Match(text); + if (!match.Success) + return; + + int timestamp = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + int delta = int.Parse(match.Groups[6].Value, CultureInfo.InvariantCulture); + var quest = new Quest + { + Id = match.Groups[1].Value.Trim(), + Solves = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture), + Timestamp = timestamp, + Description = match.Groups[4].Value, + MaxSolves = int.Parse(match.Groups[5].Value, CultureInfo.InvariantCulture), + Delta = delta, + // Unchecked, as the original's int sum was. + ExpireTime = unchecked(timestamp + delta), + }; + + _quests.Add(quest); + _questsById[quest.Id] = quest; + QuestsChanged?.Invoke(); + } + catch (Exception ex) + { + _context.Chat.Write($"Error parsing quest line '{text}': {ex.Message}"); + } + } + + /// The now the countdowns are measured from, in Unix seconds. + internal long UnixNow => new DateTimeOffset(DateTime.SpecifyKind(_context.Clock.UtcNow, DateTimeKind.Utc)) + .ToUnixTimeSeconds(); + + /// One run of the thirty-second stream. Internal so tests can run it directly. + internal void StreamPriorityQuests() + { + try + { + _context.Verbose("[QUEST-STREAM] Timer fired, checking conditions..."); + + if (!_context.Settings.Current.WebSocketEnabled) + { + _context.Verbose("[QUEST-STREAM] WebSocket not enabled, skipping"); + return; + } + + if (_quests.Count == 0) + { + _context.Verbose($"[QUEST-STREAM] No quest data available (null: False, count: {_quests.Count})"); + return; + } + + long currentTime = UnixNow; + IReadOnlyList priorityQuests = PriorityQuests(); + _context.Verbose($"[QUEST-STREAM] Found {priorityQuests.Count} priority quests to stream"); + + foreach (Quest quest in priorityQuests) + { + try + { + string questName = QuestNames.GetFriendlyName(quest.Id); + string countdown = QuestStreamingService.FormatCountdown(quest.ExpireTime - currentTime); + _context.Verbose($"[QUEST-STREAM] Sending: {questName} - {countdown}"); + _context.Send(QuestStreamingService.BuildFrame(_context, questName, countdown)); + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST-STREAM] Error streaming quest {quest.Id}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST-STREAM] Error in timer handler: {ex.Message}"); + } + } + + /// Last solved, as the flag tracker shows it: UTC, month first. + public static string FormatTimeStamp(int timestamp) + { + try + { + DateTime dateTime = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; + return dateTime.ToString("MM/dd/yyyy HH:mm:ss", CultureInfo.InvariantCulture); + } + catch (ArgumentOutOfRangeException) + { + return "Invalid"; + } + } + + /// A duration as "1d 2h 3m 4s", leaving out the zero parts. + public static string FormatSeconds(int seconds) + { + if (seconds <= 0) + return "0s"; + + int days = seconds / 86400; + seconds %= 86400; + int hours = seconds / 3600; + seconds %= 3600; + int minutes = seconds / 60; + seconds %= 60; + + string result = string.Empty; + if (days > 0) + result += string.Create(CultureInfo.InvariantCulture, $"{days}d "); + if (hours > 0) + result += string.Create(CultureInfo.InvariantCulture, $"{hours}h "); + if (minutes > 0) + result += string.Create(CultureInfo.InvariantCulture, $"{minutes}m "); + if (seconds > 0 || string.IsNullOrEmpty(result)) + result += string.Create(CultureInfo.InvariantCulture, $"{seconds}s"); + + return result.Trim(); + } + + private void OnChat(PluginChatMessage message) + { + if (!IsRefreshing || string.IsNullOrEmpty(message.Text)) + return; + ParseQuestLine(message.Text); + } + + private void HandleRefreshQuests(string[] args) + { + _ = args; + try + { + _context.Chat.Write("[QUEST] Refreshing quest data..."); + RefreshQuests(); + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST] Refresh failed: {ex.Message}"); + } + } + + private void HandleQuestStatus(string[] args) + { + _ = args; + try + { + _context.Chat.Write("=== Quest Streaming Status ==="); + _context.Chat.Write($"Timer Active: {IsStreaming}"); + _context.Chat.Write($"WebSocket Enabled: {_context.Settings.Current.WebSocketEnabled}"); + _context.Chat.Write($"Quest Manager: {(_context.IsLoggedIn ? "Active" : "Not Active")}"); + _context.Chat.Write($"Quest Count: {_quests.Count}"); + + IReadOnlyList priorityQuests = PriorityQuests(); + _context.Chat.Write($"Priority Quests Found: {priorityQuests.Count}"); + foreach (Quest quest in priorityQuests) + _context.Chat.Write($" - {QuestNames.GetFriendlyName(quest.Id)} ({quest.Id})"); + + _context.Chat.Write($"Verbose Logging: {_context.Settings.Current.VerboseLogging}"); + _context.Chat.Write("Use '/mm verbose' to toggle debug logging"); + } + catch (Exception ex) + { + _context.Chat.Write($"[QUEST] Status check failed: {ex.Message}"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs b/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs new file mode 100644 index 0000000..184871a --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Quests/QuestNames.cs @@ -0,0 +1,211 @@ +namespace OpenAC.MosswartMassacre.Quests; + +/// +/// Friendly names for quest stamps, the table the original plugin shipped. +/// The friendly name is what the backend's quest frame carries as +/// quest_name, so the table is copied as it was, including the stamp +/// listed twice (insatiableeaterjaw): the later entry wins, exactly as +/// the original's indexer initializer made it win. +/// +internal static class QuestNames +{ + public static readonly Dictionary QuestStampToName = new() + { + // Character-specific Quest Stamps (from actual /myquests output) + ["30minattributes"] = "30 Minute Attribute Gems Timer", + ["academeyexittokengiven"] = "Academy Exit Token Received", + ["aerbaxchestkey2pickup"] = "Aerbax Chest Key #2 Pickup", + ["anekshaygemofknowledgetimer_monthly"] = "A'nekshay Gem of Knowledge Monthly Timer", + ["anekshaygemoflesserknowledgecollectedinamonth"] = "A'nekshay Gems of Lesser Knowledge Monthly Count", + ["anekshaygemoflesserknowledgetimer_monthly"] = "A'nekshay Gem of Lesser Knowledge Monthly Timer", + ["attributereset30day"] = "30-Day Attribute Reset Timer", + ["augmentationblankgemacquired"] = "Blank Augmentation Gem Pickup Timer", + ["bellowsnewbieturnedin"] = "Blacksmith's Bellows Turned In", + ["bonecrunchkeypickuptimer"] = "Bonecrunch's Key Pickup Timer", + ["callingstonegiven"] = "Calling Stone Turned Over", + ["defeatedbonecrunch"] = "Bonecrunch Defeated", + ["efmlcentermanafieldused"] = "EF Middle Level Center Mana Field Used", + ["efmleastmanafieldused"] = "EF Middle Level East Mana Field Used", + ["efmlnorthmanafieldused"] = "EF Middle Level North Mana Field Used", + ["efmlsouthmanafieldused"] = "EF Middle Level South Mana Field Used", + ["efmlwestmanafieldused"] = "EF Middle Level West Mana Field Used", + ["efulcentermanafieldused"] = "EF Upper Level Center Mana Field Used", + ["efuleastmanafieldused"] = "EF Upper Level East Mana Field Used", + ["efulnorthmanafieldused"] = "EF Upper Level North Mana Field Used", + ["efulsouthmanafieldused"] = "EF Upper Level South Mana Field Used", + ["efulwestmanafieldused"] = "EF Upper Level West Mana Field Used", + ["insatiableeaterjaw"] = "Insatiable Eater Jaw Collection", + ["pathwardencomplete"] = "Pathwarden Visit Complete", + ["pathwardenfound1111"] = "Pathwarden Greeter Encountered", + ["recallsingularitycaul"] = "Recall Singularity Bore Pickup", + ["stipendscollectedinamonth"] = "Monthly Stipends Collected Count", + ["stipendtimer_0812"] = "Stipend Collection Timer", + ["stipendtimer_monthly"] = "Monthly Stipend Timer", + ["upperinsatiablejaw"] = "Upper Insatiable Eater Jaw Collection", + ["usedattributereset"] = "Attribute Reset Used", + ["usedfreeattributereset"] = "Free Attribute Reset Used", + ["usedfreeskillreset"] = "Free Skill Reset Used", + ["usedskillreset"] = "Skill Reset Used", + ["virindiisland"] = "Singularity Island Visit", + + // Kill Tasks + ["turshscalp"] = "Tursh Scalp", + ["polarursuin"] = "Polar Ursuin Kill Task Main Flag Timer", + ["polarursuinkillcount"] = "Polar Ursuin Kill Counter", + ["polardillotask"] = "Polar Dillo Kill Task Main Flag", + ["polardillokills"] = "Polar Dillo Kill Counter", + ["repugnanteaterkilltask"] = "Repugnant Eater Kill Task", + ["repugeaterkillcount"] = "Repugnant Eater Kill Counter", + ["deathcap"] = "Deathcap Thrungus Kill Task", + ["deathcapkillcount"] = "Deathcap Thrungus Kill Counter", + ["grievverv"] = "Grievver Violator Kill Task", + ["grievvervkillcount"] = "Grievver Violator Kill Counter", + ["tuskerg"] = "Tusker Guard Kill Task Main Flag", + ["tuskergkillcount"] = "Tusker Guard Kill Counter", + + // Quest Timers and Pickups + ["blankaug"] = "Blank Aug Gem Pickup Timer", + ["greatcavepenguinegg"] = "Great Cave Penguin Egg Pickup Timer", + ["deathallurecd"] = "Death's Allure Timer Flag", + ["brewmastercover"] = "Brew Master Quest Pickup Timer Cover", + ["brewmasterback"] = "Brew Master Quest Pickup Timer Back", + ["brewmasterpages"] = "Brew Master Quest Pickup Timer Pages", + ["brewmasterspine"] = "Brew Master Quest Pickup Timer Spine", + ["eleonorasheart"] = "Elanora's Heart Quest Pickup Timer", + ["beacongemobtained"] = "Cooldown for obtaining another beacon gem", + ["beaconcomplete"] = "Beacon Quest Complete Timer", + ["sirginaziosword"] = "Pick up of Sir Ginazio's Sword", + + // Major Quests + ["maraudersjaw"] = "Marauder's Lair Quest", + ["fledgemastertusk"] = "Fledge Master's Tusk Quest", + ["crystallinekiller"] = "Crystalline Killer", + ["darkisledelivery"] = "Dark Isle Delivery", + ["defeatingvaeshok"] = "Defeating Vaeshok", + ["hollyjollyhelperquest"] = "Holly Jolly Helper Quest", + ["moarsmenjailbreak"] = "Moarsmen Jailbreak", + ["shamblingarchivistdestroyer"] = "Shambling Archivist Destroyer", + ["tracingthestone"] = "Tracing The Stone", + ["undeadjawcollection"] = "Undead Jaw Collection", + ["weedingofthederutree"] = "Weeding of the Deru Tree", + ["ironbladecommander"] = "Iron Blade Commander", + ["mumiyahhuntingneftet"] = "Mumiyah Hunting Neftet", + ["torgashstasks"] = "Torgash's Tasks", + + // Thrungus Hovels Items + ["stolenfryingpan"] = "Thrungus Hovels", + ["stolenring"] = "Thrungus Hovels", + ["stolenbrewkettle"] = "Thrungus Hovels", + ["stolenamulet"] = "Thrungus Hovels", + ["stolenewer"] = "Thrungus Hovels", + ["stolennecklace"] = "Thrungus Hovels", + ["stolenplatter"] = "Thrungus Hovels", + ["stolenbracelet"] = "Thrungus Hovels", + + // Special Items and Flags + ["ringofkarlun"] = "Knights of Karlun", + ["trainingacademycomplete"] = "Completion of Training Academy for Exit", + ["cowtipcounter"] = "Counter for Cow Tipping", + ["cowtip"] = "Main Timed Flag for Cow Tipping", + ["skillloweringgempickedup"] = "Picked up a forgetfulness gem", + + // Healing Machine Components + ["orbhealingmachine"] = "Healing Machine Orb", + ["pedestalhealingmachine"] = "Healing Machine Pedestal", + ["tihnhealingmachine"] = "Healing Machine Tihn", + ["lavushealingmachine"] = "Healing Machine Lavus", + ["hookhealingmachine"] = "Healing Machine Hook", + + // Eater Jaws + ["ravenouseaterjaw"] = "Ravenous Eater Jaw", + ["insatiableeaterjaw"] = "Insatiable Eater Jaw", + ["engorgedeaterjaw"] = "Engorged Eater Jaw", + ["voraciouseaterjaw"] = "Voracious Eater Jaw", + ["abhorrenteaterjaw"] = "Abhorrent Eater Jaw", + + // Kill Tasks (Extended) + ["altereddrudgekilltask"] = "Altered Drudge Kill Task", + ["altereddrudgekillcount"] = "Altered Drudge Kill Counter", + ["arcticmattekarkilltask"] = "Arctic Mattekar Kill Task", + ["arcticmattekarkillcount"] = "Arctic Mattekar Kill Counter", + ["armoredillohuntingneftetkilltask"] = "Armoredillo Hunting Neftet Kill Task", + ["armoredillohuntingneftetkillcount"] = "Armoredillo Hunting Neftet Kill Counter", + ["augmenteddrudgekilltask"] = "Augmented Drudge Kill Task", + ["augmenteddrudgekillcount"] = "Augmented Drudge Kill Counter", + ["banishedcreaturekilltask"] = "Banished Creature Kill Task", + ["banishedcreaturekillcount"] = "Banished Creature Kill Counter", + ["benekniffiskilltask"] = "Benek Niffis Kill Task", + ["benekniffiskillcount"] = "Benek Niffis Kill Counter", + ["blackcoralgolemkilltask"] = "Black Coral Golem Kill Task", + ["blackcoralgolemkillcount"] = "Black Coral Golem Kill Counter", + ["blessedmoarsmankilltask"] = "Blessed Moarsman Kill Task", + ["blessedmoarsmankillcount"] = "Blessed Moarsman Kill Counter", + ["blightedcoralgolemkilltask"] = "Blighted Coral Golem Kill Task", + ["blightedcoralgolemkillcount"] = "Blighted Coral Golem Kill Counter", + ["bloodshrethkilltask"] = "Blood Shreth Kill Task", + ["bloodshrethkillcount"] = "Blood Shreth Kill Counter", + ["bronzegauntlettrooperkilltask"] = "Bronze Gauntlet Trooper Kill Task", + ["bronzegauntlettrooperkillcount"] = "Bronze Gauntlet Trooper Kill Counter", + ["coppercogtrooperkilltask"] = "Copper Cog Trooper Kill Task", + ["coppercogtrooperkillcount"] = "Copper Cog Trooper Kill Counter", + ["coppergolemkingpinkilltask"] = "Copper Golem Kingpin Kill Task", + ["coppergolemkingpinkillcount"] = "Copper Golem Kingpin Kill Counter", + ["coralgolemkilltask"] = "Coral Golem Kill Task", + ["coralgolemkillcount"] = "Coral Golem Kill Counter", + ["coralgolemviceroykilltask"] = "Coral Golem Viceroy Kill Task", + ["coralgolemviceroykillcount"] = "Coral Golem Viceroy Kill Counter", + ["corruptedgravestonekilltask"] = "Corrupted Gravestone Kill Task", + ["corruptedgravestonekillcount"] = "Corrupted Gravestone Kill Counter", + ["deathcapthrunguskilltask"] = "Deathcap Thrungus Kill Task", + ["deathcapthrunguskillcount"] = "Deathcap Thrungus Kill Counter", + ["desertcactuskilltask"] = "Desert Cactus Kill Task", + ["desertcactuskillcount"] = "Desert Cactus Kill Counter", + ["devourermargulkilltask"] = "Devourer Margul Kill Task", + ["devourermargulkillcount"] = "Devourer Margul Kill Counter", + + // Society and Faction Quests + ["celestialhandintroductioncomplete"] = "Celestial Hand Introduction Complete", + ["eldrytchwebintroductioncomplete"] = "Eldrytch Web Introduction Complete", + ["radiantbloodintroductioncomplete"] = "Radiant Blood Introduction Complete", + ["celestialhandinitiatetest"] = "Celestial Hand Initiate Test", + ["eldrytchwebinitiatetest"] = "Eldrytch Web Initiate Test", + ["radiantbloodinitiatetest"] = "Radiant Blood Initiate Test", + + // Luminance Aura Related + ["aetheriaredemption"] = "Aetheria Redemption", + ["aegisofmerc"] = "Aegis of Merc", + ["lumaugtradein"] = "Luminance Augmentation Trade In", + + // Common AC Quests + ["holtburgtraderskill"] = "Holtburg Trader Skill Quest", + ["shoushitraderskill"] = "Shoushi Trader Skill Quest", + ["yaraqtraderskill"] = "Yaraq Trader Skill Quest", + ["newbiequests"] = "Newbie Academy Quests", + ["moarsmanraid"] = "Moarsman Raid", + ["virindiparadox"] = "Virindi Paradox", + ["portalspace"] = "Portal Space Exploration" + }; + + /// The friendly name for a stamp, or the stamp itself when the table has none. + public static string GetFriendlyName(string questStamp) + { + if (string.IsNullOrEmpty(questStamp)) + return questStamp; + + return QuestStampToName.TryGetValue(questStamp.ToLower(System.Globalization.CultureInfo.InvariantCulture), out string? friendlyName) + ? friendlyName + : questStamp; + } + + /// The friendly name with the stamp in parentheses, or the stamp alone. + public static string GetDisplayName(string questStamp) + { + if (string.IsNullOrEmpty(questStamp)) + return questStamp; + + string friendlyName = GetFriendlyName(questStamp); + return !string.Equals(friendlyName, questStamp, StringComparison.OrdinalIgnoreCase) + ? $"{friendlyName} ({questStamp})" + : questStamp; + } +} diff --git a/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs b/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs new file mode 100644 index 0000000..0a2bcb7 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using Newtonsoft.Json; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Quests; + +/// +/// The quest frame: type, timestamp, character_name, +/// quest_name, countdown, in that order, all strings. The +/// original wrote it from an anonymous object with those five members in that +/// order; this class declares them in the same order so Newtonsoft writes the +/// same bytes. +/// +internal sealed class QuestFrame +{ + [JsonProperty("type")] + public string Type { get; } = "quest"; + + [JsonProperty("timestamp")] + public string Timestamp { get; init; } = string.Empty; + + [JsonProperty("character_name")] + public string CharacterName { get; init; } = string.Empty; + + [JsonProperty("quest_name")] + public string QuestName { get; init; } = string.Empty; + + [JsonProperty("countdown")] + public string Countdown { get; init; } = string.Empty; +} + +/// Which quests stream, how their countdown is written, and the frame they go in. +internal static class QuestStreamingService +{ + /// The three stamps the original streamed, and only those. + public static bool IsHighPriorityQuest(string questId) => + questId == "stipendtimer_0812" + || questId == "augmentationblankgemacquired" + || questId == "insatiableeaterjaw"; + + /// + /// The countdown text: READY at or past zero, then the two largest + /// units, the smaller one zero-padded: 1d 02h, 3h 05m, + /// 4m 09s, 59s. The backend shows this string as it is. + /// + public static string FormatCountdown(long seconds) + { + if (seconds <= 0) + return "READY"; + + TimeSpan timeSpan = TimeSpan.FromSeconds(seconds); + + if (timeSpan.TotalDays >= 1) + return string.Create(CultureInfo.InvariantCulture, $"{(int)timeSpan.TotalDays}d {timeSpan.Hours:D2}h"); + if (timeSpan.TotalHours >= 1) + return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Hours}h {timeSpan.Minutes:D2}m"); + if (timeSpan.TotalMinutes >= 1) + return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Minutes}m {timeSpan.Seconds:D2}s"); + return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Seconds}s"); + } + + public static QuestFrame BuildFrame(MmContext context, string questName, string countdown) => + new() + { + Timestamp = Wire.Timestamp(context.Clock), + CharacterName = context.CharacterName, + QuestName = questName, + Countdown = countdown, + }; +} diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml new file mode 100644 index 0000000..1e731de --- /dev/null +++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + +