Merge slice/flagtracker-metas: quests, Mossy Tracker, meta sync

Resolved FeatureCatalog (both blocks kept; the main window's Tools buttons now
open the Mossy Tracker and Metas panels). Tests from both sides adjusted to
the combined plugin: quest tests look at quest frames only (chat is streamed
too), and the stream tests ignore the /myquests request sent at login.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:29:10 +02:00
commit 2e5d768ca9
19 changed files with 3984 additions and 1 deletions

View file

@ -1,5 +1,8 @@
using OpenAC.MosswartMassacre.Backend;
using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Infrastructure;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Quests;
using OpenAC.MosswartMassacre.Streams;
using OpenAC.MosswartMassacre.Trackers;
@ -27,7 +30,8 @@ internal static class FeatureCatalog
features.Add(new VitalsStream(context));
features.Add(new SpawnPortalStream(context));
features.Add(new BotControl(context));
features.Add(new Views.MainWindow(context, stats));
var mainWindow = new Views.MainWindow(context, stats);
features.Add(mainWindow);
session.AddConnectionListener(telemetry);
// Cross-computer vital and debuff sharing.
@ -47,5 +51,17 @@ internal static class FeatureCatalog
else
radar.Stop();
};
// Quests, flag tracker, meta sync.
var quests = new QuestManager(context);
features.Add(quests);
var flagTracker = new FlagTrackerFeature(context, quests);
features.Add(flagTracker);
var metas = new MetaSyncFeature(context);
features.Add(metas);
if (flagTracker.Panel is { } trackerPanel)
mainWindow.FlagTrackerOpener = () => trackerPanel.IsVisible = true;
if (metas.Panel is { } metasPanel)
mainWindow.MetasOpener = () => metasPanel.IsVisible = true;
}
}

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);
}
}

View file

