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:
parent
0b5da797e0
commit
714fbfa2e8
8 changed files with 1735 additions and 0 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using OpenAC.MosswartMassacre.Backend;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
using OpenAC.MosswartMassacre.Quests;
|
||||
|
||||
namespace OpenAC.MosswartMassacre;
|
||||
|
|
@ -16,5 +17,6 @@ internal static class FeatureCatalog
|
|||
// Quests, flag tracker, meta sync.
|
||||
var quests = new QuestManager(context);
|
||||
features.Add(quests);
|
||||
features.Add(new FlagTrackerFeature(context, quests));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
672
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs
Normal file
672
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs
Normal 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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
159
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
Normal file
159
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
319
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs
Normal file
319
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
119
src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml
Normal file
119
src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The "Mossy Tracker" window. One tab per page of the original window, plus
|
||||
the tracked weapons it kept data for; each page is a Refresh button over a
|
||||
read-only list whose header row is the labels above it. -->
|
||||
<panel x="60" y="60" w="620" h="440" title="Mossy Tracker"
|
||||
visible="{IsVisible}" resizable="true" minw="620" minh="440">
|
||||
|
||||
<tab x="6" y="22" w="50" h="18" text="Augs"
|
||||
selected="{AugsSelected}" onclick="{ShowAugs}" />
|
||||
<tab x="56" y="22" w="44" h="18" text="Lum"
|
||||
selected="{LumSelected}" onclick="{ShowLum}" />
|
||||
<tab x="100" y="22" w="60" h="18" text="Recalls"
|
||||
selected="{RecallsSelected}" onclick="{ShowRecalls}" />
|
||||
<tab x="160" y="22" w="64" h="18" text="Cantrips"
|
||||
selected="{CantripsSelected}" onclick="{ShowCantrips}" />
|
||||
<tab x="224" y="22" w="66" h="18" text="Weapons"
|
||||
selected="{WeaponsSelected}" onclick="{ShowWeapons}" />
|
||||
<tab x="290" y="22" w="58" h="18" text="Quests"
|
||||
selected="{QuestsSelected}" onclick="{ShowQuests}" />
|
||||
|
||||
<!-- ==================== Augmentations ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{AugsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshAugs}" />
|
||||
<label x="2" y="26" text="Augmentation" />
|
||||
<label x="202" y="26" text="Progress" />
|
||||
<label x="282" y="26" text="Trainer" />
|
||||
<label x="442" y="26" text="Location" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="16"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="text" width="200" items="{AugNames}" />
|
||||
<column type="text" width="80" items="{AugProgress}" colors="{AugProgressColors}" />
|
||||
<column type="text" width="160" items="{AugTrainers}" />
|
||||
<column type="text" width="*" items="{AugLocations}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Luminance auras ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{LumSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshLum}" />
|
||||
<label x="2" y="26" text="Luminance Aura" />
|
||||
<label x="302" y="26" text="Progress" />
|
||||
<label x="402" y="26" text="Category" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="16"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="text" width="300" items="{LumNames}" />
|
||||
<column type="text" width="100" items="{LumProgress}" colors="{LumProgressColors}" />
|
||||
<column type="text" width="*" items="{LumCategories}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Recall spells ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{RecallsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshRecalls}" />
|
||||
<label x="22" y="26" text="Recall Spell" />
|
||||
<label x="422" y="26" text="Status" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="18"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="icon" width="20" iconkind="spell" values="{RecallSpellIds}" onclick="{IgnoreRow}" />
|
||||
<column type="text" width="400" items="{RecallNames}" />
|
||||
<column type="text" width="*" items="{RecallStatus}" colors="{RecallStatusColors}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Cantrips ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{CantripsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshCantrips}" />
|
||||
<label x="22" y="26" text="Skill/Effect" />
|
||||
<label x="422" y="26" text="Cantrip Level" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="18"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="icon" width="20" iconkind="did" values="{CantripIcons}" onclick="{IgnoreRow}" />
|
||||
<column type="text" width="400" items="{CantripNames}" />
|
||||
<column type="text" width="*" items="{CantripLevels}" colors="{CantripLevelColors}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Weapons ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{WeaponsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshWeapons}" />
|
||||
<label x="22" y="26" text="Weapon" />
|
||||
<label x="322" y="26" text="Status" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="18"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="icon" width="20" iconkind="did" values="{WeaponIcons}" onclick="{IgnoreRow}" />
|
||||
<column type="text" width="300" items="{WeaponNames}" />
|
||||
<column type="text" width="*" items="{WeaponStatus}" colors="{WeaponStatusColors}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Quests ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{QuestsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="110" h="20" text="Refresh Quests" onclick="{RefreshQuests}" />
|
||||
<label x="2" y="26" text="Quest Name" />
|
||||
<label x="232" y="26" text="Countdown" />
|
||||
<label x="322" y="26" text="Last Solved" />
|
||||
<label x="452" y="26" text="Cooldown" />
|
||||
<label x="552" y="26" text="Solves" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="16"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="text" width="230" items="{QuestRowNames}" />
|
||||
<column type="text" width="90" items="{QuestCountdowns}" colors="{QuestCountdownColors}" />
|
||||
<column type="text" width="130" items="{QuestLastSolved}" />
|
||||
<column type="text" width="100" items="{QuestCooldowns}" />
|
||||
<column type="text" width="*" items="{QuestSolves}" colors="{QuestSolvesColors}" />
|
||||
</list>
|
||||
</group>
|
||||
</panel>
|
||||
|
|
@ -446,6 +446,11 @@ public sealed class FakeSpellCatalog : ISpellCatalog
|
|||
/// <summary>The full spell table, independent of what the character knows.</summary>
|
||||
public IReadOnlyList<PluginSpellInfo> All { get; set; } = [];
|
||||
|
||||
/// <summary>The spells the character knows, for <see cref="IsKnown"/>.</summary>
|
||||
public HashSet<uint> KnownSpellIds { get; } = [];
|
||||
|
||||
public bool IsKnown(uint spellId) => KnownSpellIds.Contains(spellId);
|
||||
|
||||
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
||||
{
|
||||
foreach (PluginSpellInfo spell in KnownSelfBuffs
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
using OpenAC.MosswartMassacre.Tests.Fakes;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.FlagTracker;
|
||||
|
||||
public sealed class FlagTrackerTests
|
||||
{
|
||||
private const uint Player = 0x50000001;
|
||||
|
||||
private static PluginHarness WithUi(Action<FakeHost>? arrange = null) =>
|
||||
new(arrange: host =>
|
||||
{
|
||||
host.HasUi = true;
|
||||
arrange?.Invoke(host);
|
||||
});
|
||||
|
||||
private static void SetPlayerInts(FakeHost host, Dictionary<uint, int> ints) =>
|
||||
host.Automation.Objects.Properties[Player] = new PluginItemProperties(
|
||||
ints,
|
||||
new Dictionary<uint, long>(),
|
||||
new Dictionary<uint, bool>(),
|
||||
new Dictionary<uint, double>(),
|
||||
new Dictionary<uint, string>(),
|
||||
new Dictionary<uint, uint>(),
|
||||
new Dictionary<uint, uint>());
|
||||
|
||||
private static FlagTrackerViewModel Panel(PluginHarness harness) =>
|
||||
harness.Feature<FlagTrackerFeature>().Panel!;
|
||||
|
||||
private static int Row(IReadOnlyList<string> names, string name)
|
||||
{
|
||||
int index = names.ToList().IndexOf(name);
|
||||
Assert.True(index >= 0, $"no row {name}");
|
||||
return index;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_headless_host_gets_no_window_and_login_does_nothing()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
harness.Run(6);
|
||||
|
||||
FlagTrackerFeature feature = harness.Feature<FlagTrackerFeature>();
|
||||
Assert.Null(feature.Panel);
|
||||
Assert.Empty(harness.Host.Ui.Panels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_graphical_host_gets_the_mossy_tracker_window_closed()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
|
||||
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels);
|
||||
Assert.Equal("flagtracker", panel.Descriptor.WindowId);
|
||||
Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
|
||||
Assert.True(panel.Descriptor.ShowInSidePanel);
|
||||
Assert.EndsWith(FlagTrackerFeature.MarkupFile, panel.MarkupPath, StringComparison.Ordinal);
|
||||
Assert.Same(Panel(harness), panel.Binding);
|
||||
Assert.False(Panel(harness).IsVisible);
|
||||
Assert.True(Panel(harness).AugsSelected);
|
||||
|
||||
Panel(harness).ShowQuests();
|
||||
Assert.True(Panel(harness).QuestsSelected);
|
||||
Assert.False(Panel(harness).AugsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Augmentations_come_from_the_players_own_properties()
|
||||
{
|
||||
using var harness = WithUi(host => SetPlayerInts(host, new() { [231] = 3, [230] = 2 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal("--- Death Augs ---", panel.AugNames[0]);
|
||||
int keepItems = Row(panel.AugNames, "Keep Items");
|
||||
Assert.Equal("[MAX]", panel.AugProgress[keepItems]);
|
||||
Assert.Equal(0x008000u, panel.AugProgressColors[keepItems]);
|
||||
Assert.Equal("Rohula bint Ludun", panel.AugTrainers[keepItems]);
|
||||
Assert.Equal("Ayan Baqur", panel.AugLocations[keepItems]);
|
||||
|
||||
int keepSpells = Row(panel.AugNames, "Keep Spells");
|
||||
Assert.Equal("[0/1]", panel.AugProgress[keepSpells]);
|
||||
Assert.Equal(0xFF0000u, panel.AugProgressColors[keepSpells]);
|
||||
|
||||
int carrying = Row(panel.AugNames, "Extra Carrying Capacity");
|
||||
Assert.Equal("[2/5]", panel.AugProgress[carrying]);
|
||||
Assert.Equal(0xFFFF00u, panel.AugProgressColors[carrying]);
|
||||
|
||||
// A bought augmentation shows after Refresh.
|
||||
SetPlayerInts(harness.Host, new() { [232] = 1 });
|
||||
panel.RefreshAugs();
|
||||
Assert.Equal("[MAX]", panel.AugProgress[keepSpells]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Luminance_auras_show_progress_and_the_seer_they_belong_to()
|
||||
{
|
||||
using var harness = WithUi(host => SetPlayerInts(host, new() { [333] = 5, [344] = 2 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
int damage = Row(panel.LumNames, "+1 Damage Rating");
|
||||
Assert.Equal("5/5", panel.LumProgress[damage]);
|
||||
Assert.Equal(0x008000u, panel.LumProgressColors[damage]);
|
||||
Assert.Equal("Nalicana Auras", panel.LumCategories[damage]);
|
||||
|
||||
int kahiri = Row(panel.LumNames, "(Ka'hiri) +2 Specialized Skills");
|
||||
Assert.Equal("2/5", panel.LumProgress[kahiri]);
|
||||
Assert.Equal(0xFFFF00u, panel.LumProgressColors[kahiri]);
|
||||
Assert.Equal("LoyalToKahiri", panel.LumCategories[kahiri]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recalls_show_known_and_unknown_with_their_spell_icons()
|
||||
{
|
||||
using var harness = WithUi(host => host.Automation.Spells.KnownSpellIds.Add(2023));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal(17, panel.RecallNames.Count);
|
||||
Assert.Equal("Recall the Sanctuary", panel.RecallNames[0]);
|
||||
Assert.Equal(2023u, panel.RecallSpellIds[0]);
|
||||
Assert.Equal("Known", panel.RecallStatus[0]);
|
||||
Assert.Equal(0x008000u, panel.RecallStatusColors[0]);
|
||||
Assert.Equal("Unknown", panel.RecallStatus[1]);
|
||||
Assert.Equal(0xFF0000u, panel.RecallStatusColors[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cantrips_are_matched_to_trained_skills_attributes_and_wards()
|
||||
{
|
||||
using var harness = WithUi(host =>
|
||||
{
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(34, "War Magic", PluginSkillTraining.Specialized, 400));
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(6, "Melee Defense", PluginSkillTraining.Trained, 300));
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(21, "Healing", PluginSkillTraining.Untrained, 10));
|
||||
host.Automation.Spells.All =
|
||||
[
|
||||
FakeSpellCatalog.Spell(4715, "Epic War Magic Aptitude"),
|
||||
FakeSpellCatalog.Spell(2248, "Major Invulnerability"),
|
||||
FakeSpellCatalog.Spell(1, "Minor Invulnerability"),
|
||||
FakeSpellCatalog.Spell(6099, "Legendary Flame Ward"),
|
||||
FakeSpellCatalog.Spell(2091, "Major Strength"),
|
||||
FakeSpellCatalog.Spell(2, "Strength Self VIII"),
|
||||
];
|
||||
host.Automation.Character.ActiveEnchantments =
|
||||
[
|
||||
new PluginActiveEnchantment(4715, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2248, 0, 0, -1),
|
||||
new PluginActiveEnchantment(1, 0, 0, -1),
|
||||
new PluginActiveEnchantment(6099, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2091, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2, 0, 0, 3600),
|
||||
];
|
||||
});
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"--- Specialized Skills ---", "War Magic",
|
||||
"--- Trained Skills ---", "Invulnerability",
|
||||
"--- Attributes ---", "Strength",
|
||||
"--- Protection Auras ---", "Armor", "Bludgeoning Ward", "Piercing Ward",
|
||||
"Slashing Ward", "Flame Ward", "Frost Ward", "Acid Ward", "Storm Ward",
|
||||
],
|
||||
panel.CantripNames);
|
||||
|
||||
Assert.Equal(FlagTrackerData.Icons.CategoryStar, panel.CantripIcons[0]);
|
||||
Assert.Equal("Epic", panel.CantripLevels[1]);
|
||||
Assert.Equal(0x800080u, panel.CantripLevelColors[1]);
|
||||
Assert.Equal(FlagTrackerData.Icons.GreenCircle, panel.CantripIcons[1]);
|
||||
// The higher of two cantrips on one skill wins.
|
||||
Assert.Equal("Major", panel.CantripLevels[3]);
|
||||
Assert.Equal(0x0000FFu, panel.CantripLevelColors[3]);
|
||||
Assert.Equal("Major", panel.CantripLevels[5]);
|
||||
Assert.Equal("N/A", panel.CantripLevels[7]);
|
||||
Assert.Equal(FlagTrackerData.Icons.RedCircle, panel.CantripIcons[7]);
|
||||
int flame = Row(panel.CantripNames, "Flame Ward");
|
||||
Assert.Equal("Legendary", panel.CantripLevels[flame]);
|
||||
Assert.Equal(0xFFA500u, panel.CantripLevelColors[flame]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracked_weapons_are_found_in_the_inventory_by_name()
|
||||
{
|
||||
using var harness = WithUi(host => host.Automation.Items.Owned.Add(
|
||||
FakeItems.Item(0x80000001, "Atlan Sword") with { IconId = 0x06001234 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
int atlan = Row(panel.WeaponNames, "Atlan Sword");
|
||||
Assert.Equal("Acquired", panel.WeaponStatus[atlan]);
|
||||
Assert.Equal(0x06001234u, panel.WeaponIcons[atlan]);
|
||||
int axe = Row(panel.WeaponNames, "Atlan Axe");
|
||||
Assert.Equal("Not Acquired", panel.WeaponStatus[axe]);
|
||||
Assert.Equal(FlagTrackerData.Icons.Portal, panel.WeaponIcons[axe]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_quest_page_hides_maxed_quests_and_sorts_by_solves()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
harness.Login();
|
||||
long now = new DateTimeOffset(harness.Clock.UtcNow).ToUnixTimeSeconds();
|
||||
var chat = harness.Host.Automation.Chat;
|
||||
chat.Deliver($"stipendtimer_0812 - 2 solves ({now - 10})\"x\" -1 3700");
|
||||
chat.Deliver($"maxed - 5 solves ({now})\"x\" 5 60");
|
||||
chat.Deliver($"aaa - 7 solves ({now - 100})\"x\" -1 50");
|
||||
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
Assert.Equal(["aaa", "Stipend Collection Timer"], panel.QuestRowNames);
|
||||
Assert.Equal(["READY", "1h 01m"], panel.QuestCountdowns);
|
||||
Assert.Equal([0x008000u, 0xFFFF00u], panel.QuestCountdownColors);
|
||||
Assert.Equal(["50s", "1h 1m 40s"], panel.QuestCooldowns);
|
||||
Assert.Equal(["7", "2"], panel.QuestSolves);
|
||||
|
||||
// The countdowns move on every five seconds.
|
||||
harness.Run(5.1);
|
||||
Assert.Equal("1h 01m", panel.QuestCountdowns[1]);
|
||||
harness.Run(120);
|
||||
Assert.StartsWith("59m ", panel.QuestCountdowns[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Logoff_empties_the_pages()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
harness.Login();
|
||||
Assert.NotEmpty(Panel(harness).AugNames);
|
||||
|
||||
harness.Plugin.OnLogoff();
|
||||
|
||||
Assert.Empty(Panel(harness).AugNames);
|
||||
Assert.Empty(Panel(harness).QuestRowNames);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Proves every <c>{Binding}</c> in this slice's markup files resolves to a
|
||||
/// public property of the right shape on its binding object, the check the
|
||||
/// host would otherwise only make when the window is built in game.
|
||||
/// </summary>
|
||||
public sealed class TrackerAndMetasMarkupContractTests
|
||||
{
|
||||
private static readonly string[] InteractiveElementNames =
|
||||
[
|
||||
"tab", "button", "toggle", "slider", "field", "menu", "list", "column",
|
||||
];
|
||||
|
||||
public static TheoryData<string, Type> MarkupFiles => new()
|
||||
{
|
||||
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryBindingResolvesToAPublicProperty(string file, Type bindingType)
|
||||
{
|
||||
XElement root = LoadRoot(file);
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
|
||||
|
||||
foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
|
||||
{
|
||||
string value = attribute.Value;
|
||||
if (!value.Contains('{', StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
Assert.Matches("^\\{[^{}]+\\}$", value);
|
||||
string name = value[1..^1];
|
||||
Assert.True(
|
||||
byName.ContainsKey(name),
|
||||
$"{file}: binding {value} on <{attribute.Parent?.Name}> has no public {bindingType.Name} property.");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryBindingHasTheShapeTheHostReads(string file, Type bindingType)
|
||||
{
|
||||
XElement root = LoadRoot(file);
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
|
||||
|
||||
foreach (XElement element in root.DescendantsAndSelf())
|
||||
AssertElementBindings(file, element, byName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryInteractiveControlDeclaresAHandler(string file, Type bindingType)
|
||||
{
|
||||
_ = bindingType;
|
||||
XElement root = LoadRoot(file);
|
||||
HashSet<string> interactive = new(InteractiveElementNames, StringComparer.Ordinal);
|
||||
|
||||
XElement[] controls = [.. root.Descendants().Where(element => interactive.Contains(element.Name.LocalName))];
|
||||
Assert.NotEmpty(controls);
|
||||
|
||||
foreach (XElement control in controls)
|
||||
{
|
||||
if (control.Name.LocalName == "column" && (string?)control.Attribute("type") == "text")
|
||||
continue;
|
||||
|
||||
XAttribute? handler = control.Attribute("onclick")
|
||||
?? control.Attribute("onchange")
|
||||
?? control.Attribute("onsubmit");
|
||||
Assert.True(handler is not null, $"{file}: <{control.Name.LocalName}> declares no handler.");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType)
|
||||
{
|
||||
XElement root = LoadRoot(file);
|
||||
Assert.Equal("panel", root.Name.LocalName);
|
||||
Assert.Equal("{IsVisible}", (string?)root.Attribute("visible"));
|
||||
PropertyInfo isVisible = PropertiesOf(bindingType)["IsVisible"];
|
||||
Assert.Equal(typeof(bool), isVisible.PropertyType);
|
||||
Assert.True(isVisible.CanWrite && isVisible.SetMethod!.IsPublic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryListColumnHasASaneWidth(string file, Type bindingType)
|
||||
{
|
||||
_ = bindingType;
|
||||
XElement root = LoadRoot(file);
|
||||
|
||||
foreach (XElement list in root.Descendants("list"))
|
||||
{
|
||||
XElement[] columns = [.. list.Elements("column")];
|
||||
for (int index = 0; index < columns.Length; index++)
|
||||
{
|
||||
string? width = (string?)columns[index].Attribute("width");
|
||||
Assert.True(width is not null, $"{file}: <column> {index} has no width.");
|
||||
bool isStar = width == "*";
|
||||
bool isPositive = double.TryParse(
|
||||
width,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out double parsed)
|
||||
&& parsed > 0d;
|
||||
Assert.True(isStar || isPositive, $"{file}: <column width=\"{width}\"> at {index}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFlagTrackerTabsAreTheOriginalPagesPlusWeapons()
|
||||
{
|
||||
XElement root = LoadRoot(FlagTrackerFeature.MarkupFile);
|
||||
string[] tabs = [.. root.Elements("tab").Select(tab => (string?)tab.Attribute("text") ?? string.Empty)];
|
||||
|
||||
Assert.Equal(["Augs", "Lum", "Recalls", "Cantrips", "Weapons", "Quests"], tabs);
|
||||
Assert.Equal(6, root.Elements("group").Count());
|
||||
Assert.Equal("Mossy Tracker", (string?)root.Attribute("title"));
|
||||
}
|
||||
|
||||
private static void AssertElementBindings(string file, XElement element, IReadOnlyDictionary<string, PropertyInfo> byName)
|
||||
{
|
||||
if (element.Name.LocalName == "column")
|
||||
{
|
||||
AssertBindingType(file, element, "onchange", typeof(Action<int>), byName);
|
||||
AssertBindingType(file, element, "onclick", typeof(Action<int>), byName);
|
||||
AssertListBinding(file, element, "items", typeof(string), byName);
|
||||
AssertListBinding(file, element, "colors", typeof(uint), byName);
|
||||
switch ((string?)element.Attribute("type"))
|
||||
{
|
||||
case "check":
|
||||
AssertListBinding(file, element, "values", typeof(bool), byName);
|
||||
break;
|
||||
case "icon":
|
||||
AssertListBinding(file, element, "values", typeof(uint), byName);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
AssertBindingType(file, element, "onclick", typeof(Action), byName);
|
||||
AssertBindingType(file, element, "onsubmit", typeof(Action<string>), byName);
|
||||
AssertBindingType(file, element, "visible", typeof(bool), byName);
|
||||
AssertBindingType(file, element, "enabled", typeof(bool), byName);
|
||||
AssertBindingType(file, element, "checked", typeof(bool), byName);
|
||||
Type? selectedType = element.Name.LocalName switch
|
||||
{
|
||||
"tab" => typeof(bool),
|
||||
"list" => typeof(int),
|
||||
"menu" => null,
|
||||
_ => null,
|
||||
};
|
||||
AssertBindingType(file, element, "selected", selectedType, byName);
|
||||
|
||||
Type? changeType = element.Name.LocalName switch
|
||||
{
|
||||
"field" or "menu" => typeof(Action<string>),
|
||||
"slider" => typeof(Action<float>),
|
||||
"list" => typeof(Action<int>),
|
||||
_ => null,
|
||||
};
|
||||
AssertBindingType(file, element, "onchange", changeType, byName);
|
||||
|
||||
if (element.Name.LocalName is "list" or "menu")
|
||||
AssertListBinding(file, element, "items", typeof(string), byName);
|
||||
}
|
||||
|
||||
private static void AssertBindingType(
|
||||
string file,
|
||||
XElement element,
|
||||
string attributeName,
|
||||
Type? expected,
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName)
|
||||
{
|
||||
if (expected is null)
|
||||
return;
|
||||
string? value = (string?)element.Attribute(attributeName);
|
||||
if (value is null || !value.StartsWith('{'))
|
||||
return;
|
||||
|
||||
PropertyInfo property = byName[value[1..^1]];
|
||||
Assert.True(
|
||||
expected.IsAssignableFrom(property.PropertyType),
|
||||
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {property.PropertyType.Name}, expected {expected.Name}.");
|
||||
}
|
||||
|
||||
private static void AssertListBinding(
|
||||
string file,
|
||||
XElement element,
|
||||
string attributeName,
|
||||
Type elementType,
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName)
|
||||
{
|
||||
string? value = (string?)element.Attribute(attributeName);
|
||||
if (value is null || !value.StartsWith('{'))
|
||||
return;
|
||||
|
||||
Type type = byName[value[1..^1]].PropertyType;
|
||||
Assert.True(
|
||||
typeof(IReadOnlyList<>).MakeGenericType(elementType).IsAssignableFrom(type),
|
||||
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {type.Name}, expected IReadOnlyList<{elementType.Name}>.");
|
||||
}
|
||||
|
||||
private static XElement LoadRoot(string file)
|
||||
{
|
||||
XDocument document = XDocument.Load(Path.Combine(AppContext.BaseDirectory, file));
|
||||
return Assert.IsType<XElement>(document.Root);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PropertyInfo> PropertiesOf(Type type) =>
|
||||
type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
||||
.ToDictionary(static property => property.Name, StringComparer.Ordinal);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue