Flag tracker: the Mossy Tracker window as a markup panel

The owner calls the flag tracker important, so the original's pages come
across with their tables unchanged: augmentations and luminance auras from
the player's own int properties (the object table carries them), recalls
from the spellbook with composited spell icons, cantrips matched from the
active enchantments to trained skills, attributes and wards, the tracked
weapons the original kept data for but never showed, and the quest timers.
Read on the tick thread at login and on each page's Refresh; the quest
page redraws every five seconds instead of from a thread-pool timer.

The root panel's visibility is the settable IsVisible so the main window
can open it; the three sections the original left as stubs stay out, and a
headless host registers nothing. The fake spell catalog learns IsKnown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:20:52 +02:00
parent 0b5da797e0
commit 714fbfa2e8
8 changed files with 1735 additions and 0 deletions

View file

@ -0,0 +1,672 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.FlagTracker;
/// <summary>
/// What the flag tracker shows, and how each tab reads it from the character:
/// augmentation and luminance counts from the player's own integer properties,
/// recalls from the spellbook, cantrips from the active enchantments matched
/// against the trained skills, and the tracked weapons from the inventory by
/// name. The tables are the original plugin's, copied as they were.
/// </summary>
/// <remarks>
/// The original's society, facility hub and character flag sections were
/// never implemented there and are not here. Every read happens on the tick
/// thread, when a tab is refreshed.
/// </remarks>
internal sealed class FlagTrackerData
{
/// <summary>Row text colours, <c>0xRRGGBB</c>, the named colours the original used.</summary>
public static class Colors
{
public const uint White = 0xFFFFFF;
public const uint Red = 0xFF0000;
public const uint Green = 0x008000;
public const uint Yellow = 0xFFFF00;
public const uint Blue = 0x0000FF;
public const uint Purple = 0x800080;
public const uint Orange = 0xFFA500;
}
/// <summary>Icons the original drew: the cantrip lamps, the category star and the portal fallback.</summary>
public static class Icons
{
public const uint GreenCircle = 0x060011F9;
public const uint RedCircle = 0x060011F8;
public const uint CategoryStar = 0x06002856;
public const uint Portal = 0x06002D14;
}
public const string NotAvailable = "N/A";
public sealed class AugmentationInfo
{
public required string Name { get; init; }
public uint? IntId { get; init; }
public int Repeatable { get; init; }
public string Trainer { get; init; } = string.Empty;
public string Location { get; init; } = string.Empty;
public int CurrentValue { get; set; }
public bool IsMaxed => CurrentValue >= Repeatable;
}
public sealed class LuminanceAuraInfo
{
public required string Name { get; init; }
public uint IntId { get; init; }
public int Cap { get; init; }
/// <summary>The seer's loyalty flag, for the seer auras only.</summary>
public string? QuestFlag { get; init; }
public int CurrentValue { get; set; }
}
public sealed class RecallSpellInfo
{
public required string Name { get; init; }
public uint SpellId { get; init; }
public string Category { get; init; } = string.Empty;
public bool IsKnown { get; set; }
}
public sealed class CantripInfo
{
public required string Name { get; init; }
/// <summary>"Minor" through "Legendary", or "N/A" when the character has none.</summary>
public string Value { get; set; } = NotAvailable;
public uint Color { get; set; } = Colors.White;
/// <summary>The green lamp when the cantrip is up, the red one when it is not.</summary>
public uint ComputedIconId { get; set; }
}
public sealed class TrackedWeaponInfo
{
public required string Name { get; init; }
public string Category { get; init; } = string.Empty;
public string WeaponType { get; init; } = string.Empty;
public int ItemId { get; init; }
public string Status { get; set; } = "Unknown";
public bool IsAcquired { get; set; }
public uint IconId { get; set; }
}
// Cantrip skill names that differ from the skill's own name.
private static readonly Dictionary<uint, string> SkillCantripReplacements = new()
{
[15] = "MagicResistance",
[6] = "Invulnerability",
[7] = "Impgrenability",
[47] = "MissileWeapon",
[44] = "HeavyWeapon",
[45] = "LightWeapon",
[46] = "FinesseWeapon",
};
private static readonly (uint Id, string Name)[] SkillIdToName =
[
(1, "Axe"), (2, "Bow"), (3, "Crossbow"), (4, "Dagger"), (5, "Mace"),
(6, "Melee Defense"), (7, "Missile Defense"), (8, "Sling"), (9, "Spear"),
(10, "Staff"), (11, "Sword"), (12, "Thrown Weapons"), (13, "Unarmed Combat"),
(14, "Arcane Lore"), (15, "Magic Defense"), (16, "Mana Conversion"),
(17, "Spellcraft"), (18, "Item Tinkering"), (19, "Assess Person"),
(20, "Deception"), (21, "Healing"), (22, "Jump"), (23, "Lockpick"),
(24, "Run"), (25, "Awareness"), (26, "Arms and Armor Repair"),
(27, "Assess Creature"), (28, "Weapon Tinkering"), (29, "Armor Tinkering"),
(30, "Magic Item Tinkering"), (31, "Creature Enchantment"),
(32, "Item Enchantment"), (33, "Life Magic"), (34, "War Magic"),
(35, "Leadership"), (36, "Loyalty"), (37, "Fletching"), (38, "Alchemy"),
(39, "Cooking"), (40, "Salvaging"), (41, "Two Handed Combat"),
(42, "Gearcraft"), (43, "Void Magic"), (44, "Heavy Weapons"),
(45, "Light Weapons"), (46, "Finesse Weapons"), (47, "Missile Weapons"),
(48, "Shield"), (49, "Dual Wield"), (50, "Recklessness"),
(51, "Sneak Attack"), (52, "Dirty Fighting"), (53, "Threat Assessment"),
(54, "Summoning"),
];
private static readonly (string Prefix, uint Color)[] CantripPatterns =
[
("Minor", Colors.White),
("Moderate", Colors.Green),
("Major", Colors.Blue),
("Epic", Colors.Purple),
("Legendary", Colors.Orange),
];
private static readonly (string Key, string Aura)[] ProtectionMappings =
[
("Armor", "Armor"),
("Bludgeoning Ward", "Bludgeoning Ward"),
("Piercing Ward", "Piercing Ward"),
("Slashing Ward", "Slashing Ward"),
("Flame Ward", "Flame Ward"),
("Frost Ward", "Frost Ward"),
("Cold Ward", "Frost Ward"),
("Acid Ward", "Acid Ward"),
("Storm Ward", "Storm Ward"),
("Lightning Ward", "Storm Ward"),
("Bludgeoning Protection", "Bludgeoning Ward"),
("Piercing Protection", "Piercing Ward"),
("Slashing Protection", "Slashing Ward"),
("Fire Protection", "Flame Ward"),
("Cold Protection", "Frost Ward"),
("Acid Protection", "Acid Ward"),
("Lightning Protection", "Storm Ward"),
("Bludgeoning", "Bludgeoning Ward"),
("Piercing", "Piercing Ward"),
("Slashing", "Slashing Ward"),
("Fire", "Flame Ward"),
("Flame", "Flame Ward"),
("Cold", "Frost Ward"),
("Frost", "Frost Ward"),
("Acid", "Acid Ward"),
("Lightning", "Storm Ward"),
("Storm", "Storm Ward"),
];
private static readonly (string Key, string Attribute)[] AttributeMappings =
[
("Strength", "Strength"),
("Endurance", "Endurance"),
("Coordination", "Coordination"),
("Quickness", "Quickness"),
("Focus", "Focus"),
("Self", "Willpower"),
("Willpower", "Willpower"),
];
private static readonly (string Key, string Skill)[] SkillMappings =
[
("Magic Resistance", "MagicResistance"),
("Invulnerability", "Invulnerability"),
("Impregnability", "Impgrenability"),
("Heavy Weapon Aptitude", "HeavyWeapon"),
("Light Weapon Aptitude", "LightWeapon"),
("Finesse Weapon Aptitude", "FinesseWeapon"),
("Missile Weapon Aptitude", "MissileWeapon"),
("Alchemical Prowess", "Alchemy"),
("Arcane Prowess", "Arcane Lore"),
("Armor Tinkering Expertise", "Armor Tinkering"),
("Assess Creature", "Assess Creature"),
("Assess Person", "Assess Person"),
("Cooking Prowess", "Cooking"),
("Deception Prowess", "Deception"),
("Fletching Prowess", "Fletching"),
("Healing Prowess", "Healing"),
("Item Tinkering Expertise", "Item Tinkering"),
("Leadership", "Leadership"),
("Lockpick Prowess", "Lockpick"),
("Fealty", "Loyalty"),
("Magic Item Tinkering Expertise", "Magic Item Tinkering"),
("Mana Conversion Prowess", "Mana Conversion"),
("Jumping Prowess", "Jump"),
("Salvaging Aptitude", "Salvaging"),
("Weapon Tinkering Expertise", "Weapon Tinkering"),
("War Magic Aptitude", "War Magic"),
("Life Magic Aptitude", "Life Magic"),
("Creature Enchantment Aptitude", "Creature Enchantment"),
("Item Enchantment Aptitude", "Item Enchantment"),
("Void Magic Aptitude", "Void Magic"),
("Summoning Prowess", "Summoning"),
("Two Handed Combat Aptitude", "Two Handed Combat"),
("Dual Wield Aptitude", "Dual Wield"),
("Shield Aptitude", "Shield"),
("Sneak Attack Prowess", "Sneak Attack"),
("Dirty Fighting Prowess", "Dirty Fighting"),
("Recklessness", "Recklessness"),
];
private static readonly Dictionary<string, int> CantripRank = new(StringComparer.Ordinal)
{
["Minor"] = 1,
["Moderate"] = 2,
["Major"] = 3,
["Epic"] = 4,
["Legendary"] = 5,
};
private readonly IAutomationSurface _automation;
public FlagTrackerData(IAutomationSurface automation)
{
_automation = automation;
AugmentationCategories = BuildAugmentations();
LuminanceAuraCategories = BuildLuminanceAuras();
RecallSpells = BuildRecalls();
Cantrips = BuildCantrips();
WeaponCategories = BuildWeapons();
}
public Dictionary<string, List<AugmentationInfo>> AugmentationCategories { get; }
public Dictionary<string, List<LuminanceAuraInfo>> LuminanceAuraCategories { get; }
public List<RecallSpellInfo> RecallSpells { get; }
public Dictionary<string, Dictionary<string, CantripInfo>> Cantrips { get; }
public Dictionary<string, List<TrackedWeaponInfo>> WeaponCategories { get; private set; }
private bool InWorld => _automation.IsAvailable && _automation.Character.IsInWorld;
public void RefreshAll()
{
RefreshAugmentations();
RefreshLuminanceAuras();
RefreshRecallSpells();
RefreshCantrips();
RefreshWeapons();
}
public void RefreshAugmentations()
{
if (!InWorld)
return;
foreach (List<AugmentationInfo> augmentations in AugmentationCategories.Values)
{
foreach (AugmentationInfo aug in augmentations)
{
// An augmentation with no property (the health one) keeps its value, as it did.
if (aug.IntId is uint id)
aug.CurrentValue = ReadCharacterInt(id);
}
}
}
public void RefreshLuminanceAuras()
{
if (!InWorld)
return;
foreach (List<LuminanceAuraInfo> auras in LuminanceAuraCategories.Values)
{
foreach (LuminanceAuraInfo aura in auras)
aura.CurrentValue = ReadCharacterInt(aura.IntId);
}
}
public void RefreshRecallSpells()
{
if (!InWorld)
return;
foreach (RecallSpellInfo recall in RecallSpells)
recall.IsKnown = _automation.Spells.IsKnown(recall.SpellId);
}
public void RefreshCantrips()
{
if (!InWorld)
return;
Cantrips["Specialized Skills"].Clear();
Cantrips["Trained Skills"].Clear();
PopulateCharacterSkills();
foreach (Dictionary<string, CantripInfo> category in Cantrips.Values)
{
foreach (CantripInfo cantrip in category.Values)
{
cantrip.Value = NotAvailable;
cantrip.Color = Colors.White;
}
}
foreach (PluginActiveEnchantment enchantment in _automation.Character.ActiveEnchantments)
{
if (_automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell)
&& !string.IsNullOrEmpty(spell.Name))
{
DetectCantrip(spell.Name);
}
}
foreach (Dictionary<string, CantripInfo> category in Cantrips.Values)
{
foreach (CantripInfo cantrip in category.Values)
cantrip.ComputedIconId = cantrip.Value != NotAvailable ? Icons.GreenCircle : Icons.RedCircle;
}
}
public void RefreshWeapons()
{
if (!InWorld)
return;
WeaponCategories = BuildWeapons();
IReadOnlyList<PluginInventoryItem> inventory = _automation.Items.CaptureOwnedItems();
foreach (List<TrackedWeaponInfo> weapons in WeaponCategories.Values)
{
foreach (TrackedWeaponInfo weapon in weapons)
{
// Matched by name either way round, as the original matched
// it; an item with no name matches nothing.
PluginInventoryItem? found = null;
foreach (PluginInventoryItem item in inventory)
{
if (string.IsNullOrEmpty(item.Name))
continue;
if (item.Name.Contains(weapon.Name, StringComparison.Ordinal)
|| weapon.Name.Contains(item.Name, StringComparison.Ordinal))
{
found = item;
break;
}
}
if (found is { } match)
{
weapon.IsAcquired = true;
weapon.Status = "Acquired";
weapon.IconId = match.IconId;
}
else
{
weapon.IsAcquired = false;
weapon.Status = "Not Acquired";
weapon.IconId = Icons.Portal;
}
}
}
}
/// <summary>
/// Files one active spell under its cantrip row when its name is a
/// cantrip's: "Epic Flame Ward", "Major Strength", "Legendary War Magic
/// Aptitude". A higher level replaces a lower one; anything else is left.
/// </summary>
internal void DetectCantrip(string spellName)
{
foreach ((string prefix, uint color) in CantripPatterns)
{
if (!spellName.StartsWith(prefix + " ", StringComparison.Ordinal))
continue;
string skillPart = spellName[(prefix.Length + 1)..];
if (MatchProtectionAura(skillPart, prefix, color))
return;
if (MatchAttribute(skillPart, prefix, color))
return;
if (MatchSkill(skillPart, prefix, color))
return;
}
}
private int ReadCharacterInt(uint property) =>
_automation.Objects.TryGetIntProperty(_automation.Character.ObjectId, property, out int value)
? value
: 0;
private void PopulateCharacterSkills()
{
foreach ((uint skillId, string name) in SkillIdToName)
{
if (!_automation.Character.TryGetSkill(skillId, out PluginSkillInfo skill))
continue;
string skillName = SkillCantripReplacements.TryGetValue(skillId, out string? replacement)
? replacement
: name;
if (skill.Training == PluginSkillTraining.Specialized)
Cantrips["Specialized Skills"][skillName] = new CantripInfo { Name = skillName };
else if (skill.Training == PluginSkillTraining.Trained)
Cantrips["Trained Skills"][skillName] = new CantripInfo { Name = skillName };
}
}
private bool MatchProtectionAura(string skillPart, string level, uint color)
{
foreach ((string key, string aura) in ProtectionMappings)
{
if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase))
continue;
Raise(Cantrips["Protection Auras"], aura, level, color);
return true;
}
return false;
}
private bool MatchAttribute(string skillPart, string level, uint color)
{
string cleaned = skillPart.Trim();
foreach ((string key, string attribute) in AttributeMappings)
{
if (!cleaned.Equals(key, StringComparison.OrdinalIgnoreCase))
continue;
Raise(Cantrips["Attributes"], attribute, level, color);
return true;
}
// Then the looser match the original fell back to: the name contains an attribute.
foreach ((string key, string attribute) in AttributeMappings)
{
if (cleaned.IndexOf(key, StringComparison.OrdinalIgnoreCase) < 0)
continue;
Raise(Cantrips["Attributes"], attribute, level, color);
return true;
}
return false;
}
private bool MatchSkill(string skillPart, string level, uint color)
{
foreach ((string key, string skill) in SkillMappings)
{
if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase))
continue;
foreach (string category in new[] { "Specialized Skills", "Trained Skills" })
{
if (!Cantrips[category].TryGetValue(skill, out CantripInfo? cantrip))
continue;
if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value))
{
cantrip.Value = level;
cantrip.Color = color;
}
return true;
}
}
return false;
}
private static void Raise(Dictionary<string, CantripInfo> category, string name, string level, uint color)
{
if (!category.TryGetValue(name, out CantripInfo? cantrip))
{
cantrip = new CantripInfo { Name = name };
category[name] = cantrip;
}
if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value))
{
cantrip.Value = level;
cantrip.Color = color;
}
}
private static bool IsHigherCantripLevel(string newLevel, string currentLevel) =>
CantripRank.TryGetValue(newLevel, out int next)
&& CantripRank.TryGetValue(currentLevel, out int current)
&& next > current;
private static Dictionary<string, List<AugmentationInfo>> BuildAugmentations() => new()
{
["Death Augs"] =
[
new() { Name = "Keep Items", IntId = 231, Repeatable = 3, Trainer = "Rohula bint Ludun", Location = "Ayan Baqur" },
new() { Name = "Keep Spells", IntId = 232, Repeatable = 1, Trainer = "Erik Festus", Location = "Ayan Baqur" },
],
["Skill Augs"] =
[
new() { Name = "+5 All Skills", IntId = 326, Repeatable = 1, Trainer = "Arianna the Adept", Location = "Bandit Castle" },
new() { Name = "+10 Melee Skills", IntId = 300, Repeatable = 1, Trainer = "Carlito Gallo", Location = "Silyun" },
new() { Name = "+10 Magic Skills", IntId = 302, Repeatable = 1, Trainer = "Rahina bint Zalanis", Location = "Zaikhal" },
new() { Name = "+10 Missile Skills", IntId = 301, Repeatable = 1, Trainer = "Kilaf", Location = "Zaikhal" },
],
["Rating Augs"] =
[
new() { Name = "25% Crit Protection", IntId = 233, Repeatable = 1, Trainer = "Piersanti Linante", Location = "Sanamar" },
new() { Name = "1% Critical Chance", IntId = 298, Repeatable = 1, Trainer = "Anfram Mellow", Location = "Ayan Baqur" },
new() { Name = "3% Critical Damage", IntId = 299, Repeatable = 1, Trainer = "Alishia bint Aldan", Location = "Ayan Baqur" },
new() { Name = "3% Damage Rating", IntId = 309, Repeatable = 1, Trainer = "Neela Nashua", Location = "Bandit Castle" },
new() { Name = "3% Damage Reduction", IntId = 310, Repeatable = 1, Trainer = "Emily Yarow", Location = "Cragstone" },
],
["Burden / Pack Augs"] =
[
new() { Name = "Extra Carrying Capacity", IntId = 230, Repeatable = 5, Trainer = "Husoon", Location = "Zaikhal" },
new() { Name = "Extra Pack Slot", IntId = 229, Repeatable = 1, Trainer = "Dumida bint Ruminre", Location = "Zaikhal" },
new() { Name = "Infused War Magic", IntId = 297, Repeatable = 1, Trainer = "Raphel Detante", Location = "Silyun" },
new() { Name = "Infused Void Magic", IntId = 328, Repeatable = 1, Trainer = "Morathe", Location = "Candeth Keep" },
new() { Name = "Infused Creature Magic", IntId = 294, Repeatable = 1, Trainer = "Gustuv Lansdown", Location = "Cragstone" },
new() { Name = "Infused Life Magic", IntId = 296, Repeatable = 1, Trainer = "Akemi Fei", Location = "Hebian-To" },
new() { Name = "Infused Item Magic", IntId = 295, Repeatable = 1, Trainer = "Gan Fo", Location = "Hebian-To" },
],
["Misc Augs"] =
[
new() { Name = "10% Health Increase", IntId = null, Repeatable = 1, Trainer = "Donatello Linante", Location = "Silyun" },
new() { Name = "Increased Spell Duration", IntId = 238, Repeatable = 5, Trainer = "Nawamara Ujio", Location = "Mayoi" },
new() { Name = "Faster HP Regen", IntId = 237, Repeatable = 2, Trainer = "Alison Dulane", Location = "Bandit Castle" },
new() { Name = "5% Experience Increase", IntId = 234, Repeatable = 1, Trainer = "Rickard Dumalia", Location = "Silyun" },
],
["Salvage Augs"] =
[
new() { Name = "Specialized Weapon Tinkering", IntId = 228, Repeatable = 1, Trainer = "Lenor Turk", Location = "Cragstone" },
new() { Name = "Specialized Armor Tinkering", IntId = 226, Repeatable = 1, Trainer = "Joshun Felden", Location = "Cragstone" },
new() { Name = "Specialized Item Tinkering", IntId = 225, Repeatable = 1, Trainer = "Brienne Carlus", Location = "Cragstone" },
new() { Name = "Specialized Magic Item Tinkering", IntId = 227, Repeatable = 1, Trainer = "Burrell Sammrun", Location = "Cragstone" },
new() { Name = "Specialized Salvaging", IntId = 224, Repeatable = 1, Trainer = "Robert Crow", Location = "Cragstone" },
new() { Name = "25% More Salvage", IntId = 235, Repeatable = 4, Trainer = "Kris Cennis", Location = "Cragstone" },
new() { Name = "5% Imbue Chance", IntId = 236, Repeatable = 1, Trainer = "Lug", Location = "Oolutanga's Refuge" },
],
["Stat Augs"] =
[
new() { Name = "All Stats", IntId = 217, Repeatable = 10, Trainer = "", Location = "" },
new() { Name = "Strength", IntId = 218, Repeatable = 10, Trainer = "Fiun Luunere", Location = "Fiun Outpost" },
new() { Name = "Endurance", IntId = 219, Repeatable = 10, Trainer = "Fiun Ruun", Location = "Fiun Outpost" },
new() { Name = "Coordination", IntId = 220, Repeatable = 10, Trainer = "Fiun Bayaas", Location = "Fiun Outpost" },
new() { Name = "Quickness", IntId = 221, Repeatable = 10, Trainer = "Fiun Riish", Location = "Fiun Outpost" },
new() { Name = "Focus", IntId = 222, Repeatable = 10, Trainer = "Fiun Vasherr", Location = "Fiun Outpost" },
new() { Name = "Self", IntId = 223, Repeatable = 10, Trainer = "Fiun Noress", Location = "Fiun Outpost" },
],
["Resistance Augs"] =
[
new() { Name = "All Resistances", IntId = 239, Repeatable = 2, Trainer = "", Location = "" },
new() { Name = "Blunt", IntId = 242, Repeatable = 2, Trainer = "Nawamara Dia", Location = "Hebian-To" },
new() { Name = "Pierce", IntId = 241, Repeatable = 2, Trainer = "Kyujo Rujen", Location = "Hebian-To" },
new() { Name = "Slashing", IntId = 240, Repeatable = 2, Trainer = "Ilin Wis", Location = "Hebian-To" },
new() { Name = "Fire", IntId = 244, Repeatable = 2, Trainer = "Rikshen Ri", Location = "Hebian-To" },
new() { Name = "Frost", IntId = 245, Repeatable = 2, Trainer = "Lu Bao", Location = "Hebian-To" },
new() { Name = "Acid", IntId = 243, Repeatable = 2, Trainer = "Shujio Milao", Location = "Hebian-To" },
new() { Name = "Lightning", IntId = 246, Repeatable = 2, Trainer = "Enli Yuo", Location = "Hebian-To" },
],
};
private static Dictionary<string, List<LuminanceAuraInfo>> BuildLuminanceAuras() => new()
{
["Nalicana Auras"] =
[
new() { Name = "+1 Aetheria Proc Rating", IntId = 338, Cap = 5 },
new() { Name = "+1 Damage Reduction Rating", IntId = 334, Cap = 5 },
new() { Name = "+1 Crit Reduction Rating", IntId = 336, Cap = 5 },
new() { Name = "+1 Damage Rating", IntId = 333, Cap = 5 },
new() { Name = "+1 Crit Damage Rating", IntId = 335, Cap = 5 },
new() { Name = "+1 Heal Rating", IntId = 342, Cap = 5 },
new() { Name = "+1 Equipment Mana Rating", IntId = 339, Cap = 5 },
new() { Name = "+1 Mana Stone Rating", IntId = 340, Cap = 5 },
new() { Name = "+1 Crafting Skills", IntId = 343, Cap = 5 },
new() { Name = "+1 All Skills", IntId = 365, Cap = 10 },
],
["Seer Auras"] =
[
new() { Name = "(Ka'hiri) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToKahiri" },
new() { Name = "(Ka'hiri) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToKahiri" },
new() { Name = "(Shade of Lady Adja) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" },
new() { Name = "(Shade of Lady Adja) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" },
new() { Name = "(Liam of Gelid) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" },
new() { Name = "(Liam of Gelid) +1 Crit Damage Rating", IntId = 335, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" },
new() { Name = "(Lord Tyragar) +1 Crit Reduction Rating", IntId = 336, Cap = 5, QuestFlag = "LoyalToLordTyragar" },
new() { Name = "(Lord Tyragar) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToLordTyragar" },
],
};
private static List<RecallSpellInfo> BuildRecalls() =>
[
new() { Name = "Recall the Sanctuary", SpellId = 2023, Category = "Basic Recalls" },
new() { Name = "Aerlinthe Recall", SpellId = 2041, Category = "Island Recalls" },
new() { Name = "Mount Lethe Recall", SpellId = 2813, Category = "Island Recalls" },
new() { Name = "Recall Aphus Lassel", SpellId = 2931, Category = "Island Recalls" },
new() { Name = "Ulgrim's Recall", SpellId = 2941, Category = "Special Recalls" },
new() { Name = "Recall to the Singularity Caul", SpellId = 2943, Category = "Island Recalls" },
new() { Name = "Glenden Wood Recall", SpellId = 3865, Category = "Town Recalls" },
new() { Name = "Bur Recall", SpellId = 4084, Category = "Town Recalls" },
new() { Name = "Call of the Mhoire Forge", SpellId = 4128, Category = "Special Recalls" },
new() { Name = "Paradox-touched Olthoi Infested Area Recall", SpellId = 4198, Category = "Special Recalls" },
new() { Name = "Colosseum Recall", SpellId = 4213, Category = "Special Recalls" },
new() { Name = "Return to the Keep", SpellId = 4214, Category = "Special Recalls" },
new() { Name = "Gear Knight Invasion Area Camp Recall", SpellId = 5330, Category = "Special Recalls" },
new() { Name = "Lost City of Neftet Recall", SpellId = 5541, Category = "Special Recalls" },
new() { Name = "Rynthid Recall", SpellId = 6150, Category = "Special Recalls" },
new() { Name = "Viridian Rise Recall", SpellId = 6321, Category = "Special Recalls" },
new() { Name = "Viridian Rise Great Tree Recall", SpellId = 6322, Category = "Special Recalls" },
];
private static Dictionary<string, Dictionary<string, CantripInfo>> BuildCantrips() => new()
{
["Specialized Skills"] = [],
["Trained Skills"] = [],
["Attributes"] = [],
["Protection Auras"] = new()
{
// Listed up front so a missing ward shows as missing.
["Armor"] = new() { Name = "Armor" },
["Bludgeoning Ward"] = new() { Name = "Bludgeoning Ward" },
["Piercing Ward"] = new() { Name = "Piercing Ward" },
["Slashing Ward"] = new() { Name = "Slashing Ward" },
["Flame Ward"] = new() { Name = "Flame Ward" },
["Frost Ward"] = new() { Name = "Frost Ward" },
["Acid Ward"] = new() { Name = "Acid Ward" },
["Storm Ward"] = new() { Name = "Storm Ward" },
},
};
private static Dictionary<string, List<TrackedWeaponInfo>> BuildWeapons() => new()
{
["Legendary Weapons"] =
[
new() { Name = "Bow of the Quiddity", Category = "Legendary Weapons", WeaponType = "Bow", ItemId = 23044 },
new() { Name = "Sword of the Quiddity", Category = "Legendary Weapons", WeaponType = "Sword", ItemId = 23039 },
new() { Name = "Mace of the Quiddity", Category = "Legendary Weapons", WeaponType = "Mace", ItemId = 23040 },
new() { Name = "Atlatl of the Quiddity", Category = "Legendary Weapons", WeaponType = "Atlatl", ItemId = 23043 },
new() { Name = "Staff of the Quiddity", Category = "Legendary Weapons", WeaponType = "Staff", ItemId = 23041 },
new() { Name = "Dagger of the Quiddity", Category = "Legendary Weapons", WeaponType = "Dagger", ItemId = 23042 },
],
["Slayer Weapons"] =
[
new() { Name = "Shadowfire Isparian Bow", Category = "Slayer Weapons", WeaponType = "Bow", ItemId = 20636 },
new() { Name = "Shadowfire Isparian Sword", Category = "Slayer Weapons", WeaponType = "Sword", ItemId = 20631 },
new() { Name = "Shadowfire Isparian Staff", Category = "Slayer Weapons", WeaponType = "Staff", ItemId = 20633 },
new() { Name = "Shadowfire Isparian Dagger", Category = "Slayer Weapons", WeaponType = "Dagger", ItemId = 20634 },
new() { Name = "Shadowfire Isparian Axe", Category = "Slayer Weapons", WeaponType = "Axe", ItemId = 20635 },
new() { Name = "Shadowfire Isparian Mace", Category = "Slayer Weapons", WeaponType = "Mace", ItemId = 20632 },
],
["Society Weapons"] =
[
new() { Name = "Radiant Blood Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32834 },
new() { Name = "Celestial Hand Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32835 },
new() { Name = "Eldritch Web Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32836 },
new() { Name = "Radiant Blood Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32837 },
new() { Name = "Celestial Hand Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32838 },
new() { Name = "Eldritch Web Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32839 },
],
["Atlan Weapons"] =
[
new() { Name = "Atlan Sword", Category = "Atlan Weapons", WeaponType = "Sword", ItemId = 11648 },
new() { Name = "Atlan Axe", Category = "Atlan Weapons", WeaponType = "Axe", ItemId = 11649 },
new() { Name = "Atlan Mace", Category = "Atlan Weapons", WeaponType = "Mace", ItemId = 11650 },
new() { Name = "Atlan Spear", Category = "Atlan Weapons", WeaponType = "Spear", ItemId = 11651 },
new() { Name = "Atlan Staff", Category = "Atlan Weapons", WeaponType = "Staff", ItemId = 11652 },
new() { Name = "Atlan Dagger", Category = "Atlan Weapons", WeaponType = "Dagger", ItemId = 11653 },
],
};
}

View file

@ -0,0 +1,159 @@
using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre.FlagTracker;
/// <summary>
/// The "Mossy Tracker" window: augmentations, luminance auras, recalls,
/// cantrips, tracked weapons and quest timers for the character in the world.
/// Nothing here reaches the backend.
/// </summary>
/// <remarks>
/// The window exists only on a host that draws one; on a headless host this
/// feature registers nothing and does nothing. The pages are read at login
/// and whenever the player presses a page's Refresh button, as the original
/// read them when its window opened and on Refresh; the quest page follows
/// the quest list and redraws its countdowns every five seconds.
/// </remarks>
internal sealed class FlagTrackerFeature : IMmFeature
{
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
public const string MarkupFile = "mosswartmassacre-flagtracker.xml";
/// <summary>How often the quest countdowns are redrawn.</summary>
public static readonly TimeSpan QuestRedrawInterval = TimeSpan.FromSeconds(5);
private readonly MmContext _context;
private readonly QuestManager _quests;
private readonly FlagTrackerData _data;
private readonly IDisposable? _panel;
private readonly Action? _onQuestsChanged;
private IDisposable? _questRedraw;
public FlagTrackerFeature(MmContext context, QuestManager quests)
{
_context = context;
_quests = quests;
_data = new FlagTrackerData(context.Automation);
if (!context.Host.HasUi)
return;
Panel = new FlagTrackerViewModel();
Panel.RefreshRequested += OnRefreshRequested;
_onQuestsChanged = ShowQuests;
_quests.QuestsChanged += _onQuestsChanged;
string directory = Path.GetDirectoryName(typeof(FlagTrackerFeature).Assembly.Location) ?? ".";
_panel = context.Host.Ui.RegisterPanel(
new PluginPanelDescriptor("flagtracker", "Mossy Tracker")
{
IconText = "MT",
// The icon the original window carried (7735).
IconSurfaceId = 0x06001E37,
// Shown while the binding's IsVisible is set; the host's own
// shown state starts true so that flag alone opens it.
StartVisible = true,
ShowInSidePanel = true,
},
Path.Combine(directory, MarkupFile),
Panel);
}
/// <summary>
/// The window's binding object; set its <c>IsVisible</c> to open the
/// window. Null on a host without a window.
/// </summary>
public FlagTrackerViewModel? Panel { get; }
internal FlagTrackerData Data => _data;
public void OnLogin()
{
if (Panel is null)
return;
RefreshAll();
_questRedraw?.Dispose();
_questRedraw = _context.Scheduler.Every(QuestRedrawInterval, ShowQuests);
}
public void OnLogoff()
{
_questRedraw?.Dispose();
_questRedraw = null;
Panel?.Clear();
}
public void Dispose()
{
OnLogoff();
if (_onQuestsChanged is not null)
_quests.QuestsChanged -= _onQuestsChanged;
if (Panel is not null)
Panel.RefreshRequested -= OnRefreshRequested;
_panel?.Dispose();
}
/// <summary>Reads every page again and redraws it.</summary>
public void RefreshAll()
{
if (Panel is null)
return;
try
{
_data.RefreshAll();
}
catch (Exception ex)
{
_context.Chat.Write($"[MossyTracker] Data refresh failed: {ex.Message}");
}
Panel.ShowData(_data);
ShowQuests();
}
private void ShowQuests()
{
if (Panel is null || !_context.IsLoggedIn)
return;
Panel.ShowQuestRows(_quests.QuestList, _quests.UnixNow);
}
private void OnRefreshRequested(string button)
{
if (Panel is null || !_context.IsLoggedIn)
return;
try
{
switch (button)
{
case nameof(FlagTrackerViewModel.RefreshAugs):
_data.RefreshAugmentations();
Panel.ShowAugmentations(_data);
break;
case nameof(FlagTrackerViewModel.RefreshLum):
_data.RefreshLuminanceAuras();
Panel.ShowLuminance(_data);
break;
case nameof(FlagTrackerViewModel.RefreshRecalls):
_data.RefreshRecallSpells();
Panel.ShowRecallSpells(_data);
break;
case nameof(FlagTrackerViewModel.RefreshCantrips):
_data.RefreshCantrips();
Panel.ShowCantripRows(_data);
break;
case nameof(FlagTrackerViewModel.RefreshWeapons):
_data.RefreshWeapons();
Panel.ShowWeaponRows(_data);
break;
case nameof(FlagTrackerViewModel.RefreshQuests):
_quests.RefreshQuests();
ShowQuests();
break;
}
}
catch (Exception ex)
{
_context.Chat.Write($"[MossyTracker] Refresh failed: {ex.Message}");
}
}
}

View file

@ -0,0 +1,319 @@
using System.Globalization;
using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre.FlagTracker;
/// <summary>
/// The binding object of the "Mossy Tracker" window
/// (<c>mosswartmassacre-flagtracker.xml</c>). One tab per page of the
/// original window, and each page's list as parallel column lists that are
/// replaced whole on every refresh, so the host re-reading them every frame
/// never sees a half-built page. Everything is assigned on the tick thread.
/// </summary>
internal sealed class FlagTrackerViewModel
{
private enum Page
{
Augs,
Lum,
Recalls,
Cantrips,
Weapons,
Quests,
}
private Page _page = Page.Augs;
public FlagTrackerViewModel()
{
IgnoreRow = _ => { };
ShowAugs = () => _page = Page.Augs;
ShowLum = () => _page = Page.Lum;
ShowRecalls = () => _page = Page.Recalls;
ShowCantrips = () => _page = Page.Cantrips;
ShowWeapons = () => _page = Page.Weapons;
ShowQuests = () => _page = Page.Quests;
RefreshAugs = () => RefreshRequested?.Invoke(nameof(RefreshAugs));
RefreshLum = () => RefreshRequested?.Invoke(nameof(RefreshLum));
RefreshRecalls = () => RefreshRequested?.Invoke(nameof(RefreshRecalls));
RefreshCantrips = () => RefreshRequested?.Invoke(nameof(RefreshCantrips));
RefreshWeapons = () => RefreshRequested?.Invoke(nameof(RefreshWeapons));
RefreshQuests = () => RefreshRequested?.Invoke(nameof(RefreshQuests));
}
/// <summary>A refresh button was pressed; the argument is the button's binding name.</summary>
internal event Action<string>? RefreshRequested;
/// <summary>
/// Whether the window is open. The root panel's visibility is bound to it,
/// so the main window opens this one by setting it; it starts closed.
/// </summary>
public bool IsVisible { get; set; }
// ---- tabs --------------------------------------------------------------
public bool AugsSelected => _page == Page.Augs;
public bool LumSelected => _page == Page.Lum;
public bool RecallsSelected => _page == Page.Recalls;
public bool CantripsSelected => _page == Page.Cantrips;
public bool WeaponsSelected => _page == Page.Weapons;
public bool QuestsSelected => _page == Page.Quests;
public Action ShowAugs { get; }
public Action ShowLum { get; }
public Action ShowRecalls { get; }
public Action ShowCantrips { get; }
public Action ShowWeapons { get; }
public Action ShowQuests { get; }
public Action RefreshAugs { get; }
public Action RefreshLum { get; }
public Action RefreshRecalls { get; }
public Action RefreshCantrips { get; }
public Action RefreshWeapons { get; }
public Action RefreshQuests { get; }
/// <summary>Lists here are read-only views; a click selects nothing.</summary>
public int NoRow => -1;
public Action<int> IgnoreRow { get; }
// ---- Augs --------------------------------------------------------------
public IReadOnlyList<string> AugNames { get; private set; } = [];
public IReadOnlyList<string> AugProgress { get; private set; } = [];
public IReadOnlyList<uint> AugProgressColors { get; private set; } = [];
public IReadOnlyList<string> AugTrainers { get; private set; } = [];
public IReadOnlyList<string> AugLocations { get; private set; } = [];
// ---- Lum ---------------------------------------------------------------
public IReadOnlyList<string> LumNames { get; private set; } = [];
public IReadOnlyList<string> LumProgress { get; private set; } = [];
public IReadOnlyList<uint> LumProgressColors { get; private set; } = [];
public IReadOnlyList<string> LumCategories { get; private set; } = [];
// ---- Recalls -----------------------------------------------------------
public IReadOnlyList<uint> RecallSpellIds { get; private set; } = [];
public IReadOnlyList<string> RecallNames { get; private set; } = [];
public IReadOnlyList<string> RecallStatus { get; private set; } = [];
public IReadOnlyList<uint> RecallStatusColors { get; private set; } = [];
// ---- Cantrips ----------------------------------------------------------
public IReadOnlyList<uint> CantripIcons { get; private set; } = [];
public IReadOnlyList<string> CantripNames { get; private set; } = [];
public IReadOnlyList<string> CantripLevels { get; private set; } = [];
public IReadOnlyList<uint> CantripLevelColors { get; private set; } = [];
// ---- Weapons -----------------------------------------------------------
public IReadOnlyList<uint> WeaponIcons { get; private set; } = [];
public IReadOnlyList<string> WeaponNames { get; private set; } = [];
public IReadOnlyList<string> WeaponStatus { get; private set; } = [];
public IReadOnlyList<uint> WeaponStatusColors { get; private set; } = [];
// ---- Quests ------------------------------------------------------------
public IReadOnlyList<string> QuestRowNames { get; private set; } = [];
public IReadOnlyList<string> QuestCountdowns { get; private set; } = [];
public IReadOnlyList<uint> QuestCountdownColors { get; private set; } = [];
public IReadOnlyList<string> QuestLastSolved { get; private set; } = [];
public IReadOnlyList<string> QuestCooldowns { get; private set; } = [];
public IReadOnlyList<string> QuestSolves { get; private set; } = [];
public IReadOnlyList<uint> QuestSolvesColors { get; private set; } = [];
/// <summary>Rebuilds every page but the quests from the tracker's data.</summary>
public void ShowData(FlagTrackerData data)
{
ShowAugmentations(data);
ShowLuminance(data);
ShowRecallSpells(data);
ShowCantripRows(data);
ShowWeaponRows(data);
}
public void ShowAugmentations(FlagTrackerData data)
{
var names = new List<string>();
var progress = new List<string>();
var colors = new List<uint>();
var trainers = new List<string>();
var locations = new List<string>();
foreach ((string category, List<FlagTrackerData.AugmentationInfo> augmentations) in data.AugmentationCategories)
{
names.Add($"--- {category} ---");
progress.Add(string.Empty);
colors.Add(FlagTrackerData.Colors.White);
trainers.Add(string.Empty);
locations.Add(string.Empty);
foreach (FlagTrackerData.AugmentationInfo aug in augmentations)
{
names.Add(aug.Name);
progress.Add(aug.IsMaxed
? "[MAX]"
: string.Create(CultureInfo.InvariantCulture, $"[{aug.CurrentValue}/{aug.Repeatable}]"));
colors.Add(aug.IsMaxed
? FlagTrackerData.Colors.Green
: aug.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red);
trainers.Add(aug.Trainer);
locations.Add(aug.Location);
}
}
AugNames = names;
AugProgress = progress;
AugProgressColors = colors;
AugTrainers = trainers;
AugLocations = locations;
}
public void ShowLuminance(FlagTrackerData data)
{
var names = new List<string>();
var progress = new List<string>();
var colors = new List<uint>();
var categories = new List<string>();
foreach ((string category, List<FlagTrackerData.LuminanceAuraInfo> auras) in data.LuminanceAuraCategories)
{
names.Add($"--- {category} ---");
progress.Add(string.Empty);
colors.Add(FlagTrackerData.Colors.White);
categories.Add(string.Empty);
foreach (FlagTrackerData.LuminanceAuraInfo aura in auras)
{
names.Add(aura.Name);
progress.Add(string.Create(CultureInfo.InvariantCulture, $"{aura.CurrentValue}/{aura.Cap}"));
colors.Add(aura.CurrentValue >= aura.Cap
? FlagTrackerData.Colors.Green
: aura.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red);
categories.Add(category == "Seer Auras" && !string.IsNullOrEmpty(aura.QuestFlag)
? aura.QuestFlag
: category);
}
}
LumNames = names;
LumProgress = progress;
LumProgressColors = colors;
LumCategories = categories;
}
public void ShowRecallSpells(FlagTrackerData data)
{
RecallSpellIds = [.. data.RecallSpells.Select(recall => recall.SpellId)];
RecallNames = [.. data.RecallSpells.Select(recall => recall.Name)];
RecallStatus = [.. data.RecallSpells.Select(recall => recall.IsKnown ? "Known" : "Unknown")];
RecallStatusColors = [.. data.RecallSpells.Select(recall =>
recall.IsKnown ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red)];
}
public void ShowCantripRows(FlagTrackerData data)
{
var icons = new List<uint>();
var names = new List<string>();
var levels = new List<string>();
var colors = new List<uint>();
foreach ((string category, Dictionary<string, FlagTrackerData.CantripInfo> cantrips) in data.Cantrips)
{
icons.Add(FlagTrackerData.Icons.CategoryStar);
names.Add($"--- {category} ---");
levels.Add(string.Empty);
colors.Add(FlagTrackerData.Colors.White);
foreach ((string name, FlagTrackerData.CantripInfo cantrip) in cantrips)
{
icons.Add(cantrip.ComputedIconId);
names.Add(name);
levels.Add(cantrip.Value);
colors.Add(cantrip.Color);
}
}
CantripIcons = icons;
CantripNames = names;
CantripLevels = levels;
CantripLevelColors = colors;
}
public void ShowWeaponRows(FlagTrackerData data)
{
var icons = new List<uint>();
var names = new List<string>();
var status = new List<string>();
var colors = new List<uint>();
foreach ((string category, List<FlagTrackerData.TrackedWeaponInfo> weapons) in data.WeaponCategories)
{
icons.Add(FlagTrackerData.Icons.CategoryStar);
names.Add($"--- {category} ---");
status.Add(string.Empty);
colors.Add(FlagTrackerData.Colors.White);
foreach (FlagTrackerData.TrackedWeaponInfo weapon in weapons)
{
icons.Add(weapon.IconId);
names.Add(weapon.Name);
status.Add(weapon.Status);
colors.Add(weapon.IsAcquired ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red);
}
}
WeaponIcons = icons;
WeaponNames = names;
WeaponStatus = status;
WeaponStatusColors = colors;
}
/// <summary>
/// The quest page: every quest not yet solved its maximum number of times,
/// most solves first, then by name; the countdown green when ready and
/// yellow while cooling down.
/// </summary>
public void ShowQuestRows(IReadOnlyList<Quest> quests, long unixNow)
{
var names = new List<string>();
var countdowns = new List<string>();
var countdownColors = new List<uint>();
var lastSolved = new List<string>();
var cooldowns = new List<string>();
var solves = new List<string>();
var solvesColors = new List<uint>();
IEnumerable<Quest> visible = quests
.Where(quest => !(quest.MaxSolves > 0 && quest.Solves >= quest.MaxSolves))
.OrderByDescending(quest => quest.Solves)
.ThenBy(quest => QuestNames.GetFriendlyName(quest.Id), StringComparer.InvariantCulture);
foreach (Quest quest in visible)
{
names.Add(QuestNames.GetFriendlyName(quest.Id));
countdowns.Add(QuestStreamingService.FormatCountdown(quest.ExpireTime - unixNow));
countdownColors.Add(quest.ExpireTime <= unixNow
? FlagTrackerData.Colors.Green
: FlagTrackerData.Colors.Yellow);
lastSolved.Add(QuestManager.FormatTimeStamp(quest.Timestamp));
cooldowns.Add(QuestManager.FormatSeconds(quest.Delta));
solves.Add(quest.Solves.ToString(CultureInfo.InvariantCulture));
solvesColors.Add(FlagTrackerData.Colors.White);
}
QuestRowNames = names;
QuestCountdowns = countdowns;
QuestCountdownColors = countdownColors;
QuestLastSolved = lastSolved;
QuestCooldowns = cooldowns;
QuestSolves = solves;
QuestSolvesColors = solvesColors;
}
/// <summary>Empties every page, at logoff.</summary>
public void Clear()
{
AugNames = AugProgress = AugTrainers = AugLocations = [];
AugProgressColors = [];
LumNames = LumProgress = LumCategories = [];
LumProgressColors = [];
RecallSpellIds = RecallStatusColors = [];
RecallNames = RecallStatus = [];
CantripIcons = CantripLevelColors = [];
CantripNames = CantripLevels = [];
WeaponIcons = WeaponStatusColors = [];
WeaponNames = WeaponStatus = [];
ShowQuestRows([], 0);
}
}