@ -0,0 +1,337 @@
using System.Collections.Concurrent;
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// Meta and nav sync from the club's repository: the <c>/mm metasrefresh</c>,
/// <c>metascheck</c>, <c>metaspull</c> and <c>metaspullfile</c> verbs and, on a
/// host with a window, the Metas window. Nothing here reaches the backend.
/// </summary>
/// <remarks>
/// Network work runs on the thread pool; every result, chat line and window
/// update is queued and applied on the next tick. The file list is not tied
/// to a character, so it survives a relog.
/// </remarks>
internal sealed class MetaSyncFeature : IMmFeature
{
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
public const string MarkupFile = "mosswartmassacre-metas.xml";
private readonly MmContext _context;
private readonly MetaSyncManager _manager;
private readonly ConcurrentQueue<Action> _onTick = new();
private readonly CancellationTokenSource _shutdown = new();
private readonly Action<double> _tick;
private readonly IDisposable? _panel;
private Task _lastOperation = Task.CompletedTask;
private bool _disposed;
public MetaSyncFeature(MmContext context)
: this(context, context.Host.VtankProfiles, handler: null)
{
}
/// <summary>Builds the feature on a given profile storage and HTTP handler; tests pass fakes.</summary>
internal MetaSyncFeature(MmContext context, IPluginStorage profiles, HttpMessageHandler? handler)
{
_context = context;
_manager = new MetaSyncManager(
profiles,
handler,
() => RepositoryUrl,
() => _context.Clock.UtcNow,
message => _onTick.Enqueue(() => _context.Chat.Write(message)));
_tick = _ => Pump();
context.Host.Events.Tick += _tick;
context.Commands.Register("metasrefresh", _ => RunRefresh(), "Refresh remote metas list");
context.Commands.Register("metascheck", _ => RunCheck(), "Check local metas against remote");
context.Commands.Register("metaspull", _ => RunPullOutdated(), "Download outdated metas/nav files");
context.Commands.Register("metaspullfile", HandlePullFile, "Download one metas/nav file by relative path");
if (!context.Host.HasUi)
return;
Panel = new MetasViewModel();
Panel.ShowPath(_manager.StorageRoot);
Panel.ShowFiles([]);
Panel.ButtonPressed += OnButton;
Panel.SearchRequested += ShowFiles;
string directory = Path.GetDirectoryName(typeof(MetaSyncFeature).Assembly.Location) ?? ".";
_panel = context.Host.Ui.RegisterPanel(
new PluginPanelDescriptor("metas", "Metas")
{
IconText = "ME",
// Shown while the binding's IsVisible is set; the host's own
// shown state starts true so that flag alone opens it.
StartVisible = true,
ShowInSidePanel = true,
},
Path.Combine(directory, MarkupFile),
Panel);
}
/// <summary>
/// The window's binding object; set its <c>IsVisible</c> to open the
/// window. Null on a host without a window.
/// </summary>
public MetasViewModel? Panel { get; }
internal MetaSyncManager Manager => _manager;
/// <summary>The operation started last; tests await it before pumping the tick.</summary>
internal Task LastOperation => _lastOperation;
/// <summary>The repository setting, or the original's repository when it is empty.</summary>
private string RepositoryUrl
{
get
{
string? url = _context.Settings.Current.MetasRepositoryUrl;
return string.IsNullOrWhiteSpace(url) ? MetaSyncManager.DefaultRepositoryUrl : url;
}
}
public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot);
public void OnLogoff()
{
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_context.Host.Events.Tick -= _tick;
_shutdown.Cancel();
if (Panel is not null)
{
Panel.ButtonPressed -= OnButton;
Panel.SearchRequested -= ShowFiles;
}
_panel?.Dispose();
_onTick.Clear();
// Operations run one after another, so the manager goes once the
// last one, cancelled above, has finished.
_lastOperation.ContinueWith(
_ => { _manager.Dispose(); _shutdown.Dispose(); },
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
}
/// <summary>Applies finished work on the tick thread. Internal so tests can pump without a host tick.</summary>
internal void Pump()
{
while (_onTick.TryDequeue(out Action? action))
{
try
{
action();
}
catch (Exception ex)
{
_context.Host.Log.Error("A metas update failed.", ex);
}
}
}
private void RunRefresh(Action<int>? done = null, Action<Exception>? failed = null) =>
Start(
token => _manager.RefreshCatalogAsync(token),
count =>
{
ShowFiles();
if (done is null)
_context.Chat.Write($"[Metas] Loaded {count} remote .met/.nav files");
else
done(count);
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Refresh failed: {ex.Message}");
else
failed(ex);
});
private void RunCheck(
Action<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)>? done = null,
Action<Exception>? failed = null) =>
Start(
token => _manager.CheckStatusesAsync(token),
summary =>
{
ShowFiles();
if (done is null)
{
_context.Chat.Write(
$"[Metas] Up-to-date: {summary.UpToDate}, Updates: {summary.NeedsUpdate}, Missing: {summary.NotDownloaded}, Errors: {summary.Errors}");
}
else
{
done(summary);
}
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Check failed: {ex.Message}");
else
failed(ex);
});
private void RunPullOutdated(
Action<(int Downloaded, int Skipped, int Failed, int Backups)>? done = null,
Action<Exception>? failed = null) =>
Start(
token => _manager.DownloadOutdatedAsync(token),
result =>
{
ShowFiles();
if (done is null)
{
_context.Chat.Write(
$"[Metas] Downloaded: {result.Downloaded}, Skipped: {result.Skipped}, Failed: {result.Failed}, Backups: {result.Backups}");
}
else
{
done(result);
}
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Pull failed: {ex.Message}");
else
failed(ex);
});
private void HandlePullFile(string[] args)
{
if (args.Length < 2)
{
_context.Chat.Write("Usage: /mm metaspullfile <relative/path/file.met>");
return;
}
string relativePath = string.Join(" ", args.Skip(1));
Start(
token => _manager.DownloadFileAsync(relativePath, token),
result =>
{
ShowFiles();
_context.Chat.Write($"[Metas] {relativePath}: {result.Message}");
},
ex => _context.Chat.Write($"[Metas] Pull file failed: {ex.Message}"));
}
private void OnButton(string button)
{
MetasViewModel? panel = Panel;
if (panel is null)
return;
switch (button)
{
case nameof(MetasViewModel.RefreshList):
panel.ShowPath(_manager.StorageRoot);
panel.StatusText = "Status: refreshing remote file list...";
RunRefresh(
count => panel.StatusText = $"Status: loaded {count} remote files",
ex =>
{
panel.StatusText = "Status: refresh failed";
_context.Chat.Write($"[Metas] Refresh failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.CheckUpdates):
panel.ShowPath(_manager.StorageRoot);
panel.StatusText = "Status: checking remote updates...";
RunCheck(
summary => panel.StatusText =
$"Status: {summary.UpToDate} current, {summary.NeedsUpdate} updates, {summary.NotDownloaded} missing, {summary.Errors} errors",
ex =>
{
panel.StatusText = "Status: update check failed";
_context.Chat.Write($"[Metas] Update check failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.DownloadOutdated):
panel.StatusText = "Status: downloading outdated files...";
RunPullOutdated(
result => panel.StatusText =
$"Status: downloaded {result.Downloaded}, skipped {result.Skipped}, failed {result.Failed}, backups {result.Backups}",
ex =>
{
panel.StatusText = "Status: pull outdated failed";
_context.Chat.Write($"[Metas] Pull outdated failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.DownloadSelected):
string selection = panel.SelectedRelativePath;
if (string.IsNullOrWhiteSpace(selection))
{
_context.Chat.Write("[Metas] Select a file first");
return;
}
panel.StatusText = $"Status: downloading {selection}...";
Start(
token => _manager.DownloadFileAsync(selection, token),
result =>
{
ShowFiles();
panel.StatusText = $"Status: {result.Message}";
},
ex =>
{
panel.StatusText = "Status: download failed";
_context.Chat.Write($"[Metas] Download failed: {ex.Message}");
});
break;
}
}
private void ShowFiles()
{
if (Panel is null)
return;
Panel.ShowFiles(_manager.GetCachedFiles());
Panel.ShowLastCheck(_manager.LastStatusCheckUtc, _manager.LastCatalogRefreshUtc);
}
/// <summary>Runs <paramref name="work"/> on the thread pool and applies its outcome on the next tick.</summary>
private void Start<T>(Func<CancellationToken, Task<T>> work, Action<T> done, Action<Exception> failed)
{
if (_disposed)
return;
CancellationToken token = _shutdown.Token;
Task previous = _lastOperation;
// Each operation starts when the one before it has finished, the
// order the player asked for them in.
_lastOperation = Task.Run(async () =>
{
await previous.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
try
{
T result = await work(token).ConfigureAwait(false);
_onTick.Enqueue(() => done(result));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// Shutting down: nobody is left to tell.
}
catch (Exception ex)
{
_onTick.Enqueue(() => failed(ex));
}
}, CancellationToken.None);
}
}

View file

@ -0,0 +1,470 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using AcDream.Plugin.Abstractions;
using Newtonsoft.Json.Linq;
namespace OpenAC.MosswartMassacre.Metas;
internal enum MetaFileSyncStatus
{
Unknown,
NotDownloaded,
UpToDate,
UpdateAvailable,
Error,
}
/// <summary>One <c>.met</c> or <c>.nav</c> file of the repository and how the local copy compares.</summary>
internal sealed class MetaFileEntry
{
public string RelativePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Extension { get; set; } = string.Empty;
/// <summary>The storage key the file is kept under, or empty when there is no storage.</summary>
public string LocalPath { get; set; } = string.Empty;
public bool LocalExists { get; set; }
public string LocalSha256 { get; set; } = string.Empty;
public string RemoteSha256 { get; set; } = string.Empty;
public MetaFileSyncStatus Status { get; set; }
public string StatusText { get; set; } = string.Empty;
public MetaFileEntry Clone() => (MetaFileEntry)MemberwiseClone();
}
internal sealed class MetaDownloadResult
{
public string RelativePath { get; set; } = string.Empty;
public bool Downloaded { get; set; }
public bool SkippedAsCurrent { get; set; }
public bool BackupCreated { get; set; }
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// Keeps the shared automation profiles in step with the metas repository on
/// the club's git server: lists its <c>.met</c> and <c>.nav</c> files through
/// the server's API, compares each with the local copy by SHA-256, and
/// downloads the ones that differ, keeping the old copy as <c>.bak</c>.
/// </summary>
/// <remarks>
/// <para>The endpoints are the original's: the tree comes from
/// <c>{server}/api/v1/repos/{owner}/{repo}/git/trees/main?recursive=1</c> and each
/// file from <c>{repository}/raw/branch/main/{path}</c>. The repository address is a
/// setting that defaults to the original's repository.</para>
/// <para>Files land where the automation plugin reads dropped profiles, flattened
/// to their bare names as the original flattened them into its profile
/// folder: metas under <c>mosstank/metas</c>, navs under <c>mosstank/navs</c>.
/// The shared storage holds text, so both sides are compared as the text a
/// read of the stored file gives back (UTF-8, byte-order mark dropped),
/// hashed as UTF-8; for the plain-text files in the repository that is the
/// same as the original's byte comparison.</para>
/// <para>The methods run off the tick thread: they touch only the network, the
/// shared storage and this object's own list, which is locked. One operation
/// runs at a time; a second waits for the first. Notices for the chat
/// window go through the callback, which the owner marshals to the tick.</para>
/// </remarks>
internal sealed class MetaSyncManager : IDisposable
{
public const string DefaultRepositoryUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas";
public const string BranchName = "main";
/// <summary>Where the automation plugin looks for dropped metas.</summary>
public const string MetaFolder = "mosstank/metas";
/// <summary>Where the automation plugin looks for dropped navs.</summary>
public const string NavFolder = "mosstank/navs";
private readonly IPluginStorage _storage;
private readonly HttpClient _http;
private readonly Func<string> _repositoryUrl;
private readonly Func<DateTime> _utcNow;
private readonly Action<string> _notice;
private readonly object _sync = new();
private readonly SemaphoreSlim _oneAtATime = new(1, 1);
private List<MetaFileEntry> _cachedFiles = [];
public MetaSyncManager(
IPluginStorage storage,
HttpMessageHandler? handler,
Func<string> repositoryUrl,
Func<DateTime> utcNow,
Action<string> notice)
{
_storage = storage;
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_http.Timeout = TimeSpan.FromSeconds(30);
_repositoryUrl = repositoryUrl;
_utcNow = utcNow;
_notice = notice;
}
public DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue;
public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue;
/// <summary>The storage root the files are written beneath, for the path label; null without storage.</summary>
public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
public IReadOnlyList<MetaFileEntry> GetCachedFiles()
{
lock (_sync)
return [.. _cachedFiles.Select(static entry => entry.Clone())];
}
/// <summary>The API and raw-file addresses for a repository address.</summary>
public static (string TreeUrl, string RawBaseUrl) ResolveEndpoints(string repositoryUrl)
{
string address = string.IsNullOrWhiteSpace(repositoryUrl) ? DefaultRepositoryUrl : repositoryUrl.Trim();
var uri = new Uri(address, UriKind.Absolute);
string server = uri.GetLeftPart(UriPartial.Authority);
string path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
path = path[..^4];
return (
$"{server}/api/v1/repos/{path}/git/trees/{BranchName}?recursive=1",
$"{server}/{path}/raw/branch/{BranchName}");
}
/// <summary>The storage key a repository file is kept under, or null for a file that is neither a meta nor a nav.</summary>
public static string? LocalKeyFor(string relativePath)
{
string fileName = Path.GetFileName((relativePath ?? string.Empty).Replace('\\', '/'));
if (fileName.Length == 0)
return null;
if (fileName.EndsWith(".met", StringComparison.OrdinalIgnoreCase))
return $"{MetaFolder}/{fileName}";
if (fileName.EndsWith(".nav", StringComparison.OrdinalIgnoreCase))
return $"{NavFolder}/{fileName}";
return null;
}
public async Task<int> RefreshCatalogAsync(CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_oneAtATime.Release();
}
}
public async Task<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)> CheckStatusesAsync(
CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<MetaFileEntry> workingSet = CloneCache();
if (workingSet.Count == 0)
{
await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
workingSet = CloneCache();
}
foreach (MetaFileEntry entry in workingSet)
{
try
{
string? localText = entry.LocalPath.Length == 0 ? null : _storage.ReadText(entry.LocalPath);
if (localText is null)
{
entry.LocalExists = false;
entry.LocalSha256 = string.Empty;
entry.Status = MetaFileSyncStatus.NotDownloaded;
entry.StatusText = "Not downloaded";
continue;
}
entry.LocalExists = true;
entry.LocalSha256 = Sha256(localText);
string remoteText = await DownloadRemoteTextAsync(entry.RelativePath, cancellationToken)
.ConfigureAwait(false);
entry.RemoteSha256 = Sha256(remoteText);
if (string.Equals(entry.LocalSha256, entry.RemoteSha256, StringComparison.OrdinalIgnoreCase))
{
entry.Status = MetaFileSyncStatus.UpToDate;
entry.StatusText = "Up to date";
}
else
{
entry.Status = MetaFileSyncStatus.UpdateAvailable;
entry.StatusText = "Update available";
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
entry.Status = MetaFileSyncStatus.Error;
entry.StatusText = "Error";
_notice($"[Metas] Status check failed for {entry.RelativePath}: {ex.Message}");
}
}
lock (_sync)
{
_cachedFiles = workingSet;
LastStatusCheckUtc = _utcNow();
}
return (
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpToDate),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpdateAvailable),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.NotDownloaded),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.Error));
}
finally
{
_oneAtATime.Release();
}
}
public async Task<MetaDownloadResult> DownloadFileAsync(string relativePath, CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await DownloadFileCoreAsync(relativePath, cancellationToken).ConfigureAwait(false);
}
finally
{
_oneAtATime.Release();
}
}
public async Task<(int Downloaded, int Skipped, int Failed, int Backups)> DownloadOutdatedAsync(
CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<MetaFileEntry> current = CloneCache();
if (current.Count == 0)
{
await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
current = CloneCache();
}
List<MetaFileEntry> candidates = [.. current
.Where(static entry => entry.Status is MetaFileSyncStatus.UpdateAvailable
or MetaFileSyncStatus.NotDownloaded
or MetaFileSyncStatus.Unknown)
.OrderBy(static entry => entry.RelativePath, StringComparer.OrdinalIgnoreCase)];
int downloaded = 0;
int skipped = 0;
int failed = 0;
int backups = 0;
foreach (MetaFileEntry entry in candidates)
{
try
{
MetaDownloadResult result = await DownloadFileCoreAsync(entry.RelativePath, cancellationToken)
.ConfigureAwait(false);
if (result.Downloaded)
downloaded++;
if (result.SkippedAsCurrent)
skipped++;
if (result.BackupCreated)
backups++;
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
failed++;
_notice($"[Metas] Download failed for {entry.RelativePath}: {ex.Message}");
UpdateSingleCachedEntry(
entry.RelativePath, entry.LocalPath, entry.LocalSha256, entry.RemoteSha256,
MetaFileSyncStatus.Error, "Error");
}
}
return (downloaded, skipped, failed, backups);
}
finally
{
_oneAtATime.Release();
}
}
public void Dispose()
{
_http.Dispose();
_oneAtATime.Dispose();
}
private async Task<int> RefreshCatalogCoreAsync(CancellationToken cancellationToken)
{
List<string> remoteEntries = await FetchRemoteTreeEntriesAsync(cancellationToken).ConfigureAwait(false);
HashSet<string> localKeys = ListLocalKeys();
List<MetaFileEntry> result = [.. remoteEntries
.Where(static path => path.EndsWith(".met", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".nav", StringComparison.OrdinalIgnoreCase))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
string localPath = _storage.IsAvailable ? LocalKeyFor(path) ?? string.Empty : string.Empty;
bool exists = localPath.Length > 0 && localKeys.Contains(localPath);
return new MetaFileEntry
{
RelativePath = path,
FileName = Path.GetFileName(path),
Extension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(),
LocalPath = localPath,
LocalExists = exists,
Status = exists ? MetaFileSyncStatus.Unknown : MetaFileSyncStatus.NotDownloaded,
StatusText = exists ? "Unknown" : "Not downloaded",
};
})];
lock (_sync)
{
_cachedFiles = result;
LastCatalogRefreshUtc = _utcNow();
}
return result.Count;
}
private async Task<MetaDownloadResult> DownloadFileCoreAsync(string relativePath, CancellationToken cancellationToken)
{
var result = new MetaDownloadResult { RelativePath = relativePath };
if (string.IsNullOrWhiteSpace(relativePath))
{
result.Message = "No file selected.";
return result;
}
if (!_storage.IsAvailable)
{
result.Message = "VTank path could not be resolved.";
return result;
}
// The original dropped any file into the profile folder; only the
// two kinds the automation plugin reads have a folder here.
string? localPath = LocalKeyFor(relativePath);
if (localPath is null)
{
result.Message = "Only .met and .nav files can be pulled.";
return result;
}
string remoteText = await DownloadRemoteTextAsync(relativePath, cancellationToken).ConfigureAwait(false);
string remoteHash = Sha256(remoteText);
string? localText = _storage.ReadText(localPath);
if (localText is not null)
{
string localHash = Sha256(localText);
if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase))
{
result.SkippedAsCurrent = true;
result.Message = "Already up to date.";
UpdateSingleCachedEntry(relativePath, localPath, localHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
_storage.WriteText(localPath + ".bak", localText);
result.BackupCreated = true;
}
_storage.WriteText(localPath, remoteText);
result.Downloaded = true;
result.Message = result.BackupCreated ? "Downloaded and updated (.bak created)." : "Downloaded.";
UpdateSingleCachedEntry(relativePath, localPath, remoteHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
private async Task<List<string>> FetchRemoteTreeEntriesAsync(CancellationToken cancellationToken)
{
(string treeUrl, _) = ResolveEndpoints(_repositoryUrl());
using HttpResponseMessage response = await _http.GetAsync(treeUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (JObject.Parse(json)["tree"] is not JArray tree)
return [];
return [.. tree
.Where(static node => string.Equals(node["type"]?.ToString(), "blob", StringComparison.OrdinalIgnoreCase))
.Select(static node => node["path"]?.ToString())
.Where(static path => !string.IsNullOrWhiteSpace(path))
.Select(static path => path!)];
}
private async Task<string> DownloadRemoteTextAsync(string relativePath, CancellationToken cancellationToken)
{
(_, string rawBaseUrl) = ResolveEndpoints(_repositoryUrl());
string escapedPath = string.Join(
"/",
(relativePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Select(Uri.EscapeDataString));
byte[] bytes = await _http.GetByteArrayAsync($"{rawBaseUrl}/{escapedPath}", cancellationToken)
.ConfigureAwait(false);
return DecodeAsStored(bytes);
}
/// <summary>
/// The text a read of this file from storage would give back: UTF-8,
/// with a leading byte-order mark taken as the encoding it names and
/// dropped, as the host's own text read does.
/// </summary>
internal static string DecodeAsStored(byte[] bytes)
{
using var reader = new StreamReader(new MemoryStream(bytes), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}
internal static string Sha256(string text) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(CultureInfo.InvariantCulture);
private HashSet<string> ListLocalKeys()
{
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!_storage.IsAvailable)
return keys;
foreach (string key in _storage.List(MetaFolder))
keys.Add(key);
foreach (string key in _storage.List(NavFolder))
keys.Add(key);
return keys;
}
private List<MetaFileEntry> CloneCache()
{
lock (_sync)
return [.. _cachedFiles.Select(static entry => entry.Clone())];
}
private void UpdateSingleCachedEntry(
string relativePath,
string localPath,
string localHash,
string remoteHash,
MetaFileSyncStatus status,
string statusText)
{
lock (_sync)
{
MetaFileEntry? target = _cachedFiles.FirstOrDefault(entry =>
string.Equals(entry.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase));
if (target is null)
return;
target.LocalPath = localPath;
target.LocalExists = status == MetaFileSyncStatus.UpToDate
|| (localPath.Length > 0 && _storage.ReadText(localPath) is not null);
target.LocalSha256 = localHash ?? string.Empty;
target.RemoteSha256 = remoteHash ?? string.Empty;
target.Status = status;
target.StatusText = statusText ?? "Unknown";
}
}
}

View file

@ -0,0 +1,172 @@
using System.Globalization;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// The binding object of the Metas window (<c>mosswartmassacre-metas.xml</c>),
/// the original's Metas tab: the repository's files with their status, a
/// live search, a pick list for one file, and the refresh, check and
/// download buttons. Assigned on the tick thread only.
/// </summary>
internal sealed class MetasViewModel
{
private readonly Dictionary<string, string> _pathByLabel = new(StringComparer.Ordinal);
public MetasViewModel()
{
IgnoreRow = _ => { };
SearchChanged = text =>
{
SearchText = text ?? string.Empty;
SearchRequested?.Invoke();
};
ClearSearch = () =>
{
SearchText = string.Empty;
SearchRequested?.Invoke();
};
SelectionChanged = label => SelectedLabel = label ?? string.Empty;
RefreshList = () => ButtonPressed?.Invoke(nameof(RefreshList));
CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates));
DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated));
DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected));
}
/// <summary>A button was pressed; the argument is its binding name.</summary>
internal event Action<string>? ButtonPressed;
/// <summary>The search text changed or was cleared.</summary>
internal event Action? SearchRequested;
/// <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; }
public string PathText { get; set; } = "VTank Path: unresolved";
public string LastCheckText { get; set; } = "Last Check: never";
public string StatusText { get; set; } = "Status: idle";
public string SearchText { get; set; } = string.Empty;
public Action<string> SearchChanged { get; }
public Action ClearSearch { get; }
public Action RefreshList { get; }
public Action CheckUpdates { get; }
public Action DownloadOutdated { get; }
public Action DownloadSelected { get; }
public int NoRow => -1;
public Action<int> IgnoreRow { get; }
public IReadOnlyList<string> FileTypes { get; private set; } = [];
public IReadOnlyList<string> FilePaths { get; private set; } = [];
public IReadOnlyList<string> FileStatus { get; private set; } = [];
public IReadOnlyList<uint> FileStatusColors { get; private set; } = [];
public IReadOnlyList<string> SelectionItems { get; private set; } = [];
public string SelectedLabel { get; set; } = string.Empty;
public Action<string> SelectionChanged { get; }
/// <summary>The repository path of the file picked in the list, or empty.</summary>
public string SelectedRelativePath =>
_pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty;
/// <summary>
/// Redraws the list and the pick list from the manager's files, filtered
/// by the search text on path or name. The list puts the files that need
/// attention first: missing, then outdated, then failed, then current.
/// </summary>
public void ShowFiles(IReadOnlyList<MetaFileEntry> all)
{
IReadOnlyList<MetaFileEntry> files = ApplySearch(all);
List<MetaFileEntry> ordered = [.. files
.OrderBy(static file => file.Status switch
{
MetaFileSyncStatus.NotDownloaded => 0,
MetaFileSyncStatus.UpdateAvailable => 1,
MetaFileSyncStatus.Error => 2,
_ => 3,
})
.ThenBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)];
if (ordered.Count == 0)
{
bool searching = !string.IsNullOrWhiteSpace(SearchText);
FileTypes = ["-"];
FilePaths = [searching ? "No matches" : "No files loaded"];
FileStatus = [searching ? "Try a different filter" : "Click Refresh List"];
FileStatusColors = [0xFFFFFFu];
}
else
{
FileTypes = [.. ordered.Select(static file => file.Extension.ToUpperInvariant())];
FilePaths = [.. ordered.Select(static file => file.RelativePath)];
FileStatus = [.. ordered.Select(static file => file.StatusText)];
FileStatusColors = [.. ordered.Select(static file => StatusColor(file.Status))];
}
string previous = SelectedRelativePath;
_pathByLabel.Clear();
var labels = new List<string>();
foreach (MetaFileEntry file in files.OrderBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase))
{
string label = $"[{file.Extension}] {file.RelativePath}";
labels.Add(label);
_pathByLabel[label] = file.RelativePath;
}
SelectionItems = labels;
SelectedLabel = labels.FirstOrDefault(label =>
previous.Length > 0 && _pathByLabel[label].Equals(previous, StringComparison.OrdinalIgnoreCase))
?? labels.FirstOrDefault()
?? string.Empty;
}
public void ShowLastCheck(DateTime lastStatusCheckUtc, DateTime lastCatalogRefreshUtc)
{
DateTime stamp = lastStatusCheckUtc != DateTime.MinValue ? lastStatusCheckUtc : lastCatalogRefreshUtc;
LastCheckText = stamp == DateTime.MinValue
? "Last Check: never"
: string.Create(
CultureInfo.InvariantCulture,
$"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}");
}
public void ShowPath(string? storageRoot) =>
PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}";
/// <summary>The status text colour: green current, orange outdated, blue missing, red failed.</summary>
public static uint StatusColor(MetaFileSyncStatus status) => status switch
{
MetaFileSyncStatus.UpToDate => 0x008000u,
MetaFileSyncStatus.UpdateAvailable => 0xB46E00u,
MetaFileSyncStatus.NotDownloaded => 0x0046A0u,
MetaFileSyncStatus.Error => 0xB40000u,
_ => 0xFFFFFFu,
};
private IReadOnlyList<MetaFileEntry> ApplySearch(IReadOnlyList<MetaFileEntry> files)
{
string filter = SearchText.Trim();
if (filter.Length == 0)
return files;
return [.. files.Where(file =>
file.RelativePath.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| file.FileName.Contains(filter, StringComparison.OrdinalIgnoreCase))];
}
}

View file

@ -0,0 +1,325 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Quests;
/// <summary>One line of the server's <c>/myquests</c> answer.</summary>
internal sealed class Quest
{
public string Id { get; init; } = string.Empty;
public int Solves { get; init; }
/// <summary>When the quest was last solved, in Unix seconds.</summary>
public int Timestamp { get; init; }
public string Description { get; init; } = string.Empty;
public int MaxSolves { get; init; }
/// <summary>The quest's cooldown in seconds.</summary>
public int Delta { get; init; }
/// <summary>When the quest can be done again, in Unix seconds.</summary>
public int ExpireTime { get; init; }
}
/// <summary>
/// The character's quest timers, read the way the original read them: ask the
/// server with <c>/myquests</c> and, for the next three seconds, parse every
/// chat line that looks like one of its answer lines. Every thirty seconds the
/// timers of the priority quests go to the backend as <c>quest</c> frames.
/// </summary>
/// <remarks>
/// The answer lines stay in the chat window; the original never hid them.
/// Everything here runs on the tick thread: the chat event, the three-second
/// window and the stream are all driven by the host's tick.
/// </remarks>
internal sealed class QuestManager : IMmFeature
{
/// <summary>How long after asking the chat lines are read as answers.</summary>
public static readonly TimeSpan RefreshWindow = TimeSpan.FromSeconds(3);
/// <summary>How often the priority quests are streamed.</summary>
public static readonly TimeSpan StreamInterval = TimeSpan.FromSeconds(30);
// One answer line: "stamp - N solves (lastSolved)"message" maxSolves delta".
private static readonly Regex QuestLine = new(
@"([^\-]+) - (\d+) solves \((\d+)\)""([^""]+)"" (-?\d+) (\d+)",
RegexOptions.CultureInvariant);
private readonly MmContext _context;
private readonly List<Quest> _quests = [];
private readonly Dictionary<string, Quest> _questsById = new(StringComparer.Ordinal);
private readonly Action<PluginChatMessage> _onChat;
private IDisposable? _refreshWindow;
private IDisposable? _stream;
private bool _subscribed;
public QuestManager(MmContext context)
{
_context = context;
_onChat = OnChat;
context.Commands.Register("refreshquests", HandleRefreshQuests, "Refresh quest data");
context.Commands.Register("queststatus", HandleQuestStatus, "Show quest streaming status");
}
/// <summary>Raised on the tick thread whenever the quest list changes.</summary>
public event Action? QuestsChanged;
/// <summary>Every parsed answer line, in the order the server sent them.</summary>
public IReadOnlyList<Quest> QuestList => _quests;
/// <summary>True while the answer to a <c>/myquests</c> is being read.</summary>
public bool IsRefreshing { get; private set; }
/// <summary>True while the thirty-second stream is scheduled.</summary>
public bool IsStreaming => _stream is not null;
public void OnLogin()
{
if (!_subscribed)
{
_context.Automation.Chat.Received += _onChat;
_subscribed = true;
}
ClearQuests();
RefreshQuests();
_stream?.Dispose();
_stream = _context.Scheduler.Every(StreamInterval, StreamPriorityQuests);
}
public void OnLogoff()
{
_stream?.Dispose();
_stream = null;
_refreshWindow?.Dispose();
_refreshWindow = null;
IsRefreshing = false;
if (_subscribed)
{
_context.Automation.Chat.Received -= _onChat;
_subscribed = false;
}
ClearQuests();
}
public void Dispose() => OnLogoff();
/// <summary>
/// Asks the server for the quest list. A request while the previous answer
/// is still being read is ignored, as it was in the original.
/// </summary>
public void RefreshQuests()
{
try
{
if (IsRefreshing)
return;
ClearQuests();
IsRefreshing = true;
_context.Automation.Chat.Submit("/myquests");
_refreshWindow = _context.Scheduler.Delay(RefreshWindow, () =>
{
IsRefreshing = false;
_refreshWindow = null;
QuestsChanged?.Invoke();
});
}
catch (Exception ex)
{
IsRefreshing = false;
_context.Chat.Write($"Error refreshing quests: {ex.Message}");
}
}
public void ClearQuests()
{
bool had = _quests.Count > 0;
_quests.Clear();
_questsById.Clear();
if (had)
QuestsChanged?.Invoke();
}
public bool HasQuestFlag(string questStamp) => _questsById.ContainsKey(questStamp);
public bool TryGetQuest(string questStamp, out Quest quest) =>
_questsById.TryGetValue(questStamp, out quest!);
/// <summary>The priority quests, first answer line per stamp, in answer order.</summary>
public IReadOnlyList<Quest> PriorityQuests() =>
[.. _quests
.Where(quest => QuestStreamingService.IsHighPriorityQuest(quest.Id))
.GroupBy(quest => quest.Id)
.Select(group => group.First())];
/// <summary>Parses one chat line; a line that is not an answer line is ignored.</summary>
internal void ParseQuestLine(string text)
{
try
{
Match match = QuestLine.Match(text);
if (!match.Success)
return;
int timestamp = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
int delta = int.Parse(match.Groups[6].Value, CultureInfo.InvariantCulture);
var quest = new Quest
{
Id = match.Groups[1].Value.Trim(),
Solves = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture),
Timestamp = timestamp,
Description = match.Groups[4].Value,
MaxSolves = int.Parse(match.Groups[5].Value, CultureInfo.InvariantCulture),
Delta = delta,
// Unchecked, as the original's int sum was.
ExpireTime = unchecked(timestamp + delta),
};
_quests.Add(quest);
_questsById[quest.Id] = quest;
QuestsChanged?.Invoke();
}
catch (Exception ex)
{
_context.Chat.Write($"Error parsing quest line '{text}': {ex.Message}");
}
}
/// <summary>The now the countdowns are measured from, in Unix seconds.</summary>
internal long UnixNow => new DateTimeOffset(DateTime.SpecifyKind(_context.Clock.UtcNow, DateTimeKind.Utc))
.ToUnixTimeSeconds();
/// <summary>One run of the thirty-second stream. Internal so tests can run it directly.</summary>
internal void StreamPriorityQuests()
{
try
{
_context.Verbose("[QUEST-STREAM] Timer fired, checking conditions...");
if (!_context.Settings.Current.WebSocketEnabled)
{
_context.Verbose("[QUEST-STREAM] WebSocket not enabled, skipping");
return;
}
if (_quests.Count == 0)
{
_context.Verbose($"[QUEST-STREAM] No quest data available (null: False, count: {_quests.Count})");
return;
}
long currentTime = UnixNow;
IReadOnlyList<Quest> priorityQuests = PriorityQuests();
_context.Verbose($"[QUEST-STREAM] Found {priorityQuests.Count} priority quests to stream");
foreach (Quest quest in priorityQuests)
{
try
{
string questName = QuestNames.GetFriendlyName(quest.Id);
string countdown = QuestStreamingService.FormatCountdown(quest.ExpireTime - currentTime);
_context.Verbose($"[QUEST-STREAM] Sending: {questName} - {countdown}");
_context.Send(QuestStreamingService.BuildFrame(_context, questName, countdown));
}
catch (Exception ex)
{
_context.Chat.Write($"[QUEST-STREAM] Error streaming quest {quest.Id}: {ex.Message}");
}
}
}
catch (Exception ex)
{
_context.Chat.Write($"[QUEST-STREAM] Error in timer handler: {ex.Message}");
}
}
/// <summary>Last solved, as the flag tracker shows it: UTC, month first.</summary>
public static string FormatTimeStamp(int timestamp)
{
try
{
DateTime dateTime = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
return dateTime.ToString("MM/dd/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
}
catch (ArgumentOutOfRangeException)
{
return "Invalid";
}
}
/// <summary>A duration as "1d 2h 3m 4s", leaving out the zero parts.</summary>
public static string FormatSeconds(int seconds)
{
if (seconds <= 0)
return "0s";
int days = seconds / 86400;
seconds %= 86400;
int hours = seconds / 3600;
seconds %= 3600;
int minutes = seconds / 60;
seconds %= 60;
string result = string.Empty;
if (days > 0)
result += string.Create(CultureInfo.InvariantCulture, $"{days}d ");
if (hours > 0)
result += string.Create(CultureInfo.InvariantCulture, $"{hours}h ");
if (minutes > 0)
result += string.Create(CultureInfo.InvariantCulture, $"{minutes}m ");
if (seconds > 0 || string.IsNullOrEmpty(result))
result += string.Create(CultureInfo.InvariantCulture, $"{seconds}s");
return result.Trim();
}
private void OnChat(PluginChatMessage message)
{
if (!IsRefreshing || string.IsNullOrEmpty(message.Text))
return;
ParseQuestLine(message.Text);
}
private void HandleRefreshQuests(string[] args)
{
_ = args;
try
{
_context.Chat.Write("[QUEST] Refreshing quest data...");
RefreshQuests();
}
catch (Exception ex)
{
_context.Chat.Write($"[QUEST] Refresh failed: {ex.Message}");
}
}
private void HandleQuestStatus(string[] args)
{
_ = args;
try
{
_context.Chat.Write("=== Quest Streaming Status ===");
_context.Chat.Write($"Timer Active: {IsStreaming}");
_context.Chat.Write($"WebSocket Enabled: {_context.Settings.Current.WebSocketEnabled}");
_context.Chat.Write($"Quest Manager: {(_context.IsLoggedIn ? "Active" : "Not Active")}");
_context.Chat.Write($"Quest Count: {_quests.Count}");
IReadOnlyList<Quest> priorityQuests = PriorityQuests();
_context.Chat.Write($"Priority Quests Found: {priorityQuests.Count}");
foreach (Quest quest in priorityQuests)
_context.Chat.Write($" - {QuestNames.GetFriendlyName(quest.Id)} ({quest.Id})");
_context.Chat.Write($"Verbose Logging: {_context.Settings.Current.VerboseLogging}");
_context.Chat.Write("Use '/mm verbose' to toggle debug logging");
}
catch (Exception ex)
{
_context.Chat.Write($"[QUEST] Status check failed: {ex.Message}");
}
}
}

View file

@ -0,0 +1,211 @@
namespace OpenAC.MosswartMassacre.Quests;
/// <summary>
/// Friendly names for quest stamps, the table the original plugin shipped.
/// The friendly name is what the backend's <c>quest</c> frame carries as
/// <c>quest_name</c>, so the table is copied as it was, including the stamp
/// listed twice (<c>insatiableeaterjaw</c>): the later entry wins, exactly as
/// the original's indexer initializer made it win.
/// </summary>
internal static class QuestNames
{
public static readonly Dictionary<string, string> QuestStampToName = new()
{
// Character-specific Quest Stamps (from actual /myquests output)
["30minattributes"] = "30 Minute Attribute Gems Timer",
["academeyexittokengiven"] = "Academy Exit Token Received",
["aerbaxchestkey2pickup"] = "Aerbax Chest Key #2 Pickup",
["anekshaygemofknowledgetimer_monthly"] = "A'nekshay Gem of Knowledge Monthly Timer",
["anekshaygemoflesserknowledgecollectedinamonth"] = "A'nekshay Gems of Lesser Knowledge Monthly Count",
["anekshaygemoflesserknowledgetimer_monthly"] = "A'nekshay Gem of Lesser Knowledge Monthly Timer",
["attributereset30day"] = "30-Day Attribute Reset Timer",
["augmentationblankgemacquired"] = "Blank Augmentation Gem Pickup Timer",
["bellowsnewbieturnedin"] = "Blacksmith's Bellows Turned In",
["bonecrunchkeypickuptimer"] = "Bonecrunch's Key Pickup Timer",
["callingstonegiven"] = "Calling Stone Turned Over",
["defeatedbonecrunch"] = "Bonecrunch Defeated",
["efmlcentermanafieldused"] = "EF Middle Level Center Mana Field Used",
["efmleastmanafieldused"] = "EF Middle Level East Mana Field Used",
["efmlnorthmanafieldused"] = "EF Middle Level North Mana Field Used",
["efmlsouthmanafieldused"] = "EF Middle Level South Mana Field Used",
["efmlwestmanafieldused"] = "EF Middle Level West Mana Field Used",
["efulcentermanafieldused"] = "EF Upper Level Center Mana Field Used",
["efuleastmanafieldused"] = "EF Upper Level East Mana Field Used",
["efulnorthmanafieldused"] = "EF Upper Level North Mana Field Used",
["efulsouthmanafieldused"] = "EF Upper Level South Mana Field Used",
["efulwestmanafieldused"] = "EF Upper Level West Mana Field Used",
["insatiableeaterjaw"] = "Insatiable Eater Jaw Collection",
["pathwardencomplete"] = "Pathwarden Visit Complete",
["pathwardenfound1111"] = "Pathwarden Greeter Encountered",
["recallsingularitycaul"] = "Recall Singularity Bore Pickup",
["stipendscollectedinamonth"] = "Monthly Stipends Collected Count",
["stipendtimer_0812"] = "Stipend Collection Timer",
["stipendtimer_monthly"] = "Monthly Stipend Timer",
["upperinsatiablejaw"] = "Upper Insatiable Eater Jaw Collection",
["usedattributereset"] = "Attribute Reset Used",
["usedfreeattributereset"] = "Free Attribute Reset Used",
["usedfreeskillreset"] = "Free Skill Reset Used",
["usedskillreset"] = "Skill Reset Used",
["virindiisland"] = "Singularity Island Visit",
// Kill Tasks
["turshscalp"] = "Tursh Scalp",
["polarursuin"] = "Polar Ursuin Kill Task Main Flag Timer",
["polarursuinkillcount"] = "Polar Ursuin Kill Counter",
["polardillotask"] = "Polar Dillo Kill Task Main Flag",
["polardillokills"] = "Polar Dillo Kill Counter",
["repugnanteaterkilltask"] = "Repugnant Eater Kill Task",
["repugeaterkillcount"] = "Repugnant Eater Kill Counter",
["deathcap"] = "Deathcap Thrungus Kill Task",
["deathcapkillcount"] = "Deathcap Thrungus Kill Counter",
["grievverv"] = "Grievver Violator Kill Task",
["grievvervkillcount"] = "Grievver Violator Kill Counter",
["tuskerg"] = "Tusker Guard Kill Task Main Flag",
["tuskergkillcount"] = "Tusker Guard Kill Counter",
// Quest Timers and Pickups
["blankaug"] = "Blank Aug Gem Pickup Timer",
["greatcavepenguinegg"] = "Great Cave Penguin Egg Pickup Timer",
["deathallurecd"] = "Death's Allure Timer Flag",
["brewmastercover"] = "Brew Master Quest Pickup Timer Cover",
["brewmasterback"] = "Brew Master Quest Pickup Timer Back",
["brewmasterpages"] = "Brew Master Quest Pickup Timer Pages",
["brewmasterspine"] = "Brew Master Quest Pickup Timer Spine",
["eleonorasheart"] = "Elanora's Heart Quest Pickup Timer",
["beacongemobtained"] = "Cooldown for obtaining another beacon gem",
["beaconcomplete"] = "Beacon Quest Complete Timer",
["sirginaziosword"] = "Pick up of Sir Ginazio's Sword",
// Major Quests
["maraudersjaw"] = "Marauder's Lair Quest",
["fledgemastertusk"] = "Fledge Master's Tusk Quest",
["crystallinekiller"] = "Crystalline Killer",
["darkisledelivery"] = "Dark Isle Delivery",
["defeatingvaeshok"] = "Defeating Vaeshok",
["hollyjollyhelperquest"] = "Holly Jolly Helper Quest",
["moarsmenjailbreak"] = "Moarsmen Jailbreak",
["shamblingarchivistdestroyer"] = "Shambling Archivist Destroyer",
["tracingthestone"] = "Tracing The Stone",
["undeadjawcollection"] = "Undead Jaw Collection",
["weedingofthederutree"] = "Weeding of the Deru Tree",
["ironbladecommander"] = "Iron Blade Commander",
["mumiyahhuntingneftet"] = "Mumiyah Hunting Neftet",
["torgashstasks"] = "Torgash's Tasks",
// Thrungus Hovels Items
["stolenfryingpan"] = "Thrungus Hovels",
["stolenring"] = "Thrungus Hovels",
["stolenbrewkettle"] = "Thrungus Hovels",
["stolenamulet"] = "Thrungus Hovels",
["stolenewer"] = "Thrungus Hovels",
["stolennecklace"] = "Thrungus Hovels",
["stolenplatter"] = "Thrungus Hovels",
["stolenbracelet"] = "Thrungus Hovels",
// Special Items and Flags
["ringofkarlun"] = "Knights of Karlun",
["trainingacademycomplete"] = "Completion of Training Academy for Exit",
["cowtipcounter"] = "Counter for Cow Tipping",
["cowtip"] = "Main Timed Flag for Cow Tipping",
["skillloweringgempickedup"] = "Picked up a forgetfulness gem",
// Healing Machine Components
["orbhealingmachine"] = "Healing Machine Orb",
["pedestalhealingmachine"] = "Healing Machine Pedestal",
["tihnhealingmachine"] = "Healing Machine Tihn",
["lavushealingmachine"] = "Healing Machine Lavus",
["hookhealingmachine"] = "Healing Machine Hook",
// Eater Jaws
["ravenouseaterjaw"] = "Ravenous Eater Jaw",
["insatiableeaterjaw"] = "Insatiable Eater Jaw",
["engorgedeaterjaw"] = "Engorged Eater Jaw",
["voraciouseaterjaw"] = "Voracious Eater Jaw",
["abhorrenteaterjaw"] = "Abhorrent Eater Jaw",
// Kill Tasks (Extended)
["altereddrudgekilltask"] = "Altered Drudge Kill Task",
["altereddrudgekillcount"] = "Altered Drudge Kill Counter",
["arcticmattekarkilltask"] = "Arctic Mattekar Kill Task",
["arcticmattekarkillcount"] = "Arctic Mattekar Kill Counter",
["armoredillohuntingneftetkilltask"] = "Armoredillo Hunting Neftet Kill Task",
["armoredillohuntingneftetkillcount"] = "Armoredillo Hunting Neftet Kill Counter",
["augmenteddrudgekilltask"] = "Augmented Drudge Kill Task",
["augmenteddrudgekillcount"] = "Augmented Drudge Kill Counter",
["banishedcreaturekilltask"] = "Banished Creature Kill Task",
["banishedcreaturekillcount"] = "Banished Creature Kill Counter",
["benekniffiskilltask"] = "Benek Niffis Kill Task",
["benekniffiskillcount"] = "Benek Niffis Kill Counter",
["blackcoralgolemkilltask"] = "Black Coral Golem Kill Task",
["blackcoralgolemkillcount"] = "Black Coral Golem Kill Counter",
["blessedmoarsmankilltask"] = "Blessed Moarsman Kill Task",
["blessedmoarsmankillcount"] = "Blessed Moarsman Kill Counter",
["blightedcoralgolemkilltask"] = "Blighted Coral Golem Kill Task",
["blightedcoralgolemkillcount"] = "Blighted Coral Golem Kill Counter",
["bloodshrethkilltask"] = "Blood Shreth Kill Task",
["bloodshrethkillcount"] = "Blood Shreth Kill Counter",
["bronzegauntlettrooperkilltask"] = "Bronze Gauntlet Trooper Kill Task",
["bronzegauntlettrooperkillcount"] = "Bronze Gauntlet Trooper Kill Counter",
["coppercogtrooperkilltask"] = "Copper Cog Trooper Kill Task",
["coppercogtrooperkillcount"] = "Copper Cog Trooper Kill Counter",
["coppergolemkingpinkilltask"] = "Copper Golem Kingpin Kill Task",
["coppergolemkingpinkillcount"] = "Copper Golem Kingpin Kill Counter",
["coralgolemkilltask"] = "Coral Golem Kill Task",
["coralgolemkillcount"] = "Coral Golem Kill Counter",
["coralgolemviceroykilltask"] = "Coral Golem Viceroy Kill Task",
["coralgolemviceroykillcount"] = "Coral Golem Viceroy Kill Counter",
["corruptedgravestonekilltask"] = "Corrupted Gravestone Kill Task",
["corruptedgravestonekillcount"] = "Corrupted Gravestone Kill Counter",
["deathcapthrunguskilltask"] = "Deathcap Thrungus Kill Task",
["deathcapthrunguskillcount"] = "Deathcap Thrungus Kill Counter",
["desertcactuskilltask"] = "Desert Cactus Kill Task",
["desertcactuskillcount"] = "Desert Cactus Kill Counter",
["devourermargulkilltask"] = "Devourer Margul Kill Task",
["devourermargulkillcount"] = "Devourer Margul Kill Counter",
// Society and Faction Quests
["celestialhandintroductioncomplete"] = "Celestial Hand Introduction Complete",
["eldrytchwebintroductioncomplete"] = "Eldrytch Web Introduction Complete",
["radiantbloodintroductioncomplete"] = "Radiant Blood Introduction Complete",
["celestialhandinitiatetest"] = "Celestial Hand Initiate Test",
["eldrytchwebinitiatetest"] = "Eldrytch Web Initiate Test",
["radiantbloodinitiatetest"] = "Radiant Blood Initiate Test",
// Luminance Aura Related
["aetheriaredemption"] = "Aetheria Redemption",
["aegisofmerc"] = "Aegis of Merc",
["lumaugtradein"] = "Luminance Augmentation Trade In",
// Common AC Quests
["holtburgtraderskill"] = "Holtburg Trader Skill Quest",
["shoushitraderskill"] = "Shoushi Trader Skill Quest",
["yaraqtraderskill"] = "Yaraq Trader Skill Quest",
["newbiequests"] = "Newbie Academy Quests",
["moarsmanraid"] = "Moarsman Raid",
["virindiparadox"] = "Virindi Paradox",
["portalspace"] = "Portal Space Exploration"
};
/// <summary>The friendly name for a stamp, or the stamp itself when the table has none.</summary>
public static string GetFriendlyName(string questStamp)
{
if (string.IsNullOrEmpty(questStamp))
return questStamp;
return QuestStampToName.TryGetValue(questStamp.ToLower(System.Globalization.CultureInfo.InvariantCulture), out string? friendlyName)
? friendlyName
: questStamp;
}
/// <summary>The friendly name with the stamp in parentheses, or the stamp alone.</summary>
public static string GetDisplayName(string questStamp)
{
if (string.IsNullOrEmpty(questStamp))
return questStamp;
string friendlyName = GetFriendlyName(questStamp);
return !string.Equals(friendlyName, questStamp, StringComparison.OrdinalIgnoreCase)
? $"{friendlyName} ({questStamp})"
: questStamp;
}
}

View file

@ -0,0 +1,70 @@
using System.Globalization;
using Newtonsoft.Json;
using OpenAC.MosswartMassacre.Backend;
namespace OpenAC.MosswartMassacre.Quests;
/// <summary>
/// The <c>quest</c> frame: <c>type</c>, <c>timestamp</c>, <c>character_name</c>,
/// <c>quest_name</c>, <c>countdown</c>, in that order, all strings. The
/// original wrote it from an anonymous object with those five members in that
/// order; this class declares them in the same order so Newtonsoft writes the
/// same bytes.
/// </summary>
internal sealed class QuestFrame
{
[JsonProperty("type")]
public string Type { get; } = "quest";
[JsonProperty("timestamp")]
public string Timestamp { get; init; } = string.Empty;
[JsonProperty("character_name")]
public string CharacterName { get; init; } = string.Empty;
[JsonProperty("quest_name")]
public string QuestName { get; init; } = string.Empty;
[JsonProperty("countdown")]
public string Countdown { get; init; } = string.Empty;
}
/// <summary>Which quests stream, how their countdown is written, and the frame they go in.</summary>
internal static class QuestStreamingService
{
/// <summary>The three stamps the original streamed, and only those.</summary>
public static bool IsHighPriorityQuest(string questId) =>
questId == "stipendtimer_0812"
|| questId == "augmentationblankgemacquired"
|| questId == "insatiableeaterjaw";
/// <summary>
/// The countdown text: <c>READY</c> at or past zero, then the two largest
/// units, the smaller one zero-padded: <c>1d 02h</c>, <c>3h 05m</c>,
/// <c>4m 09s</c>, <c>59s</c>. The backend shows this string as it is.
/// </summary>
public static string FormatCountdown(long seconds)
{
if (seconds <= 0)
return "READY";
TimeSpan timeSpan = TimeSpan.FromSeconds(seconds);
if (timeSpan.TotalDays >= 1)
return string.Create(CultureInfo.InvariantCulture, $"{(int)timeSpan.TotalDays}d {timeSpan.Hours:D2}h");
if (timeSpan.TotalHours >= 1)
return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Hours}h {timeSpan.Minutes:D2}m");
if (timeSpan.TotalMinutes >= 1)
return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Minutes}m {timeSpan.Seconds:D2}s");
return string.Create(CultureInfo.InvariantCulture, $"{timeSpan.Seconds}s");
}
public static QuestFrame BuildFrame(MmContext context, string questName, string countdown) =>
new()
{
Timestamp = Wire.Timestamp(context.Clock),
CharacterName = context.CharacterName,
QuestName = questName,
Countdown = countdown,
};
}

View 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>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Metas window: the original's Metas tab in a window of its own. The
repository's .met/.nav files with their status, a live search, a pick list
for one file, and refresh, check and download. -->
<panel x="80" y="80" w="420" h="320" title="Metas"
visible="{IsVisible}" resizable="true" minw="420" minh="320">
<label x="10" y="24" text="Remote .met/.nav files from metas repo" />
<label x="10" y="40" text="{PathText}" />
<button x="10" y="58" w="95" h="22" text="Refresh List" onclick="{RefreshList}" />
<button x="110" y="58" w="95" h="22" text="Check Updates" onclick="{CheckUpdates}" />
<button x="210" y="58" w="120" h="22" text="Download Outdated" onclick="{DownloadOutdated}" />
<label x="10" y="86" text="Search:" />
<field x="58" y="84" w="212" h="20" text="{SearchText}" onchange="{SearchChanged}" />
<button x="275" y="83" w="55" h="22" text="Clear" onclick="{ClearSearch}" />
<list x="10" y="110" w="400" h="128" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="45" items="{FileTypes}" />
<column type="text" width="230" items="{FilePaths}" />
<column type="text" width="*" items="{FileStatus}" colors="{FileStatusColors}" />
</list>
<label x="10" y="246" text="Selected file:" anchor="left bottom" />
<menu x="95" y="244" w="225" h="20" anchor="left bottom"
items="{SelectionItems}" selected="{SelectedLabel}" onchange="{SelectionChanged}" />
<button x="325" y="243" w="85" h="22" text="Download" anchor="left bottom"
onclick="{DownloadSelected}" />
<label x="10" y="272" text="{LastCheckText}" anchor="left bottom" />
<label x="10" y="290" text="{StatusText}" anchor="left bottom" />
</panel>