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:
commit
2e5d768ca9
19 changed files with 3984 additions and 1 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
672
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs
Normal file
672
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerData.cs
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.FlagTracker;
|
||||
|
||||
/// <summary>
|
||||
/// What the flag tracker shows, and how each tab reads it from the character:
|
||||
/// augmentation and luminance counts from the player's own integer properties,
|
||||
/// recalls from the spellbook, cantrips from the active enchantments matched
|
||||
/// against the trained skills, and the tracked weapons from the inventory by
|
||||
/// name. The tables are the original plugin's, copied as they were.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The original's society, facility hub and character flag sections were
|
||||
/// never implemented there and are not here. Every read happens on the tick
|
||||
/// thread, when a tab is refreshed.
|
||||
/// </remarks>
|
||||
internal sealed class FlagTrackerData
|
||||
{
|
||||
/// <summary>Row text colours, <c>0xRRGGBB</c>, the named colours the original used.</summary>
|
||||
public static class Colors
|
||||
{
|
||||
public const uint White = 0xFFFFFF;
|
||||
public const uint Red = 0xFF0000;
|
||||
public const uint Green = 0x008000;
|
||||
public const uint Yellow = 0xFFFF00;
|
||||
public const uint Blue = 0x0000FF;
|
||||
public const uint Purple = 0x800080;
|
||||
public const uint Orange = 0xFFA500;
|
||||
}
|
||||
|
||||
/// <summary>Icons the original drew: the cantrip lamps, the category star and the portal fallback.</summary>
|
||||
public static class Icons
|
||||
{
|
||||
public const uint GreenCircle = 0x060011F9;
|
||||
public const uint RedCircle = 0x060011F8;
|
||||
public const uint CategoryStar = 0x06002856;
|
||||
public const uint Portal = 0x06002D14;
|
||||
}
|
||||
|
||||
public const string NotAvailable = "N/A";
|
||||
|
||||
public sealed class AugmentationInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public uint? IntId { get; init; }
|
||||
public int Repeatable { get; init; }
|
||||
public string Trainer { get; init; } = string.Empty;
|
||||
public string Location { get; init; } = string.Empty;
|
||||
public int CurrentValue { get; set; }
|
||||
public bool IsMaxed => CurrentValue >= Repeatable;
|
||||
}
|
||||
|
||||
public sealed class LuminanceAuraInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public uint IntId { get; init; }
|
||||
public int Cap { get; init; }
|
||||
|
||||
/// <summary>The seer's loyalty flag, for the seer auras only.</summary>
|
||||
public string? QuestFlag { get; init; }
|
||||
|
||||
public int CurrentValue { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RecallSpellInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public uint SpellId { get; init; }
|
||||
public string Category { get; init; } = string.Empty;
|
||||
public bool IsKnown { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CantripInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>"Minor" through "Legendary", or "N/A" when the character has none.</summary>
|
||||
public string Value { get; set; } = NotAvailable;
|
||||
|
||||
public uint Color { get; set; } = Colors.White;
|
||||
|
||||
/// <summary>The green lamp when the cantrip is up, the red one when it is not.</summary>
|
||||
public uint ComputedIconId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TrackedWeaponInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string Category { get; init; } = string.Empty;
|
||||
public string WeaponType { get; init; } = string.Empty;
|
||||
public int ItemId { get; init; }
|
||||
public string Status { get; set; } = "Unknown";
|
||||
public bool IsAcquired { get; set; }
|
||||
public uint IconId { get; set; }
|
||||
}
|
||||
|
||||
// Cantrip skill names that differ from the skill's own name.
|
||||
private static readonly Dictionary<uint, string> SkillCantripReplacements = new()
|
||||
{
|
||||
[15] = "MagicResistance",
|
||||
[6] = "Invulnerability",
|
||||
[7] = "Impgrenability",
|
||||
[47] = "MissileWeapon",
|
||||
[44] = "HeavyWeapon",
|
||||
[45] = "LightWeapon",
|
||||
[46] = "FinesseWeapon",
|
||||
};
|
||||
|
||||
private static readonly (uint Id, string Name)[] SkillIdToName =
|
||||
[
|
||||
(1, "Axe"), (2, "Bow"), (3, "Crossbow"), (4, "Dagger"), (5, "Mace"),
|
||||
(6, "Melee Defense"), (7, "Missile Defense"), (8, "Sling"), (9, "Spear"),
|
||||
(10, "Staff"), (11, "Sword"), (12, "Thrown Weapons"), (13, "Unarmed Combat"),
|
||||
(14, "Arcane Lore"), (15, "Magic Defense"), (16, "Mana Conversion"),
|
||||
(17, "Spellcraft"), (18, "Item Tinkering"), (19, "Assess Person"),
|
||||
(20, "Deception"), (21, "Healing"), (22, "Jump"), (23, "Lockpick"),
|
||||
(24, "Run"), (25, "Awareness"), (26, "Arms and Armor Repair"),
|
||||
(27, "Assess Creature"), (28, "Weapon Tinkering"), (29, "Armor Tinkering"),
|
||||
(30, "Magic Item Tinkering"), (31, "Creature Enchantment"),
|
||||
(32, "Item Enchantment"), (33, "Life Magic"), (34, "War Magic"),
|
||||
(35, "Leadership"), (36, "Loyalty"), (37, "Fletching"), (38, "Alchemy"),
|
||||
(39, "Cooking"), (40, "Salvaging"), (41, "Two Handed Combat"),
|
||||
(42, "Gearcraft"), (43, "Void Magic"), (44, "Heavy Weapons"),
|
||||
(45, "Light Weapons"), (46, "Finesse Weapons"), (47, "Missile Weapons"),
|
||||
(48, "Shield"), (49, "Dual Wield"), (50, "Recklessness"),
|
||||
(51, "Sneak Attack"), (52, "Dirty Fighting"), (53, "Threat Assessment"),
|
||||
(54, "Summoning"),
|
||||
];
|
||||
|
||||
private static readonly (string Prefix, uint Color)[] CantripPatterns =
|
||||
[
|
||||
("Minor", Colors.White),
|
||||
("Moderate", Colors.Green),
|
||||
("Major", Colors.Blue),
|
||||
("Epic", Colors.Purple),
|
||||
("Legendary", Colors.Orange),
|
||||
];
|
||||
|
||||
private static readonly (string Key, string Aura)[] ProtectionMappings =
|
||||
[
|
||||
("Armor", "Armor"),
|
||||
("Bludgeoning Ward", "Bludgeoning Ward"),
|
||||
("Piercing Ward", "Piercing Ward"),
|
||||
("Slashing Ward", "Slashing Ward"),
|
||||
("Flame Ward", "Flame Ward"),
|
||||
("Frost Ward", "Frost Ward"),
|
||||
("Cold Ward", "Frost Ward"),
|
||||
("Acid Ward", "Acid Ward"),
|
||||
("Storm Ward", "Storm Ward"),
|
||||
("Lightning Ward", "Storm Ward"),
|
||||
("Bludgeoning Protection", "Bludgeoning Ward"),
|
||||
("Piercing Protection", "Piercing Ward"),
|
||||
("Slashing Protection", "Slashing Ward"),
|
||||
("Fire Protection", "Flame Ward"),
|
||||
("Cold Protection", "Frost Ward"),
|
||||
("Acid Protection", "Acid Ward"),
|
||||
("Lightning Protection", "Storm Ward"),
|
||||
("Bludgeoning", "Bludgeoning Ward"),
|
||||
("Piercing", "Piercing Ward"),
|
||||
("Slashing", "Slashing Ward"),
|
||||
("Fire", "Flame Ward"),
|
||||
("Flame", "Flame Ward"),
|
||||
("Cold", "Frost Ward"),
|
||||
("Frost", "Frost Ward"),
|
||||
("Acid", "Acid Ward"),
|
||||
("Lightning", "Storm Ward"),
|
||||
("Storm", "Storm Ward"),
|
||||
];
|
||||
|
||||
private static readonly (string Key, string Attribute)[] AttributeMappings =
|
||||
[
|
||||
("Strength", "Strength"),
|
||||
("Endurance", "Endurance"),
|
||||
("Coordination", "Coordination"),
|
||||
("Quickness", "Quickness"),
|
||||
("Focus", "Focus"),
|
||||
("Self", "Willpower"),
|
||||
("Willpower", "Willpower"),
|
||||
];
|
||||
|
||||
private static readonly (string Key, string Skill)[] SkillMappings =
|
||||
[
|
||||
("Magic Resistance", "MagicResistance"),
|
||||
("Invulnerability", "Invulnerability"),
|
||||
("Impregnability", "Impgrenability"),
|
||||
("Heavy Weapon Aptitude", "HeavyWeapon"),
|
||||
("Light Weapon Aptitude", "LightWeapon"),
|
||||
("Finesse Weapon Aptitude", "FinesseWeapon"),
|
||||
("Missile Weapon Aptitude", "MissileWeapon"),
|
||||
("Alchemical Prowess", "Alchemy"),
|
||||
("Arcane Prowess", "Arcane Lore"),
|
||||
("Armor Tinkering Expertise", "Armor Tinkering"),
|
||||
("Assess Creature", "Assess Creature"),
|
||||
("Assess Person", "Assess Person"),
|
||||
("Cooking Prowess", "Cooking"),
|
||||
("Deception Prowess", "Deception"),
|
||||
("Fletching Prowess", "Fletching"),
|
||||
("Healing Prowess", "Healing"),
|
||||
("Item Tinkering Expertise", "Item Tinkering"),
|
||||
("Leadership", "Leadership"),
|
||||
("Lockpick Prowess", "Lockpick"),
|
||||
("Fealty", "Loyalty"),
|
||||
("Magic Item Tinkering Expertise", "Magic Item Tinkering"),
|
||||
("Mana Conversion Prowess", "Mana Conversion"),
|
||||
("Jumping Prowess", "Jump"),
|
||||
("Salvaging Aptitude", "Salvaging"),
|
||||
("Weapon Tinkering Expertise", "Weapon Tinkering"),
|
||||
("War Magic Aptitude", "War Magic"),
|
||||
("Life Magic Aptitude", "Life Magic"),
|
||||
("Creature Enchantment Aptitude", "Creature Enchantment"),
|
||||
("Item Enchantment Aptitude", "Item Enchantment"),
|
||||
("Void Magic Aptitude", "Void Magic"),
|
||||
("Summoning Prowess", "Summoning"),
|
||||
("Two Handed Combat Aptitude", "Two Handed Combat"),
|
||||
("Dual Wield Aptitude", "Dual Wield"),
|
||||
("Shield Aptitude", "Shield"),
|
||||
("Sneak Attack Prowess", "Sneak Attack"),
|
||||
("Dirty Fighting Prowess", "Dirty Fighting"),
|
||||
("Recklessness", "Recklessness"),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, int> CantripRank = new(StringComparer.Ordinal)
|
||||
{
|
||||
["Minor"] = 1,
|
||||
["Moderate"] = 2,
|
||||
["Major"] = 3,
|
||||
["Epic"] = 4,
|
||||
["Legendary"] = 5,
|
||||
};
|
||||
|
||||
private readonly IAutomationSurface _automation;
|
||||
|
||||
public FlagTrackerData(IAutomationSurface automation)
|
||||
{
|
||||
_automation = automation;
|
||||
AugmentationCategories = BuildAugmentations();
|
||||
LuminanceAuraCategories = BuildLuminanceAuras();
|
||||
RecallSpells = BuildRecalls();
|
||||
Cantrips = BuildCantrips();
|
||||
WeaponCategories = BuildWeapons();
|
||||
}
|
||||
|
||||
public Dictionary<string, List<AugmentationInfo>> AugmentationCategories { get; }
|
||||
|
||||
public Dictionary<string, List<LuminanceAuraInfo>> LuminanceAuraCategories { get; }
|
||||
|
||||
public List<RecallSpellInfo> RecallSpells { get; }
|
||||
|
||||
public Dictionary<string, Dictionary<string, CantripInfo>> Cantrips { get; }
|
||||
|
||||
public Dictionary<string, List<TrackedWeaponInfo>> WeaponCategories { get; private set; }
|
||||
|
||||
private bool InWorld => _automation.IsAvailable && _automation.Character.IsInWorld;
|
||||
|
||||
public void RefreshAll()
|
||||
{
|
||||
RefreshAugmentations();
|
||||
RefreshLuminanceAuras();
|
||||
RefreshRecallSpells();
|
||||
RefreshCantrips();
|
||||
RefreshWeapons();
|
||||
}
|
||||
|
||||
public void RefreshAugmentations()
|
||||
{
|
||||
if (!InWorld)
|
||||
return;
|
||||
foreach (List<AugmentationInfo> augmentations in AugmentationCategories.Values)
|
||||
{
|
||||
foreach (AugmentationInfo aug in augmentations)
|
||||
{
|
||||
// An augmentation with no property (the health one) keeps its value, as it did.
|
||||
if (aug.IntId is uint id)
|
||||
aug.CurrentValue = ReadCharacterInt(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshLuminanceAuras()
|
||||
{
|
||||
if (!InWorld)
|
||||
return;
|
||||
foreach (List<LuminanceAuraInfo> auras in LuminanceAuraCategories.Values)
|
||||
{
|
||||
foreach (LuminanceAuraInfo aura in auras)
|
||||
aura.CurrentValue = ReadCharacterInt(aura.IntId);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshRecallSpells()
|
||||
{
|
||||
if (!InWorld)
|
||||
return;
|
||||
foreach (RecallSpellInfo recall in RecallSpells)
|
||||
recall.IsKnown = _automation.Spells.IsKnown(recall.SpellId);
|
||||
}
|
||||
|
||||
public void RefreshCantrips()
|
||||
{
|
||||
if (!InWorld)
|
||||
return;
|
||||
|
||||
Cantrips["Specialized Skills"].Clear();
|
||||
Cantrips["Trained Skills"].Clear();
|
||||
PopulateCharacterSkills();
|
||||
|
||||
foreach (Dictionary<string, CantripInfo> category in Cantrips.Values)
|
||||
{
|
||||
foreach (CantripInfo cantrip in category.Values)
|
||||
{
|
||||
cantrip.Value = NotAvailable;
|
||||
cantrip.Color = Colors.White;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PluginActiveEnchantment enchantment in _automation.Character.ActiveEnchantments)
|
||||
{
|
||||
if (_automation.Spells.TryGet(enchantment.SpellId, out PluginSpellInfo spell)
|
||||
&& !string.IsNullOrEmpty(spell.Name))
|
||||
{
|
||||
DetectCantrip(spell.Name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Dictionary<string, CantripInfo> category in Cantrips.Values)
|
||||
{
|
||||
foreach (CantripInfo cantrip in category.Values)
|
||||
cantrip.ComputedIconId = cantrip.Value != NotAvailable ? Icons.GreenCircle : Icons.RedCircle;
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshWeapons()
|
||||
{
|
||||
if (!InWorld)
|
||||
return;
|
||||
|
||||
WeaponCategories = BuildWeapons();
|
||||
IReadOnlyList<PluginInventoryItem> inventory = _automation.Items.CaptureOwnedItems();
|
||||
foreach (List<TrackedWeaponInfo> weapons in WeaponCategories.Values)
|
||||
{
|
||||
foreach (TrackedWeaponInfo weapon in weapons)
|
||||
{
|
||||
// Matched by name either way round, as the original matched
|
||||
// it; an item with no name matches nothing.
|
||||
PluginInventoryItem? found = null;
|
||||
foreach (PluginInventoryItem item in inventory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(item.Name))
|
||||
continue;
|
||||
if (item.Name.Contains(weapon.Name, StringComparison.Ordinal)
|
||||
|| weapon.Name.Contains(item.Name, StringComparison.Ordinal))
|
||||
{
|
||||
found = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found is { } match)
|
||||
{
|
||||
weapon.IsAcquired = true;
|
||||
weapon.Status = "Acquired";
|
||||
weapon.IconId = match.IconId;
|
||||
}
|
||||
else
|
||||
{
|
||||
weapon.IsAcquired = false;
|
||||
weapon.Status = "Not Acquired";
|
||||
weapon.IconId = Icons.Portal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Files one active spell under its cantrip row when its name is a
|
||||
/// cantrip's: "Epic Flame Ward", "Major Strength", "Legendary War Magic
|
||||
/// Aptitude". A higher level replaces a lower one; anything else is left.
|
||||
/// </summary>
|
||||
internal void DetectCantrip(string spellName)
|
||||
{
|
||||
foreach ((string prefix, uint color) in CantripPatterns)
|
||||
{
|
||||
if (!spellName.StartsWith(prefix + " ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
string skillPart = spellName[(prefix.Length + 1)..];
|
||||
if (MatchProtectionAura(skillPart, prefix, color))
|
||||
return;
|
||||
if (MatchAttribute(skillPart, prefix, color))
|
||||
return;
|
||||
if (MatchSkill(skillPart, prefix, color))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private int ReadCharacterInt(uint property) =>
|
||||
_automation.Objects.TryGetIntProperty(_automation.Character.ObjectId, property, out int value)
|
||||
? value
|
||||
: 0;
|
||||
|
||||
private void PopulateCharacterSkills()
|
||||
{
|
||||
foreach ((uint skillId, string name) in SkillIdToName)
|
||||
{
|
||||
if (!_automation.Character.TryGetSkill(skillId, out PluginSkillInfo skill))
|
||||
continue;
|
||||
|
||||
string skillName = SkillCantripReplacements.TryGetValue(skillId, out string? replacement)
|
||||
? replacement
|
||||
: name;
|
||||
|
||||
if (skill.Training == PluginSkillTraining.Specialized)
|
||||
Cantrips["Specialized Skills"][skillName] = new CantripInfo { Name = skillName };
|
||||
else if (skill.Training == PluginSkillTraining.Trained)
|
||||
Cantrips["Trained Skills"][skillName] = new CantripInfo { Name = skillName };
|
||||
}
|
||||
}
|
||||
|
||||
private bool MatchProtectionAura(string skillPart, string level, uint color)
|
||||
{
|
||||
foreach ((string key, string aura) in ProtectionMappings)
|
||||
{
|
||||
if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
Raise(Cantrips["Protection Auras"], aura, level, color);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool MatchAttribute(string skillPart, string level, uint color)
|
||||
{
|
||||
string cleaned = skillPart.Trim();
|
||||
foreach ((string key, string attribute) in AttributeMappings)
|
||||
{
|
||||
if (!cleaned.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
Raise(Cantrips["Attributes"], attribute, level, color);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then the looser match the original fell back to: the name contains an attribute.
|
||||
foreach ((string key, string attribute) in AttributeMappings)
|
||||
{
|
||||
if (cleaned.IndexOf(key, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
Raise(Cantrips["Attributes"], attribute, level, color);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool MatchSkill(string skillPart, string level, uint color)
|
||||
{
|
||||
foreach ((string key, string skill) in SkillMappings)
|
||||
{
|
||||
if (!skillPart.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
foreach (string category in new[] { "Specialized Skills", "Trained Skills" })
|
||||
{
|
||||
if (!Cantrips[category].TryGetValue(skill, out CantripInfo? cantrip))
|
||||
continue;
|
||||
if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value))
|
||||
{
|
||||
cantrip.Value = level;
|
||||
cantrip.Color = color;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void Raise(Dictionary<string, CantripInfo> category, string name, string level, uint color)
|
||||
{
|
||||
if (!category.TryGetValue(name, out CantripInfo? cantrip))
|
||||
{
|
||||
cantrip = new CantripInfo { Name = name };
|
||||
category[name] = cantrip;
|
||||
}
|
||||
if (cantrip.Value == NotAvailable || IsHigherCantripLevel(level, cantrip.Value))
|
||||
{
|
||||
cantrip.Value = level;
|
||||
cantrip.Color = color;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHigherCantripLevel(string newLevel, string currentLevel) =>
|
||||
CantripRank.TryGetValue(newLevel, out int next)
|
||||
&& CantripRank.TryGetValue(currentLevel, out int current)
|
||||
&& next > current;
|
||||
|
||||
private static Dictionary<string, List<AugmentationInfo>> BuildAugmentations() => new()
|
||||
{
|
||||
["Death Augs"] =
|
||||
[
|
||||
new() { Name = "Keep Items", IntId = 231, Repeatable = 3, Trainer = "Rohula bint Ludun", Location = "Ayan Baqur" },
|
||||
new() { Name = "Keep Spells", IntId = 232, Repeatable = 1, Trainer = "Erik Festus", Location = "Ayan Baqur" },
|
||||
],
|
||||
["Skill Augs"] =
|
||||
[
|
||||
new() { Name = "+5 All Skills", IntId = 326, Repeatable = 1, Trainer = "Arianna the Adept", Location = "Bandit Castle" },
|
||||
new() { Name = "+10 Melee Skills", IntId = 300, Repeatable = 1, Trainer = "Carlito Gallo", Location = "Silyun" },
|
||||
new() { Name = "+10 Magic Skills", IntId = 302, Repeatable = 1, Trainer = "Rahina bint Zalanis", Location = "Zaikhal" },
|
||||
new() { Name = "+10 Missile Skills", IntId = 301, Repeatable = 1, Trainer = "Kilaf", Location = "Zaikhal" },
|
||||
],
|
||||
["Rating Augs"] =
|
||||
[
|
||||
new() { Name = "25% Crit Protection", IntId = 233, Repeatable = 1, Trainer = "Piersanti Linante", Location = "Sanamar" },
|
||||
new() { Name = "1% Critical Chance", IntId = 298, Repeatable = 1, Trainer = "Anfram Mellow", Location = "Ayan Baqur" },
|
||||
new() { Name = "3% Critical Damage", IntId = 299, Repeatable = 1, Trainer = "Alishia bint Aldan", Location = "Ayan Baqur" },
|
||||
new() { Name = "3% Damage Rating", IntId = 309, Repeatable = 1, Trainer = "Neela Nashua", Location = "Bandit Castle" },
|
||||
new() { Name = "3% Damage Reduction", IntId = 310, Repeatable = 1, Trainer = "Emily Yarow", Location = "Cragstone" },
|
||||
],
|
||||
["Burden / Pack Augs"] =
|
||||
[
|
||||
new() { Name = "Extra Carrying Capacity", IntId = 230, Repeatable = 5, Trainer = "Husoon", Location = "Zaikhal" },
|
||||
new() { Name = "Extra Pack Slot", IntId = 229, Repeatable = 1, Trainer = "Dumida bint Ruminre", Location = "Zaikhal" },
|
||||
new() { Name = "Infused War Magic", IntId = 297, Repeatable = 1, Trainer = "Raphel Detante", Location = "Silyun" },
|
||||
new() { Name = "Infused Void Magic", IntId = 328, Repeatable = 1, Trainer = "Morathe", Location = "Candeth Keep" },
|
||||
new() { Name = "Infused Creature Magic", IntId = 294, Repeatable = 1, Trainer = "Gustuv Lansdown", Location = "Cragstone" },
|
||||
new() { Name = "Infused Life Magic", IntId = 296, Repeatable = 1, Trainer = "Akemi Fei", Location = "Hebian-To" },
|
||||
new() { Name = "Infused Item Magic", IntId = 295, Repeatable = 1, Trainer = "Gan Fo", Location = "Hebian-To" },
|
||||
],
|
||||
["Misc Augs"] =
|
||||
[
|
||||
new() { Name = "10% Health Increase", IntId = null, Repeatable = 1, Trainer = "Donatello Linante", Location = "Silyun" },
|
||||
new() { Name = "Increased Spell Duration", IntId = 238, Repeatable = 5, Trainer = "Nawamara Ujio", Location = "Mayoi" },
|
||||
new() { Name = "Faster HP Regen", IntId = 237, Repeatable = 2, Trainer = "Alison Dulane", Location = "Bandit Castle" },
|
||||
new() { Name = "5% Experience Increase", IntId = 234, Repeatable = 1, Trainer = "Rickard Dumalia", Location = "Silyun" },
|
||||
],
|
||||
["Salvage Augs"] =
|
||||
[
|
||||
new() { Name = "Specialized Weapon Tinkering", IntId = 228, Repeatable = 1, Trainer = "Lenor Turk", Location = "Cragstone" },
|
||||
new() { Name = "Specialized Armor Tinkering", IntId = 226, Repeatable = 1, Trainer = "Joshun Felden", Location = "Cragstone" },
|
||||
new() { Name = "Specialized Item Tinkering", IntId = 225, Repeatable = 1, Trainer = "Brienne Carlus", Location = "Cragstone" },
|
||||
new() { Name = "Specialized Magic Item Tinkering", IntId = 227, Repeatable = 1, Trainer = "Burrell Sammrun", Location = "Cragstone" },
|
||||
new() { Name = "Specialized Salvaging", IntId = 224, Repeatable = 1, Trainer = "Robert Crow", Location = "Cragstone" },
|
||||
new() { Name = "25% More Salvage", IntId = 235, Repeatable = 4, Trainer = "Kris Cennis", Location = "Cragstone" },
|
||||
new() { Name = "5% Imbue Chance", IntId = 236, Repeatable = 1, Trainer = "Lug", Location = "Oolutanga's Refuge" },
|
||||
],
|
||||
["Stat Augs"] =
|
||||
[
|
||||
new() { Name = "All Stats", IntId = 217, Repeatable = 10, Trainer = "", Location = "" },
|
||||
new() { Name = "Strength", IntId = 218, Repeatable = 10, Trainer = "Fiun Luunere", Location = "Fiun Outpost" },
|
||||
new() { Name = "Endurance", IntId = 219, Repeatable = 10, Trainer = "Fiun Ruun", Location = "Fiun Outpost" },
|
||||
new() { Name = "Coordination", IntId = 220, Repeatable = 10, Trainer = "Fiun Bayaas", Location = "Fiun Outpost" },
|
||||
new() { Name = "Quickness", IntId = 221, Repeatable = 10, Trainer = "Fiun Riish", Location = "Fiun Outpost" },
|
||||
new() { Name = "Focus", IntId = 222, Repeatable = 10, Trainer = "Fiun Vasherr", Location = "Fiun Outpost" },
|
||||
new() { Name = "Self", IntId = 223, Repeatable = 10, Trainer = "Fiun Noress", Location = "Fiun Outpost" },
|
||||
],
|
||||
["Resistance Augs"] =
|
||||
[
|
||||
new() { Name = "All Resistances", IntId = 239, Repeatable = 2, Trainer = "", Location = "" },
|
||||
new() { Name = "Blunt", IntId = 242, Repeatable = 2, Trainer = "Nawamara Dia", Location = "Hebian-To" },
|
||||
new() { Name = "Pierce", IntId = 241, Repeatable = 2, Trainer = "Kyujo Rujen", Location = "Hebian-To" },
|
||||
new() { Name = "Slashing", IntId = 240, Repeatable = 2, Trainer = "Ilin Wis", Location = "Hebian-To" },
|
||||
new() { Name = "Fire", IntId = 244, Repeatable = 2, Trainer = "Rikshen Ri", Location = "Hebian-To" },
|
||||
new() { Name = "Frost", IntId = 245, Repeatable = 2, Trainer = "Lu Bao", Location = "Hebian-To" },
|
||||
new() { Name = "Acid", IntId = 243, Repeatable = 2, Trainer = "Shujio Milao", Location = "Hebian-To" },
|
||||
new() { Name = "Lightning", IntId = 246, Repeatable = 2, Trainer = "Enli Yuo", Location = "Hebian-To" },
|
||||
],
|
||||
};
|
||||
|
||||
private static Dictionary<string, List<LuminanceAuraInfo>> BuildLuminanceAuras() => new()
|
||||
{
|
||||
["Nalicana Auras"] =
|
||||
[
|
||||
new() { Name = "+1 Aetheria Proc Rating", IntId = 338, Cap = 5 },
|
||||
new() { Name = "+1 Damage Reduction Rating", IntId = 334, Cap = 5 },
|
||||
new() { Name = "+1 Crit Reduction Rating", IntId = 336, Cap = 5 },
|
||||
new() { Name = "+1 Damage Rating", IntId = 333, Cap = 5 },
|
||||
new() { Name = "+1 Crit Damage Rating", IntId = 335, Cap = 5 },
|
||||
new() { Name = "+1 Heal Rating", IntId = 342, Cap = 5 },
|
||||
new() { Name = "+1 Equipment Mana Rating", IntId = 339, Cap = 5 },
|
||||
new() { Name = "+1 Mana Stone Rating", IntId = 340, Cap = 5 },
|
||||
new() { Name = "+1 Crafting Skills", IntId = 343, Cap = 5 },
|
||||
new() { Name = "+1 All Skills", IntId = 365, Cap = 10 },
|
||||
],
|
||||
["Seer Auras"] =
|
||||
[
|
||||
new() { Name = "(Ka'hiri) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToKahiri" },
|
||||
new() { Name = "(Ka'hiri) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToKahiri" },
|
||||
new() { Name = "(Shade of Lady Adja) +2 Specialized Skills", IntId = 344, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" },
|
||||
new() { Name = "(Shade of Lady Adja) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToShadeOfLadyAdja" },
|
||||
new() { Name = "(Liam of Gelid) +1 Damage Rating", IntId = 333, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" },
|
||||
new() { Name = "(Liam of Gelid) +1 Crit Damage Rating", IntId = 335, Cap = 5, QuestFlag = "LoyalToLiamOfGelid" },
|
||||
new() { Name = "(Lord Tyragar) +1 Crit Reduction Rating", IntId = 336, Cap = 5, QuestFlag = "LoyalToLordTyragar" },
|
||||
new() { Name = "(Lord Tyragar) +1 Damage Reduction Rating", IntId = 334, Cap = 5, QuestFlag = "LoyalToLordTyragar" },
|
||||
],
|
||||
};
|
||||
|
||||
private static List<RecallSpellInfo> BuildRecalls() =>
|
||||
[
|
||||
new() { Name = "Recall the Sanctuary", SpellId = 2023, Category = "Basic Recalls" },
|
||||
new() { Name = "Aerlinthe Recall", SpellId = 2041, Category = "Island Recalls" },
|
||||
new() { Name = "Mount Lethe Recall", SpellId = 2813, Category = "Island Recalls" },
|
||||
new() { Name = "Recall Aphus Lassel", SpellId = 2931, Category = "Island Recalls" },
|
||||
new() { Name = "Ulgrim's Recall", SpellId = 2941, Category = "Special Recalls" },
|
||||
new() { Name = "Recall to the Singularity Caul", SpellId = 2943, Category = "Island Recalls" },
|
||||
new() { Name = "Glenden Wood Recall", SpellId = 3865, Category = "Town Recalls" },
|
||||
new() { Name = "Bur Recall", SpellId = 4084, Category = "Town Recalls" },
|
||||
new() { Name = "Call of the Mhoire Forge", SpellId = 4128, Category = "Special Recalls" },
|
||||
new() { Name = "Paradox-touched Olthoi Infested Area Recall", SpellId = 4198, Category = "Special Recalls" },
|
||||
new() { Name = "Colosseum Recall", SpellId = 4213, Category = "Special Recalls" },
|
||||
new() { Name = "Return to the Keep", SpellId = 4214, Category = "Special Recalls" },
|
||||
new() { Name = "Gear Knight Invasion Area Camp Recall", SpellId = 5330, Category = "Special Recalls" },
|
||||
new() { Name = "Lost City of Neftet Recall", SpellId = 5541, Category = "Special Recalls" },
|
||||
new() { Name = "Rynthid Recall", SpellId = 6150, Category = "Special Recalls" },
|
||||
new() { Name = "Viridian Rise Recall", SpellId = 6321, Category = "Special Recalls" },
|
||||
new() { Name = "Viridian Rise Great Tree Recall", SpellId = 6322, Category = "Special Recalls" },
|
||||
];
|
||||
|
||||
private static Dictionary<string, Dictionary<string, CantripInfo>> BuildCantrips() => new()
|
||||
{
|
||||
["Specialized Skills"] = [],
|
||||
["Trained Skills"] = [],
|
||||
["Attributes"] = [],
|
||||
["Protection Auras"] = new()
|
||||
{
|
||||
// Listed up front so a missing ward shows as missing.
|
||||
["Armor"] = new() { Name = "Armor" },
|
||||
["Bludgeoning Ward"] = new() { Name = "Bludgeoning Ward" },
|
||||
["Piercing Ward"] = new() { Name = "Piercing Ward" },
|
||||
["Slashing Ward"] = new() { Name = "Slashing Ward" },
|
||||
["Flame Ward"] = new() { Name = "Flame Ward" },
|
||||
["Frost Ward"] = new() { Name = "Frost Ward" },
|
||||
["Acid Ward"] = new() { Name = "Acid Ward" },
|
||||
["Storm Ward"] = new() { Name = "Storm Ward" },
|
||||
},
|
||||
};
|
||||
|
||||
private static Dictionary<string, List<TrackedWeaponInfo>> BuildWeapons() => new()
|
||||
{
|
||||
["Legendary Weapons"] =
|
||||
[
|
||||
new() { Name = "Bow of the Quiddity", Category = "Legendary Weapons", WeaponType = "Bow", ItemId = 23044 },
|
||||
new() { Name = "Sword of the Quiddity", Category = "Legendary Weapons", WeaponType = "Sword", ItemId = 23039 },
|
||||
new() { Name = "Mace of the Quiddity", Category = "Legendary Weapons", WeaponType = "Mace", ItemId = 23040 },
|
||||
new() { Name = "Atlatl of the Quiddity", Category = "Legendary Weapons", WeaponType = "Atlatl", ItemId = 23043 },
|
||||
new() { Name = "Staff of the Quiddity", Category = "Legendary Weapons", WeaponType = "Staff", ItemId = 23041 },
|
||||
new() { Name = "Dagger of the Quiddity", Category = "Legendary Weapons", WeaponType = "Dagger", ItemId = 23042 },
|
||||
],
|
||||
["Slayer Weapons"] =
|
||||
[
|
||||
new() { Name = "Shadowfire Isparian Bow", Category = "Slayer Weapons", WeaponType = "Bow", ItemId = 20636 },
|
||||
new() { Name = "Shadowfire Isparian Sword", Category = "Slayer Weapons", WeaponType = "Sword", ItemId = 20631 },
|
||||
new() { Name = "Shadowfire Isparian Staff", Category = "Slayer Weapons", WeaponType = "Staff", ItemId = 20633 },
|
||||
new() { Name = "Shadowfire Isparian Dagger", Category = "Slayer Weapons", WeaponType = "Dagger", ItemId = 20634 },
|
||||
new() { Name = "Shadowfire Isparian Axe", Category = "Slayer Weapons", WeaponType = "Axe", ItemId = 20635 },
|
||||
new() { Name = "Shadowfire Isparian Mace", Category = "Slayer Weapons", WeaponType = "Mace", ItemId = 20632 },
|
||||
],
|
||||
["Society Weapons"] =
|
||||
[
|
||||
new() { Name = "Radiant Blood Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32834 },
|
||||
new() { Name = "Celestial Hand Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32835 },
|
||||
new() { Name = "Eldritch Web Sword", Category = "Society Weapons", WeaponType = "Sword", ItemId = 32836 },
|
||||
new() { Name = "Radiant Blood Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32837 },
|
||||
new() { Name = "Celestial Hand Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32838 },
|
||||
new() { Name = "Eldritch Web Bow", Category = "Society Weapons", WeaponType = "Bow", ItemId = 32839 },
|
||||
],
|
||||
["Atlan Weapons"] =
|
||||
[
|
||||
new() { Name = "Atlan Sword", Category = "Atlan Weapons", WeaponType = "Sword", ItemId = 11648 },
|
||||
new() { Name = "Atlan Axe", Category = "Atlan Weapons", WeaponType = "Axe", ItemId = 11649 },
|
||||
new() { Name = "Atlan Mace", Category = "Atlan Weapons", WeaponType = "Mace", ItemId = 11650 },
|
||||
new() { Name = "Atlan Spear", Category = "Atlan Weapons", WeaponType = "Spear", ItemId = 11651 },
|
||||
new() { Name = "Atlan Staff", Category = "Atlan Weapons", WeaponType = "Staff", ItemId = 11652 },
|
||||
new() { Name = "Atlan Dagger", Category = "Atlan Weapons", WeaponType = "Dagger", ItemId = 11653 },
|
||||
],
|
||||
};
|
||||
}
|
||||
159
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
Normal file
159
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.Quests;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.FlagTracker;
|
||||
|
||||
/// <summary>
|
||||
/// The "Mossy Tracker" window: augmentations, luminance auras, recalls,
|
||||
/// cantrips, tracked weapons and quest timers for the character in the world.
|
||||
/// Nothing here reaches the backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window exists only on a host that draws one; on a headless host this
|
||||
/// feature registers nothing and does nothing. The pages are read at login
|
||||
/// and whenever the player presses a page's Refresh button, as the original
|
||||
/// read them when its window opened and on Refresh; the quest page follows
|
||||
/// the quest list and redraws its countdowns every five seconds.
|
||||
/// </remarks>
|
||||
internal sealed class FlagTrackerFeature : IMmFeature
|
||||
{
|
||||
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
|
||||
public const string MarkupFile = "mosswartmassacre-flagtracker.xml";
|
||||
|
||||
/// <summary>How often the quest countdowns are redrawn.</summary>
|
||||
public static readonly TimeSpan QuestRedrawInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly MmContext _context;
|
||||
private readonly QuestManager _quests;
|
||||
private readonly FlagTrackerData _data;
|
||||
private readonly IDisposable? _panel;
|
||||
private readonly Action? _onQuestsChanged;
|
||||
private IDisposable? _questRedraw;
|
||||
|
||||
public FlagTrackerFeature(MmContext context, QuestManager quests)
|
||||
{
|
||||
_context = context;
|
||||
_quests = quests;
|
||||
_data = new FlagTrackerData(context.Automation);
|
||||
if (!context.Host.HasUi)
|
||||
return;
|
||||
|
||||
Panel = new FlagTrackerViewModel();
|
||||
Panel.RefreshRequested += OnRefreshRequested;
|
||||
_onQuestsChanged = ShowQuests;
|
||||
_quests.QuestsChanged += _onQuestsChanged;
|
||||
|
||||
string directory = Path.GetDirectoryName(typeof(FlagTrackerFeature).Assembly.Location) ?? ".";
|
||||
_panel = context.Host.Ui.RegisterPanel(
|
||||
new PluginPanelDescriptor("flagtracker", "Mossy Tracker")
|
||||
{
|
||||
IconText = "MT",
|
||||
// The icon the original window carried (7735).
|
||||
IconSurfaceId = 0x06001E37,
|
||||
// Shown while the binding's IsVisible is set; the host's own
|
||||
// shown state starts true so that flag alone opens it.
|
||||
StartVisible = true,
|
||||
ShowInSidePanel = true,
|
||||
},
|
||||
Path.Combine(directory, MarkupFile),
|
||||
Panel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The window's binding object; set its <c>IsVisible</c> to open the
|
||||
/// window. Null on a host without a window.
|
||||
/// </summary>
|
||||
public FlagTrackerViewModel? Panel { get; }
|
||||
|
||||
internal FlagTrackerData Data => _data;
|
||||
|
||||
public void OnLogin()
|
||||
{
|
||||
if (Panel is null)
|
||||
return;
|
||||
RefreshAll();
|
||||
_questRedraw?.Dispose();
|
||||
_questRedraw = _context.Scheduler.Every(QuestRedrawInterval, ShowQuests);
|
||||
}
|
||||
|
||||
public void OnLogoff()
|
||||
{
|
||||
_questRedraw?.Dispose();
|
||||
_questRedraw = null;
|
||||
Panel?.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
OnLogoff();
|
||||
if (_onQuestsChanged is not null)
|
||||
_quests.QuestsChanged -= _onQuestsChanged;
|
||||
if (Panel is not null)
|
||||
Panel.RefreshRequested -= OnRefreshRequested;
|
||||
_panel?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Reads every page again and redraws it.</summary>
|
||||
public void RefreshAll()
|
||||
{
|
||||
if (Panel is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_data.RefreshAll();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Chat.Write($"[MossyTracker] Data refresh failed: {ex.Message}");
|
||||
}
|
||||
Panel.ShowData(_data);
|
||||
ShowQuests();
|
||||
}
|
||||
|
||||
private void ShowQuests()
|
||||
{
|
||||
if (Panel is null || !_context.IsLoggedIn)
|
||||
return;
|
||||
Panel.ShowQuestRows(_quests.QuestList, _quests.UnixNow);
|
||||
}
|
||||
|
||||
private void OnRefreshRequested(string button)
|
||||
{
|
||||
if (Panel is null || !_context.IsLoggedIn)
|
||||
return;
|
||||
try
|
||||
{
|
||||
switch (button)
|
||||
{
|
||||
case nameof(FlagTrackerViewModel.RefreshAugs):
|
||||
_data.RefreshAugmentations();
|
||||
Panel.ShowAugmentations(_data);
|
||||
break;
|
||||
case nameof(FlagTrackerViewModel.RefreshLum):
|
||||
_data.RefreshLuminanceAuras();
|
||||
Panel.ShowLuminance(_data);
|
||||
break;
|
||||
case nameof(FlagTrackerViewModel.RefreshRecalls):
|
||||
_data.RefreshRecallSpells();
|
||||
Panel.ShowRecallSpells(_data);
|
||||
break;
|
||||
case nameof(FlagTrackerViewModel.RefreshCantrips):
|
||||
_data.RefreshCantrips();
|
||||
Panel.ShowCantripRows(_data);
|
||||
break;
|
||||
case nameof(FlagTrackerViewModel.RefreshWeapons):
|
||||
_data.RefreshWeapons();
|
||||
Panel.ShowWeaponRows(_data);
|
||||
break;
|
||||
case nameof(FlagTrackerViewModel.RefreshQuests):
|
||||
_quests.RefreshQuests();
|
||||
ShowQuests();
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Chat.Write($"[MossyTracker] Refresh failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
319
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs
Normal file
319
src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerViewModel.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
using System.Globalization;
|
||||
using OpenAC.MosswartMassacre.Quests;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.FlagTracker;
|
||||
|
||||
/// <summary>
|
||||
/// The binding object of the "Mossy Tracker" window
|
||||
/// (<c>mosswartmassacre-flagtracker.xml</c>). One tab per page of the
|
||||
/// original window, and each page's list as parallel column lists that are
|
||||
/// replaced whole on every refresh, so the host re-reading them every frame
|
||||
/// never sees a half-built page. Everything is assigned on the tick thread.
|
||||
/// </summary>
|
||||
internal sealed class FlagTrackerViewModel
|
||||
{
|
||||
private enum Page
|
||||
{
|
||||
Augs,
|
||||
Lum,
|
||||
Recalls,
|
||||
Cantrips,
|
||||
Weapons,
|
||||
Quests,
|
||||
}
|
||||
|
||||
private Page _page = Page.Augs;
|
||||
|
||||
public FlagTrackerViewModel()
|
||||
{
|
||||
IgnoreRow = _ => { };
|
||||
ShowAugs = () => _page = Page.Augs;
|
||||
ShowLum = () => _page = Page.Lum;
|
||||
ShowRecalls = () => _page = Page.Recalls;
|
||||
ShowCantrips = () => _page = Page.Cantrips;
|
||||
ShowWeapons = () => _page = Page.Weapons;
|
||||
ShowQuests = () => _page = Page.Quests;
|
||||
RefreshAugs = () => RefreshRequested?.Invoke(nameof(RefreshAugs));
|
||||
RefreshLum = () => RefreshRequested?.Invoke(nameof(RefreshLum));
|
||||
RefreshRecalls = () => RefreshRequested?.Invoke(nameof(RefreshRecalls));
|
||||
RefreshCantrips = () => RefreshRequested?.Invoke(nameof(RefreshCantrips));
|
||||
RefreshWeapons = () => RefreshRequested?.Invoke(nameof(RefreshWeapons));
|
||||
RefreshQuests = () => RefreshRequested?.Invoke(nameof(RefreshQuests));
|
||||
}
|
||||
|
||||
/// <summary>A refresh button was pressed; the argument is the button's binding name.</summary>
|
||||
internal event Action<string>? RefreshRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the window is open. The root panel's visibility is bound to it,
|
||||
/// so the main window opens this one by setting it; it starts closed.
|
||||
/// </summary>
|
||||
public bool IsVisible { get; set; }
|
||||
|
||||
// ---- tabs --------------------------------------------------------------
|
||||
|
||||
public bool AugsSelected => _page == Page.Augs;
|
||||
public bool LumSelected => _page == Page.Lum;
|
||||
public bool RecallsSelected => _page == Page.Recalls;
|
||||
public bool CantripsSelected => _page == Page.Cantrips;
|
||||
public bool WeaponsSelected => _page == Page.Weapons;
|
||||
public bool QuestsSelected => _page == Page.Quests;
|
||||
|
||||
public Action ShowAugs { get; }
|
||||
public Action ShowLum { get; }
|
||||
public Action ShowRecalls { get; }
|
||||
public Action ShowCantrips { get; }
|
||||
public Action ShowWeapons { get; }
|
||||
public Action ShowQuests { get; }
|
||||
|
||||
public Action RefreshAugs { get; }
|
||||
public Action RefreshLum { get; }
|
||||
public Action RefreshRecalls { get; }
|
||||
public Action RefreshCantrips { get; }
|
||||
public Action RefreshWeapons { get; }
|
||||
public Action RefreshQuests { get; }
|
||||
|
||||
/// <summary>Lists here are read-only views; a click selects nothing.</summary>
|
||||
public int NoRow => -1;
|
||||
|
||||
public Action<int> IgnoreRow { get; }
|
||||
|
||||
// ---- Augs --------------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<string> AugNames { get; private set; } = [];
|
||||
public IReadOnlyList<string> AugProgress { get; private set; } = [];
|
||||
public IReadOnlyList<uint> AugProgressColors { get; private set; } = [];
|
||||
public IReadOnlyList<string> AugTrainers { get; private set; } = [];
|
||||
public IReadOnlyList<string> AugLocations { get; private set; } = [];
|
||||
|
||||
// ---- Lum ---------------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<string> LumNames { get; private set; } = [];
|
||||
public IReadOnlyList<string> LumProgress { get; private set; } = [];
|
||||
public IReadOnlyList<uint> LumProgressColors { get; private set; } = [];
|
||||
public IReadOnlyList<string> LumCategories { get; private set; } = [];
|
||||
|
||||
// ---- Recalls -----------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<uint> RecallSpellIds { get; private set; } = [];
|
||||
public IReadOnlyList<string> RecallNames { get; private set; } = [];
|
||||
public IReadOnlyList<string> RecallStatus { get; private set; } = [];
|
||||
public IReadOnlyList<uint> RecallStatusColors { get; private set; } = [];
|
||||
|
||||
// ---- Cantrips ----------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<uint> CantripIcons { get; private set; } = [];
|
||||
public IReadOnlyList<string> CantripNames { get; private set; } = [];
|
||||
public IReadOnlyList<string> CantripLevels { get; private set; } = [];
|
||||
public IReadOnlyList<uint> CantripLevelColors { get; private set; } = [];
|
||||
|
||||
// ---- Weapons -----------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<uint> WeaponIcons { get; private set; } = [];
|
||||
public IReadOnlyList<string> WeaponNames { get; private set; } = [];
|
||||
public IReadOnlyList<string> WeaponStatus { get; private set; } = [];
|
||||
public IReadOnlyList<uint> WeaponStatusColors { get; private set; } = [];
|
||||
|
||||
// ---- Quests ------------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<string> QuestRowNames { get; private set; } = [];
|
||||
public IReadOnlyList<string> QuestCountdowns { get; private set; } = [];
|
||||
public IReadOnlyList<uint> QuestCountdownColors { get; private set; } = [];
|
||||
public IReadOnlyList<string> QuestLastSolved { get; private set; } = [];
|
||||
public IReadOnlyList<string> QuestCooldowns { get; private set; } = [];
|
||||
public IReadOnlyList<string> QuestSolves { get; private set; } = [];
|
||||
public IReadOnlyList<uint> QuestSolvesColors { get; private set; } = [];
|
||||
|
||||
/// <summary>Rebuilds every page but the quests from the tracker's data.</summary>
|
||||
public void ShowData(FlagTrackerData data)
|
||||
{
|
||||
ShowAugmentations(data);
|
||||
ShowLuminance(data);
|
||||
ShowRecallSpells(data);
|
||||
ShowCantripRows(data);
|
||||
ShowWeaponRows(data);
|
||||
}
|
||||
|
||||
public void ShowAugmentations(FlagTrackerData data)
|
||||
{
|
||||
var names = new List<string>();
|
||||
var progress = new List<string>();
|
||||
var colors = new List<uint>();
|
||||
var trainers = new List<string>();
|
||||
var locations = new List<string>();
|
||||
foreach ((string category, List<FlagTrackerData.AugmentationInfo> augmentations) in data.AugmentationCategories)
|
||||
{
|
||||
names.Add($"--- {category} ---");
|
||||
progress.Add(string.Empty);
|
||||
colors.Add(FlagTrackerData.Colors.White);
|
||||
trainers.Add(string.Empty);
|
||||
locations.Add(string.Empty);
|
||||
foreach (FlagTrackerData.AugmentationInfo aug in augmentations)
|
||||
{
|
||||
names.Add(aug.Name);
|
||||
progress.Add(aug.IsMaxed
|
||||
? "[MAX]"
|
||||
: string.Create(CultureInfo.InvariantCulture, $"[{aug.CurrentValue}/{aug.Repeatable}]"));
|
||||
colors.Add(aug.IsMaxed
|
||||
? FlagTrackerData.Colors.Green
|
||||
: aug.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red);
|
||||
trainers.Add(aug.Trainer);
|
||||
locations.Add(aug.Location);
|
||||
}
|
||||
}
|
||||
AugNames = names;
|
||||
AugProgress = progress;
|
||||
AugProgressColors = colors;
|
||||
AugTrainers = trainers;
|
||||
AugLocations = locations;
|
||||
}
|
||||
|
||||
public void ShowLuminance(FlagTrackerData data)
|
||||
{
|
||||
var names = new List<string>();
|
||||
var progress = new List<string>();
|
||||
var colors = new List<uint>();
|
||||
var categories = new List<string>();
|
||||
foreach ((string category, List<FlagTrackerData.LuminanceAuraInfo> auras) in data.LuminanceAuraCategories)
|
||||
{
|
||||
names.Add($"--- {category} ---");
|
||||
progress.Add(string.Empty);
|
||||
colors.Add(FlagTrackerData.Colors.White);
|
||||
categories.Add(string.Empty);
|
||||
foreach (FlagTrackerData.LuminanceAuraInfo aura in auras)
|
||||
{
|
||||
names.Add(aura.Name);
|
||||
progress.Add(string.Create(CultureInfo.InvariantCulture, $"{aura.CurrentValue}/{aura.Cap}"));
|
||||
colors.Add(aura.CurrentValue >= aura.Cap
|
||||
? FlagTrackerData.Colors.Green
|
||||
: aura.CurrentValue > 0 ? FlagTrackerData.Colors.Yellow : FlagTrackerData.Colors.Red);
|
||||
categories.Add(category == "Seer Auras" && !string.IsNullOrEmpty(aura.QuestFlag)
|
||||
? aura.QuestFlag
|
||||
: category);
|
||||
}
|
||||
}
|
||||
LumNames = names;
|
||||
LumProgress = progress;
|
||||
LumProgressColors = colors;
|
||||
LumCategories = categories;
|
||||
}
|
||||
|
||||
public void ShowRecallSpells(FlagTrackerData data)
|
||||
{
|
||||
RecallSpellIds = [.. data.RecallSpells.Select(recall => recall.SpellId)];
|
||||
RecallNames = [.. data.RecallSpells.Select(recall => recall.Name)];
|
||||
RecallStatus = [.. data.RecallSpells.Select(recall => recall.IsKnown ? "Known" : "Unknown")];
|
||||
RecallStatusColors = [.. data.RecallSpells.Select(recall =>
|
||||
recall.IsKnown ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red)];
|
||||
}
|
||||
|
||||
public void ShowCantripRows(FlagTrackerData data)
|
||||
{
|
||||
var icons = new List<uint>();
|
||||
var names = new List<string>();
|
||||
var levels = new List<string>();
|
||||
var colors = new List<uint>();
|
||||
foreach ((string category, Dictionary<string, FlagTrackerData.CantripInfo> cantrips) in data.Cantrips)
|
||||
{
|
||||
icons.Add(FlagTrackerData.Icons.CategoryStar);
|
||||
names.Add($"--- {category} ---");
|
||||
levels.Add(string.Empty);
|
||||
colors.Add(FlagTrackerData.Colors.White);
|
||||
foreach ((string name, FlagTrackerData.CantripInfo cantrip) in cantrips)
|
||||
{
|
||||
icons.Add(cantrip.ComputedIconId);
|
||||
names.Add(name);
|
||||
levels.Add(cantrip.Value);
|
||||
colors.Add(cantrip.Color);
|
||||
}
|
||||
}
|
||||
CantripIcons = icons;
|
||||
CantripNames = names;
|
||||
CantripLevels = levels;
|
||||
CantripLevelColors = colors;
|
||||
}
|
||||
|
||||
public void ShowWeaponRows(FlagTrackerData data)
|
||||
{
|
||||
var icons = new List<uint>();
|
||||
var names = new List<string>();
|
||||
var status = new List<string>();
|
||||
var colors = new List<uint>();
|
||||
foreach ((string category, List<FlagTrackerData.TrackedWeaponInfo> weapons) in data.WeaponCategories)
|
||||
{
|
||||
icons.Add(FlagTrackerData.Icons.CategoryStar);
|
||||
names.Add($"--- {category} ---");
|
||||
status.Add(string.Empty);
|
||||
colors.Add(FlagTrackerData.Colors.White);
|
||||
foreach (FlagTrackerData.TrackedWeaponInfo weapon in weapons)
|
||||
{
|
||||
icons.Add(weapon.IconId);
|
||||
names.Add(weapon.Name);
|
||||
status.Add(weapon.Status);
|
||||
colors.Add(weapon.IsAcquired ? FlagTrackerData.Colors.Green : FlagTrackerData.Colors.Red);
|
||||
}
|
||||
}
|
||||
WeaponIcons = icons;
|
||||
WeaponNames = names;
|
||||
WeaponStatus = status;
|
||||
WeaponStatusColors = colors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The quest page: every quest not yet solved its maximum number of times,
|
||||
/// most solves first, then by name; the countdown green when ready and
|
||||
/// yellow while cooling down.
|
||||
/// </summary>
|
||||
public void ShowQuestRows(IReadOnlyList<Quest> quests, long unixNow)
|
||||
{
|
||||
var names = new List<string>();
|
||||
var countdowns = new List<string>();
|
||||
var countdownColors = new List<uint>();
|
||||
var lastSolved = new List<string>();
|
||||
var cooldowns = new List<string>();
|
||||
var solves = new List<string>();
|
||||
var solvesColors = new List<uint>();
|
||||
|
||||
IEnumerable<Quest> visible = quests
|
||||
.Where(quest => !(quest.MaxSolves > 0 && quest.Solves >= quest.MaxSolves))
|
||||
.OrderByDescending(quest => quest.Solves)
|
||||
.ThenBy(quest => QuestNames.GetFriendlyName(quest.Id), StringComparer.InvariantCulture);
|
||||
|
||||
foreach (Quest quest in visible)
|
||||
{
|
||||
names.Add(QuestNames.GetFriendlyName(quest.Id));
|
||||
countdowns.Add(QuestStreamingService.FormatCountdown(quest.ExpireTime - unixNow));
|
||||
countdownColors.Add(quest.ExpireTime <= unixNow
|
||||
? FlagTrackerData.Colors.Green
|
||||
: FlagTrackerData.Colors.Yellow);
|
||||
lastSolved.Add(QuestManager.FormatTimeStamp(quest.Timestamp));
|
||||
cooldowns.Add(QuestManager.FormatSeconds(quest.Delta));
|
||||
solves.Add(quest.Solves.ToString(CultureInfo.InvariantCulture));
|
||||
solvesColors.Add(FlagTrackerData.Colors.White);
|
||||
}
|
||||
|
||||
QuestRowNames = names;
|
||||
QuestCountdowns = countdowns;
|
||||
QuestCountdownColors = countdownColors;
|
||||
QuestLastSolved = lastSolved;
|
||||
QuestCooldowns = cooldowns;
|
||||
QuestSolves = solves;
|
||||
QuestSolvesColors = solvesColors;
|
||||
}
|
||||
|
||||
/// <summary>Empties every page, at logoff.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
AugNames = AugProgress = AugTrainers = AugLocations = [];
|
||||
AugProgressColors = [];
|
||||
LumNames = LumProgress = LumCategories = [];
|
||||
LumProgressColors = [];
|
||||
RecallSpellIds = RecallStatusColors = [];
|
||||
RecallNames = RecallStatus = [];
|
||||
CantripIcons = CantripLevelColors = [];
|
||||
CantripNames = CantripLevels = [];
|
||||
WeaponIcons = WeaponStatusColors = [];
|
||||
WeaponNames = WeaponStatus = [];
|
||||
ShowQuestRows([], 0);
|
||||
}
|
||||
}
|
||||
337
src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
Normal file
337
src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
470
src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
Normal file
470
src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
Normal file
172
src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
Normal 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))];
|
||||
}
|
||||
}
|
||||
325
src/OpenAC.MosswartMassacre/Quests/QuestManager.cs
Normal file
325
src/OpenAC.MosswartMassacre/Quests/QuestManager.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
211
src/OpenAC.MosswartMassacre/Quests/QuestNames.cs
Normal file
211
src/OpenAC.MosswartMassacre/Quests/QuestNames.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs
Normal file
70
src/OpenAC.MosswartMassacre/Quests/QuestStreamingService.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
119
src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml
Normal file
119
src/OpenAC.MosswartMassacre/mosswartmassacre-flagtracker.xml
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The "Mossy Tracker" window. One tab per page of the original window, plus
|
||||
the tracked weapons it kept data for; each page is a Refresh button over a
|
||||
read-only list whose header row is the labels above it. -->
|
||||
<panel x="60" y="60" w="620" h="440" title="Mossy Tracker"
|
||||
visible="{IsVisible}" resizable="true" minw="620" minh="440">
|
||||
|
||||
<tab x="6" y="22" w="50" h="18" text="Augs"
|
||||
selected="{AugsSelected}" onclick="{ShowAugs}" />
|
||||
<tab x="56" y="22" w="44" h="18" text="Lum"
|
||||
selected="{LumSelected}" onclick="{ShowLum}" />
|
||||
<tab x="100" y="22" w="60" h="18" text="Recalls"
|
||||
selected="{RecallsSelected}" onclick="{ShowRecalls}" />
|
||||
<tab x="160" y="22" w="64" h="18" text="Cantrips"
|
||||
selected="{CantripsSelected}" onclick="{ShowCantrips}" />
|
||||
<tab x="224" y="22" w="66" h="18" text="Weapons"
|
||||
selected="{WeaponsSelected}" onclick="{ShowWeapons}" />
|
||||
<tab x="290" y="22" w="58" h="18" text="Quests"
|
||||
selected="{QuestsSelected}" onclick="{ShowQuests}" />
|
||||
|
||||
<!-- ==================== Augmentations ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{AugsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshAugs}" />
|
||||
<label x="2" y="26" text="Augmentation" />
|
||||
<label x="202" y="26" text="Progress" />
|
||||
<label x="282" y="26" text="Trainer" />
|
||||
<label x="442" y="26" text="Location" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="16"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="text" width="200" items="{AugNames}" />
|
||||
<column type="text" width="80" items="{AugProgress}" colors="{AugProgressColors}" />
|
||||
<column type="text" width="160" items="{AugTrainers}" />
|
||||
<column type="text" width="*" items="{AugLocations}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Luminance auras ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{LumSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshLum}" />
|
||||
<label x="2" y="26" text="Luminance Aura" />
|
||||
<label x="302" y="26" text="Progress" />
|
||||
<label x="402" y="26" text="Category" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="16"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="text" width="300" items="{LumNames}" />
|
||||
<column type="text" width="100" items="{LumProgress}" colors="{LumProgressColors}" />
|
||||
<column type="text" width="*" items="{LumCategories}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Recall spells ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{RecallsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshRecalls}" />
|
||||
<label x="22" y="26" text="Recall Spell" />
|
||||
<label x="422" y="26" text="Status" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="18"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="icon" width="20" iconkind="spell" values="{RecallSpellIds}" onclick="{IgnoreRow}" />
|
||||
<column type="text" width="400" items="{RecallNames}" />
|
||||
<column type="text" width="*" items="{RecallStatus}" colors="{RecallStatusColors}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Cantrips ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{CantripsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshCantrips}" />
|
||||
<label x="22" y="26" text="Skill/Effect" />
|
||||
<label x="422" y="26" text="Cantrip Level" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="18"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="icon" width="20" iconkind="did" values="{CantripIcons}" onclick="{IgnoreRow}" />
|
||||
<column type="text" width="400" items="{CantripNames}" />
|
||||
<column type="text" width="*" items="{CantripLevels}" colors="{CantripLevelColors}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Weapons ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{WeaponsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="90" h="20" text="Refresh" onclick="{RefreshWeapons}" />
|
||||
<label x="22" y="26" text="Weapon" />
|
||||
<label x="322" y="26" text="Status" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="18"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="icon" width="20" iconkind="did" values="{WeaponIcons}" onclick="{IgnoreRow}" />
|
||||
<column type="text" width="300" items="{WeaponNames}" />
|
||||
<column type="text" width="*" items="{WeaponStatus}" colors="{WeaponStatusColors}" />
|
||||
</list>
|
||||
</group>
|
||||
|
||||
<!-- ==================== Quests ==================== -->
|
||||
<group x="4" y="44" w="612" h="392" visible="{QuestsSelected}"
|
||||
anchor="left top right bottom">
|
||||
<button x="0" y="0" w="110" h="20" text="Refresh Quests" onclick="{RefreshQuests}" />
|
||||
<label x="2" y="26" text="Quest Name" />
|
||||
<label x="232" y="26" text="Countdown" />
|
||||
<label x="322" y="26" text="Last Solved" />
|
||||
<label x="452" y="26" text="Cooldown" />
|
||||
<label x="552" y="26" text="Solves" />
|
||||
<list x="0" y="42" w="612" h="348" rowheight="16"
|
||||
anchor="left top right bottom"
|
||||
selected="{NoRow}" onchange="{IgnoreRow}">
|
||||
<column type="text" width="230" items="{QuestRowNames}" />
|
||||
<column type="text" width="90" items="{QuestCountdowns}" colors="{QuestCountdownColors}" />
|
||||
<column type="text" width="130" items="{QuestLastSolved}" />
|
||||
<column type="text" width="100" items="{QuestCooldowns}" />
|
||||
<column type="text" width="*" items="{QuestSolves}" colors="{QuestSolvesColors}" />
|
||||
</list>
|
||||
</group>
|
||||
</panel>
|
||||
35
src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
Normal file
35
src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
Normal 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>
|
||||
|
|
@ -9,6 +9,8 @@ public sealed class BackendSessionTests
|
|||
{
|
||||
using var harness = new PluginHarness("Mossy");
|
||||
harness.Login();
|
||||
// Login itself submits /myquests; these tests count only what the backend ran.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
|
||||
harness.Receive("{\"player_name\":\"mossy\",\"command\":\"/a hello\"}");
|
||||
Assert.Empty(harness.Host.Automation.Chat.Submitted);
|
||||
|
|
@ -23,6 +25,8 @@ public sealed class BackendSessionTests
|
|||
{
|
||||
using var harness = new PluginHarness("Mossy");
|
||||
harness.Login();
|
||||
// Login itself submits /myquests; these tests count only what the backend ran.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
|
||||
harness.Receive("{\"player_name\":\"Horan\",\"command\":\"/a hello\"}");
|
||||
harness.Tick(0.01);
|
||||
|
|
@ -35,6 +39,8 @@ public sealed class BackendSessionTests
|
|||
{
|
||||
using var harness = new PluginHarness("Mossy");
|
||||
harness.Login();
|
||||
// Login itself submits /myquests; these tests count only what the backend ran.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
|
||||
harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/one\"}");
|
||||
harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/two\"}");
|
||||
|
|
@ -49,6 +55,8 @@ public sealed class BackendSessionTests
|
|||
{
|
||||
using var harness = new PluginHarness("Mossy");
|
||||
harness.Login();
|
||||
// Login itself submits /myquests; these tests count only what the backend ran.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
var toggles = new List<bool>();
|
||||
harness.Session.RadarToggle = on => toggles.Add(on);
|
||||
|
||||
|
|
@ -68,6 +76,8 @@ public sealed class BackendSessionTests
|
|||
var seen = new List<string>();
|
||||
harness.Session.AddShareHandler(new RecordingShareHandler(seen));
|
||||
harness.Login();
|
||||
// Login itself submits /myquests; these tests count only what the backend ran.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
|
||||
harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"Horan\"}");
|
||||
|
||||
|
|
@ -81,6 +91,8 @@ public sealed class BackendSessionTests
|
|||
{
|
||||
using var harness = new PluginHarness("Mossy");
|
||||
harness.Login();
|
||||
// Login itself submits /myquests; these tests count only what the backend ran.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
|
||||
harness.Receive("not json");
|
||||
harness.Receive("[1,2,3]");
|
||||
|
|
@ -94,6 +106,8 @@ public sealed class BackendSessionTests
|
|||
{
|
||||
using var harness = new PluginHarness("Mossy");
|
||||
harness.Login();
|
||||
// Login itself submits /myquests; these tests count only what the backend ran.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
|
||||
harness.Session.OnConnected(new DateTime(2026, 9, 25, 8, 5, 9, DateTimeKind.Utc));
|
||||
|
||||
|
|
|
|||
|
|
@ -452,6 +452,11 @@ public sealed class FakeSpellCatalog : ISpellCatalog
|
|||
/// <summary>The full spell table, independent of what the character knows.</summary>
|
||||
public IReadOnlyList<PluginSpellInfo> All { get; set; } = [];
|
||||
|
||||
/// <summary>The spells the character knows, for <see cref="IsKnown"/>.</summary>
|
||||
public HashSet<uint> KnownSpellIds { get; } = [];
|
||||
|
||||
public bool IsKnown(uint spellId) => KnownSpellIds.Contains(spellId);
|
||||
|
||||
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
||||
{
|
||||
foreach (PluginSpellInfo spell in KnownSelfBuffs
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
using OpenAC.MosswartMassacre.Tests.Fakes;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.FlagTracker;
|
||||
|
||||
public sealed class FlagTrackerTests
|
||||
{
|
||||
private const uint Player = 0x50000001;
|
||||
|
||||
private static PluginHarness WithUi(Action<FakeHost>? arrange = null) =>
|
||||
new(arrange: host =>
|
||||
{
|
||||
host.HasUi = true;
|
||||
arrange?.Invoke(host);
|
||||
});
|
||||
|
||||
private static void SetPlayerInts(FakeHost host, Dictionary<uint, int> ints) =>
|
||||
host.Automation.Objects.Properties[Player] = new PluginItemProperties(
|
||||
ints,
|
||||
new Dictionary<uint, long>(),
|
||||
new Dictionary<uint, bool>(),
|
||||
new Dictionary<uint, double>(),
|
||||
new Dictionary<uint, string>(),
|
||||
new Dictionary<uint, uint>(),
|
||||
new Dictionary<uint, uint>());
|
||||
|
||||
private static FlagTrackerViewModel Panel(PluginHarness harness) =>
|
||||
harness.Feature<FlagTrackerFeature>().Panel!;
|
||||
|
||||
private static int Row(IReadOnlyList<string> names, string name)
|
||||
{
|
||||
int index = names.ToList().IndexOf(name);
|
||||
Assert.True(index >= 0, $"no row {name}");
|
||||
return index;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_headless_host_gets_no_window_and_login_does_nothing()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
harness.Run(6);
|
||||
|
||||
FlagTrackerFeature feature = harness.Feature<FlagTrackerFeature>();
|
||||
Assert.Null(feature.Panel);
|
||||
Assert.Empty(harness.Host.Ui.Panels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_graphical_host_gets_the_mossy_tracker_window_closed()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
|
||||
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker");
|
||||
Assert.Equal("flagtracker", panel.Descriptor.WindowId);
|
||||
Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
|
||||
Assert.True(panel.Descriptor.ShowInSidePanel);
|
||||
Assert.EndsWith(FlagTrackerFeature.MarkupFile, panel.MarkupPath, StringComparison.Ordinal);
|
||||
Assert.Same(Panel(harness), panel.Binding);
|
||||
Assert.False(Panel(harness).IsVisible);
|
||||
Assert.True(Panel(harness).AugsSelected);
|
||||
|
||||
Panel(harness).ShowQuests();
|
||||
Assert.True(Panel(harness).QuestsSelected);
|
||||
Assert.False(Panel(harness).AugsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Augmentations_come_from_the_players_own_properties()
|
||||
{
|
||||
using var harness = WithUi(host => SetPlayerInts(host, new() { [231] = 3, [230] = 2 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal("--- Death Augs ---", panel.AugNames[0]);
|
||||
int keepItems = Row(panel.AugNames, "Keep Items");
|
||||
Assert.Equal("[MAX]", panel.AugProgress[keepItems]);
|
||||
Assert.Equal(0x008000u, panel.AugProgressColors[keepItems]);
|
||||
Assert.Equal("Rohula bint Ludun", panel.AugTrainers[keepItems]);
|
||||
Assert.Equal("Ayan Baqur", panel.AugLocations[keepItems]);
|
||||
|
||||
int keepSpells = Row(panel.AugNames, "Keep Spells");
|
||||
Assert.Equal("[0/1]", panel.AugProgress[keepSpells]);
|
||||
Assert.Equal(0xFF0000u, panel.AugProgressColors[keepSpells]);
|
||||
|
||||
int carrying = Row(panel.AugNames, "Extra Carrying Capacity");
|
||||
Assert.Equal("[2/5]", panel.AugProgress[carrying]);
|
||||
Assert.Equal(0xFFFF00u, panel.AugProgressColors[carrying]);
|
||||
|
||||
// A bought augmentation shows after Refresh.
|
||||
SetPlayerInts(harness.Host, new() { [232] = 1 });
|
||||
panel.RefreshAugs();
|
||||
Assert.Equal("[MAX]", panel.AugProgress[keepSpells]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Luminance_auras_show_progress_and_the_seer_they_belong_to()
|
||||
{
|
||||
using var harness = WithUi(host => SetPlayerInts(host, new() { [333] = 5, [344] = 2 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
int damage = Row(panel.LumNames, "+1 Damage Rating");
|
||||
Assert.Equal("5/5", panel.LumProgress[damage]);
|
||||
Assert.Equal(0x008000u, panel.LumProgressColors[damage]);
|
||||
Assert.Equal("Nalicana Auras", panel.LumCategories[damage]);
|
||||
|
||||
int kahiri = Row(panel.LumNames, "(Ka'hiri) +2 Specialized Skills");
|
||||
Assert.Equal("2/5", panel.LumProgress[kahiri]);
|
||||
Assert.Equal(0xFFFF00u, panel.LumProgressColors[kahiri]);
|
||||
Assert.Equal("LoyalToKahiri", panel.LumCategories[kahiri]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recalls_show_known_and_unknown_with_their_spell_icons()
|
||||
{
|
||||
using var harness = WithUi(host => host.Automation.Spells.KnownSpellIds.Add(2023));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal(17, panel.RecallNames.Count);
|
||||
Assert.Equal("Recall the Sanctuary", panel.RecallNames[0]);
|
||||
Assert.Equal(2023u, panel.RecallSpellIds[0]);
|
||||
Assert.Equal("Known", panel.RecallStatus[0]);
|
||||
Assert.Equal(0x008000u, panel.RecallStatusColors[0]);
|
||||
Assert.Equal("Unknown", panel.RecallStatus[1]);
|
||||
Assert.Equal(0xFF0000u, panel.RecallStatusColors[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cantrips_are_matched_to_trained_skills_attributes_and_wards()
|
||||
{
|
||||
using var harness = WithUi(host =>
|
||||
{
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(34, "War Magic", PluginSkillTraining.Specialized, 400));
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(6, "Melee Defense", PluginSkillTraining.Trained, 300));
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(21, "Healing", PluginSkillTraining.Untrained, 10));
|
||||
host.Automation.Spells.All =
|
||||
[
|
||||
FakeSpellCatalog.Spell(4715, "Epic War Magic Aptitude"),
|
||||
FakeSpellCatalog.Spell(2248, "Major Invulnerability"),
|
||||
FakeSpellCatalog.Spell(1, "Minor Invulnerability"),
|
||||
FakeSpellCatalog.Spell(6099, "Legendary Flame Ward"),
|
||||
FakeSpellCatalog.Spell(2091, "Major Strength"),
|
||||
FakeSpellCatalog.Spell(2, "Strength Self VIII"),
|
||||
];
|
||||
host.Automation.Character.ActiveEnchantments =
|
||||
[
|
||||
new PluginActiveEnchantment(4715, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2248, 0, 0, -1),
|
||||
new PluginActiveEnchantment(1, 0, 0, -1),
|
||||
new PluginActiveEnchantment(6099, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2091, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2, 0, 0, 3600),
|
||||
];
|
||||
});
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"--- Specialized Skills ---", "War Magic",
|
||||
"--- Trained Skills ---", "Invulnerability",
|
||||
"--- Attributes ---", "Strength",
|
||||
"--- Protection Auras ---", "Armor", "Bludgeoning Ward", "Piercing Ward",
|
||||
"Slashing Ward", "Flame Ward", "Frost Ward", "Acid Ward", "Storm Ward",
|
||||
],
|
||||
panel.CantripNames);
|
||||
|
||||
Assert.Equal(FlagTrackerData.Icons.CategoryStar, panel.CantripIcons[0]);
|
||||
Assert.Equal("Epic", panel.CantripLevels[1]);
|
||||
Assert.Equal(0x800080u, panel.CantripLevelColors[1]);
|
||||
Assert.Equal(FlagTrackerData.Icons.GreenCircle, panel.CantripIcons[1]);
|
||||
// The higher of two cantrips on one skill wins.
|
||||
Assert.Equal("Major", panel.CantripLevels[3]);
|
||||
Assert.Equal(0x0000FFu, panel.CantripLevelColors[3]);
|
||||
Assert.Equal("Major", panel.CantripLevels[5]);
|
||||
Assert.Equal("N/A", panel.CantripLevels[7]);
|
||||
Assert.Equal(FlagTrackerData.Icons.RedCircle, panel.CantripIcons[7]);
|
||||
int flame = Row(panel.CantripNames, "Flame Ward");
|
||||
Assert.Equal("Legendary", panel.CantripLevels[flame]);
|
||||
Assert.Equal(0xFFA500u, panel.CantripLevelColors[flame]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracked_weapons_are_found_in_the_inventory_by_name()
|
||||
{
|
||||
using var harness = WithUi(host => host.Automation.Items.Owned.Add(
|
||||
FakeItems.Item(0x80000001, "Atlan Sword") with { IconId = 0x06001234 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
int atlan = Row(panel.WeaponNames, "Atlan Sword");
|
||||
Assert.Equal("Acquired", panel.WeaponStatus[atlan]);
|
||||
Assert.Equal(0x06001234u, panel.WeaponIcons[atlan]);
|
||||
int axe = Row(panel.WeaponNames, "Atlan Axe");
|
||||
Assert.Equal("Not Acquired", panel.WeaponStatus[axe]);
|
||||
Assert.Equal(FlagTrackerData.Icons.Portal, panel.WeaponIcons[axe]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_quest_page_hides_maxed_quests_and_sorts_by_solves()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
harness.Login();
|
||||
long now = new DateTimeOffset(harness.Clock.UtcNow).ToUnixTimeSeconds();
|
||||
var chat = harness.Host.Automation.Chat;
|
||||
chat.Deliver($"stipendtimer_0812 - 2 solves ({now - 10})\"x\" -1 3700");
|
||||
chat.Deliver($"maxed - 5 solves ({now})\"x\" 5 60");
|
||||
chat.Deliver($"aaa - 7 solves ({now - 100})\"x\" -1 50");
|
||||
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
Assert.Equal(["aaa", "Stipend Collection Timer"], panel.QuestRowNames);
|
||||
Assert.Equal(["READY", "1h 01m"], panel.QuestCountdowns);
|
||||
Assert.Equal([0x008000u, 0xFFFF00u], panel.QuestCountdownColors);
|
||||
Assert.Equal(["50s", "1h 1m 40s"], panel.QuestCooldowns);
|
||||
Assert.Equal(["7", "2"], panel.QuestSolves);
|
||||
|
||||
// The countdowns move on every five seconds.
|
||||
harness.Run(5.1);
|
||||
Assert.Equal("1h 01m", panel.QuestCountdowns[1]);
|
||||
harness.Run(120);
|
||||
Assert.StartsWith("59m ", panel.QuestCountdowns[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Logoff_empties_the_pages()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
harness.Login();
|
||||
Assert.NotEmpty(Panel(harness).AugNames);
|
||||
|
||||
harness.Plugin.OnLogoff();
|
||||
|
||||
Assert.Empty(Panel(harness).AugNames);
|
||||
Assert.Empty(Panel(harness).QuestRowNames);
|
||||
}
|
||||
}
|
||||
361
tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
Normal file
361
tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using OpenAC.MosswartMassacre.Metas;
|
||||
using OpenAC.MosswartMassacre.Tests.Fakes;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.Metas;
|
||||
|
||||
/// <summary>Answers requests from a table of URLs and records what was asked; no network.</summary>
|
||||
internal sealed class FakeGitServer : HttpMessageHandler
|
||||
{
|
||||
public Dictionary<string, (HttpStatusCode Status, byte[] Body)> Responses { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public List<string> Requests { get; } = [];
|
||||
|
||||
public void Text(string url, string body) =>
|
||||
Responses[url] = (HttpStatusCode.OK, Encoding.UTF8.GetBytes(body));
|
||||
|
||||
public void Bytes(string url, byte[] body) => Responses[url] = (HttpStatusCode.OK, body);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string url = request.RequestUri!.AbsoluteUri;
|
||||
lock (Requests)
|
||||
Requests.Add(url);
|
||||
HttpResponseMessage response = Responses.TryGetValue(url, out var answer)
|
||||
? new HttpResponseMessage(answer.Status) { Content = new ByteArrayContent(answer.Body) }
|
||||
: new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("not found") };
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MetaSyncTests
|
||||
{
|
||||
// The original's endpoints, verbatim (MetaSyncManager: ApiBaseUrl + repos/{owner}/{repo}/git/trees/{branch}?recursive=1,
|
||||
// RawBaseUrl + "/" + escaped path).
|
||||
private const string TreeUrl =
|
||||
"https://git.snakedesert.se/api/v1/repos/SawatoMosswartsEnjoyersClub/metas/git/trees/main?recursive=1";
|
||||
private const string RawBase = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas/raw/branch/main";
|
||||
|
||||
// A Gitea tree answer: blobs and trees, only .met/.nav blobs count.
|
||||
private const string TreeJson = """
|
||||
{"sha":"abc","url":"x","tree":[
|
||||
{"path":"README.md","mode":"100644","type":"blob","size":10,"sha":"1"},
|
||||
{"path":"hunting","mode":"040000","type":"tree","sha":"2"},
|
||||
{"path":"hunting/Colo.met","mode":"100644","type":"blob","size":10,"sha":"3"},
|
||||
{"path":"navs/Colo Loop.nav","mode":"100644","type":"blob","size":10,"sha":"4"},
|
||||
{"path":"x.MET","mode":"100644","type":"blob","size":10,"sha":"5"},
|
||||
{"path":"folder.nav","mode":"040000","type":"tree","sha":"6"}
|
||||
],"truncated":false,"page":1,"total_count":6}
|
||||
""";
|
||||
|
||||
private const string ColoMet = "1\r\nCondition\r\nNever\r\n";
|
||||
private const string LoopNav = "uTank2 NAV 1.2\r\n4\r\n2\r\n";
|
||||
private const string XMet = "x-meta";
|
||||
|
||||
private static (MetaSyncManager Manager, FakeGitServer Server, MemoryStorage Storage, List<string> Notices) Build(
|
||||
string repositoryUrl = "")
|
||||
{
|
||||
var server = new FakeGitServer();
|
||||
server.Text(TreeUrl, TreeJson);
|
||||
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
|
||||
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
|
||||
server.Text(RawBase + "/x.MET", XMet);
|
||||
var storage = new MemoryStorage();
|
||||
var notices = new List<string>();
|
||||
var manager = new MetaSyncManager(
|
||||
storage,
|
||||
server,
|
||||
() => repositoryUrl,
|
||||
() => new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc),
|
||||
notice => { lock (notices) notices.Add(notice); });
|
||||
return (manager, server, storage, notices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_default_repository_gives_the_original_endpoints()
|
||||
{
|
||||
(string tree, string raw) = MetaSyncManager.ResolveEndpoints(string.Empty);
|
||||
Assert.Equal(TreeUrl, tree);
|
||||
Assert.Equal(RawBase, raw);
|
||||
|
||||
(tree, raw) = MetaSyncManager.ResolveEndpoints(MetaSyncManager.DefaultRepositoryUrl);
|
||||
Assert.Equal(TreeUrl, tree);
|
||||
Assert.Equal(RawBase, raw);
|
||||
|
||||
(tree, raw) = MetaSyncManager.ResolveEndpoints("https://git.example.org/me/metas.git/");
|
||||
Assert.Equal("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", tree);
|
||||
Assert.Equal("https://git.example.org/me/metas/raw/branch/main", raw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Files_land_where_the_automation_plugin_reads_dropped_profiles()
|
||||
{
|
||||
Assert.Equal("mosstank/metas/Colo.met", MetaSyncManager.LocalKeyFor("hunting/Colo.met"));
|
||||
Assert.Equal("mosstank/navs/Colo Loop.nav", MetaSyncManager.LocalKeyFor("navs/Colo Loop.nav"));
|
||||
Assert.Equal("mosstank/metas/x.MET", MetaSyncManager.LocalKeyFor("x.MET"));
|
||||
Assert.Null(MetaSyncManager.LocalKeyFor("README.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_lists_the_met_and_nav_blobs_and_what_is_already_here()
|
||||
{
|
||||
var (manager, server, storage, _) = Build();
|
||||
storage.Seed("mosstank/metas/Colo.met", "old");
|
||||
|
||||
int count = await manager.RefreshCatalogAsync();
|
||||
|
||||
Assert.Equal(3, count);
|
||||
Assert.Equal([TreeUrl], server.Requests);
|
||||
IReadOnlyList<MetaFileEntry> files = manager.GetCachedFiles();
|
||||
Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], files.Select(file => file.RelativePath));
|
||||
Assert.Equal(["met", "nav", "met"], files.Select(file => file.Extension));
|
||||
Assert.Equal(MetaFileSyncStatus.Unknown, files[0].Status);
|
||||
Assert.Equal("Unknown", files[0].StatusText);
|
||||
Assert.Equal(MetaFileSyncStatus.NotDownloaded, files[1].Status);
|
||||
Assert.Equal("Not downloaded", files[1].StatusText);
|
||||
Assert.Equal("mosstank/navs/Colo Loop.nav", files[1].LocalPath);
|
||||
Assert.Equal(new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc), manager.LastCatalogRefreshUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_compares_the_stored_text_with_the_remote_file()
|
||||
{
|
||||
var (manager, server, storage, notices) = Build();
|
||||
storage.Seed("mosstank/metas/Colo.met", ColoMet);
|
||||
storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
|
||||
storage.Seed("mosstank/metas/x.MET", XMet);
|
||||
server.Responses.Remove(RawBase + "/x.MET");
|
||||
|
||||
var summary = await manager.CheckStatusesAsync();
|
||||
|
||||
Assert.Equal((1, 1, 0, 1), summary);
|
||||
IReadOnlyList<MetaFileEntry> files = manager.GetCachedFiles();
|
||||
Assert.Equal(
|
||||
["Up to date", "Update available", "Error"],
|
||||
files.Select(file => file.StatusText));
|
||||
Assert.Contains(notices, notice => notice.StartsWith(
|
||||
"[Metas] Status check failed for x.MET: ", StringComparison.Ordinal));
|
||||
Assert.Contains(RawBase + "/navs/Colo%20Loop.nav", server.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_downloads_missing_and_outdated_files_and_backs_up_the_old_copy()
|
||||
{
|
||||
var (manager, _, storage, _) = Build();
|
||||
storage.Seed("mosstank/metas/Colo.met", ColoMet);
|
||||
storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
|
||||
|
||||
var result = await manager.DownloadOutdatedAsync();
|
||||
|
||||
Assert.Equal((2, 1, 0, 1), result);
|
||||
Assert.Equal(LoopNav, storage.ReadText("mosstank/navs/Colo Loop.nav"));
|
||||
Assert.Equal("an older route", storage.ReadText("mosstank/navs/Colo Loop.nav.bak"));
|
||||
Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
|
||||
Assert.Null(storage.ReadText("mosstank/metas/Colo.met.bak"));
|
||||
Assert.All(manager.GetCachedFiles(), file => Assert.Equal(MetaFileSyncStatus.UpToDate, file.Status));
|
||||
|
||||
// Nothing left to do the second time.
|
||||
Assert.Equal((0, 0, 0, 0), await manager.DownloadOutdatedAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_download_is_counted_and_marked()
|
||||
{
|
||||
var (manager, server, _, notices) = Build();
|
||||
server.Responses.Remove(RawBase + "/x.MET");
|
||||
await manager.RefreshCatalogAsync();
|
||||
|
||||
var result = await manager.DownloadOutdatedAsync();
|
||||
|
||||
Assert.Equal((2, 0, 1, 0), result);
|
||||
Assert.Equal(MetaFileSyncStatus.Error, manager.GetCachedFiles().Single(file => file.RelativePath == "x.MET").Status);
|
||||
Assert.Contains(notices, notice => notice.StartsWith("[Metas] Download failed for x.MET: ", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_byte_order_mark_is_dropped_so_the_stored_copy_compares_equal()
|
||||
{
|
||||
var (manager, server, storage, _) = Build();
|
||||
server.Bytes(RawBase + "/hunting/Colo.met", [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes(ColoMet)]);
|
||||
await manager.RefreshCatalogAsync();
|
||||
|
||||
MetaDownloadResult first = await manager.DownloadFileAsync("hunting/Colo.met");
|
||||
MetaDownloadResult second = await manager.DownloadFileAsync("hunting/Colo.met");
|
||||
|
||||
Assert.Equal("Downloaded.", first.Message);
|
||||
Assert.Equal(ColoMet, storage.ReadText("mosstank/metas/Colo.met"));
|
||||
Assert.True(second.SkippedAsCurrent);
|
||||
Assert.Equal("Already up to date.", second.Message);
|
||||
Assert.Equal((1, 0, 2, 0), await manager.CheckStatusesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pulling_one_file_reports_like_the_original()
|
||||
{
|
||||
var (manager, _, storage, _) = Build();
|
||||
storage.Seed("mosstank/metas/Colo.met", "old");
|
||||
|
||||
Assert.Equal("No file selected.", (await manager.DownloadFileAsync(" ")).Message);
|
||||
Assert.Equal("Only .met and .nav files can be pulled.", (await manager.DownloadFileAsync("README.md")).Message);
|
||||
Assert.Equal("Downloaded and updated (.bak created).", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
|
||||
|
||||
storage.IsAvailable = false;
|
||||
Assert.Equal("VTank path could not be resolved.", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_repository_setting_is_honoured()
|
||||
{
|
||||
var (manager, server, _, _) = Build("https://git.example.org/me/metas");
|
||||
server.Text("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", TreeJson);
|
||||
|
||||
Assert.Equal(3, await manager.RefreshCatalogAsync());
|
||||
Assert.Equal(["https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1"], server.Requests);
|
||||
}
|
||||
|
||||
// ---- through the /mm verbs and the window ------------------------------
|
||||
|
||||
private static (PluginHarness Harness, MetaSyncFeature Feature, FakeGitServer Server, MemoryStorage Storage) BuildFeature(
|
||||
bool hasUi = false)
|
||||
{
|
||||
var harness = new PluginHarness(arrange: host => host.HasUi = hasUi);
|
||||
harness.Login();
|
||||
var server = new FakeGitServer();
|
||||
server.Text(TreeUrl, TreeJson);
|
||||
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
|
||||
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
|
||||
server.Text(RawBase + "/x.MET", XMet);
|
||||
var storage = new MemoryStorage { RootPath = "C:/profiles" };
|
||||
var feature = new MetaSyncFeature(harness.Context, storage, server);
|
||||
return (harness, feature, server, storage);
|
||||
}
|
||||
|
||||
private static async Task Settle(MetaSyncFeature feature)
|
||||
{
|
||||
await feature.LastOperation;
|
||||
feature.Pump();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_mm_verbs_write_the_original_chat_lines_on_the_tick()
|
||||
{
|
||||
var (harness, feature, server, storage) = BuildFeature();
|
||||
using (harness)
|
||||
using (feature)
|
||||
{
|
||||
storage.Seed("mosstank/metas/Colo.met", ColoMet);
|
||||
|
||||
harness.Context.Commands.Dispatch("metasrefresh");
|
||||
await Settle(feature);
|
||||
harness.Context.Commands.Dispatch("metascheck");
|
||||
await Settle(feature);
|
||||
harness.Context.Commands.Dispatch("metaspull");
|
||||
await Settle(feature);
|
||||
harness.Context.Commands.Dispatch("metaspullfile navs/Colo Loop.nav");
|
||||
await Settle(feature);
|
||||
harness.Context.Commands.Dispatch("metaspullfile");
|
||||
server.Responses.Remove(TreeUrl);
|
||||
harness.Context.Commands.Dispatch("metasrefresh");
|
||||
await Settle(feature);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files",
|
||||
"[Mosswart Massacre] [Metas] Up-to-date: 1, Updates: 0, Missing: 2, Errors: 0",
|
||||
"[Mosswart Massacre] [Metas] Downloaded: 2, Skipped: 0, Failed: 0, Backups: 0",
|
||||
"[Mosswart Massacre] [Metas] navs/Colo Loop.nav: Already up to date.",
|
||||
"[Mosswart Massacre] Usage: /mm metaspullfile <relative/path/file.met>",
|
||||
"[Mosswart Massacre] [Metas] Refresh failed: Response status code does not indicate success: 404 (Not Found).",
|
||||
],
|
||||
harness.ChatLines.Where(line => line.Contains("[Metas]", StringComparison.Ordinal)
|
||||
|| line.Contains("metaspullfile", StringComparison.Ordinal)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nothing_reaches_chat_before_the_tick_applies_it()
|
||||
{
|
||||
var (harness, feature, _, _) = BuildFeature();
|
||||
using (harness)
|
||||
using (feature)
|
||||
{
|
||||
harness.Context.Commands.Dispatch("metasrefresh");
|
||||
await feature.LastOperation;
|
||||
Assert.DoesNotContain(harness.ChatLines, line => line.Contains("[Metas]", StringComparison.Ordinal));
|
||||
|
||||
harness.Tick(0.01);
|
||||
Assert.Contains("[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files", harness.ChatLines);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_headless_host_gets_the_verbs_but_no_window()
|
||||
{
|
||||
var (harness, feature, _, _) = BuildFeature();
|
||||
using (harness)
|
||||
using (feature)
|
||||
{
|
||||
Assert.Null(feature.Panel);
|
||||
Assert.Null(harness.Feature<MetaSyncFeature>().Panel);
|
||||
Assert.Empty(harness.Host.Ui.Panels);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_window_lists_searches_and_downloads_the_picked_file()
|
||||
{
|
||||
var (harness, feature, _, storage) = BuildFeature(hasUi: true);
|
||||
using (harness)
|
||||
using (feature)
|
||||
{
|
||||
MetasViewModel panel = feature.Panel!;
|
||||
Assert.False(panel.IsVisible);
|
||||
Assert.Equal("VTank Path: C:/profiles", panel.PathText);
|
||||
Assert.Equal(["No files loaded"], panel.FilePaths);
|
||||
storage.Seed("mosstank/metas/x.MET", "stale");
|
||||
|
||||
panel.RefreshList();
|
||||
Assert.Equal("Status: refreshing remote file list...", panel.StatusText);
|
||||
await Settle(feature);
|
||||
Assert.Equal("Status: loaded 3 remote files", panel.StatusText);
|
||||
// Missing first, then the rest by path.
|
||||
Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], panel.FilePaths);
|
||||
Assert.Equal(["MET", "NAV", "MET"], panel.FileTypes);
|
||||
Assert.Equal(["Not downloaded", "Not downloaded", "Unknown"], panel.FileStatus);
|
||||
Assert.Equal([0x0046A0u, 0x0046A0u, 0xFFFFFFu], panel.FileStatusColors);
|
||||
Assert.Equal(["[met] hunting/Colo.met", "[nav] navs/Colo Loop.nav", "[met] x.MET"], panel.SelectionItems);
|
||||
Assert.Equal("[met] hunting/Colo.met", panel.SelectedLabel);
|
||||
Assert.StartsWith("Last Check: 2026-09-25 ", panel.LastCheckText, StringComparison.Ordinal);
|
||||
|
||||
panel.CheckUpdates();
|
||||
await Settle(feature);
|
||||
Assert.Equal("Status: 0 current, 1 updates, 2 missing, 0 errors", panel.StatusText);
|
||||
Assert.Equal(["Not downloaded", "Not downloaded", "Update available"], panel.FileStatus);
|
||||
Assert.Equal(0xB46E00u, panel.FileStatusColors[2]);
|
||||
|
||||
panel.SearchChanged("loop");
|
||||
Assert.Equal(["navs/Colo Loop.nav"], panel.FilePaths);
|
||||
Assert.Equal("[nav] navs/Colo Loop.nav", panel.SelectedLabel);
|
||||
panel.SearchChanged("zzz");
|
||||
Assert.Equal(["No matches"], panel.FilePaths);
|
||||
Assert.Equal(["Try a different filter"], panel.FileStatus);
|
||||
panel.ClearSearch();
|
||||
Assert.Equal(3, panel.FilePaths.Count);
|
||||
|
||||
panel.SelectionChanged("[met] x.MET");
|
||||
panel.DownloadSelected();
|
||||
Assert.Equal("Status: downloading x.MET...", panel.StatusText);
|
||||
await Settle(feature);
|
||||
Assert.Equal("Status: Downloaded and updated (.bak created).", panel.StatusText);
|
||||
Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
|
||||
Assert.Equal("stale", storage.ReadText("mosstank/metas/x.MET.bak"));
|
||||
Assert.Equal("[met] x.MET", panel.SelectedLabel);
|
||||
|
||||
panel.DownloadOutdated();
|
||||
await Settle(feature);
|
||||
Assert.Equal("Status: downloaded 2, skipped 0, failed 0, backups 0", panel.StatusText);
|
||||
Assert.All(panel.FileStatus, status => Assert.Equal("Up to date", status));
|
||||
}
|
||||
}
|
||||
}
|
||||
235
tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
Normal file
235
tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using OpenAC.MosswartMassacre.Quests;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.Quests;
|
||||
|
||||
public sealed class QuestTests
|
||||
{
|
||||
// Real answer lines in the shape the server writes them (ACE's /myquests:
|
||||
// "stamp - N solves (last)\"message\" max delta"). The first is the example
|
||||
// in the original's parser comment, the second the one UtilityBelt quotes.
|
||||
private const string CommentSample = "SomeQuest - 5 solves (1640995200)\"Quest description here\" 10 3600";
|
||||
private const string UbSample =
|
||||
"regaliamaskuber - 1 solves (1522807913)\"Player has collected the mask from a Virinidi Profatrix.\" -1 72000";
|
||||
|
||||
private static long UnixNow(PluginHarness harness) =>
|
||||
new DateTimeOffset(harness.Clock.UtcNow).ToUnixTimeSeconds();
|
||||
|
||||
private static string Line(string stamp, int solves, long lastSolved, int max, long delta) =>
|
||||
$"{stamp} - {solves} solves ({lastSolved})\"A message\" {max} {delta}";
|
||||
|
||||
[Fact]
|
||||
public void Login_asks_the_server_and_reads_the_answer_for_three_seconds()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
QuestManager quests = harness.Feature<QuestManager>();
|
||||
|
||||
Assert.Equal(["/myquests"], harness.Host.Automation.Chat.Submitted);
|
||||
Assert.True(quests.IsRefreshing);
|
||||
|
||||
harness.Host.Automation.Chat.Deliver(CommentSample);
|
||||
harness.Host.Automation.Chat.Deliver(UbSample);
|
||||
harness.Host.Automation.Chat.Deliver("You say, \"hello - 3 solves\"");
|
||||
harness.Run(3.1);
|
||||
harness.Host.Automation.Chat.Deliver("late - 1 solves (1)\"x\" 1 1");
|
||||
|
||||
Assert.False(quests.IsRefreshing);
|
||||
Assert.Equal(2, quests.QuestList.Count);
|
||||
|
||||
Quest first = quests.QuestList[0];
|
||||
Assert.Equal("SomeQuest", first.Id);
|
||||
Assert.Equal(5, first.Solves);
|
||||
Assert.Equal(1640995200, first.Timestamp);
|
||||
Assert.Equal("Quest description here", first.Description);
|
||||
Assert.Equal(10, first.MaxSolves);
|
||||
Assert.Equal(3600, first.Delta);
|
||||
Assert.Equal(1640995200 + 3600, first.ExpireTime);
|
||||
|
||||
Quest second = quests.QuestList[1];
|
||||
Assert.Equal("regaliamaskuber", second.Id);
|
||||
Assert.Equal(-1, second.MaxSolves);
|
||||
Assert.Equal(72000, second.Delta);
|
||||
Assert.Equal("Player has collected the mask from a Virinidi Profatrix.", second.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_refresh_while_one_is_reading_is_ignored_and_the_answer_stays_in_chat()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
|
||||
harness.Context.Commands.Dispatch("refreshquests");
|
||||
|
||||
Assert.Equal(["/myquests"], harness.Host.Automation.Chat.Submitted);
|
||||
Assert.Contains("[Mosswart Massacre] [QUEST] Refreshing quest data...", harness.ChatLines);
|
||||
|
||||
harness.Host.Automation.Chat.Deliver(UbSample);
|
||||
// The original never ate the answer lines.
|
||||
Assert.Contains(harness.Host.Automation.Chat.Delivered, message => message.Text == UbSample);
|
||||
|
||||
harness.Run(3.1);
|
||||
harness.Context.Commands.Dispatch("refreshquests");
|
||||
Assert.Equal(["/myquests", "/myquests"], harness.Host.Automation.Chat.Submitted);
|
||||
Assert.Empty(harness.Feature<QuestManager>().QuestList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_line_whose_number_overflows_is_reported_like_the_original()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
|
||||
harness.Host.Automation.Chat.Deliver("big - 1 solves (1)\"x\" 1 99999999999");
|
||||
|
||||
Assert.Empty(harness.Feature<QuestManager>().QuestList);
|
||||
Assert.Contains(harness.ChatLines, line => line.StartsWith(
|
||||
"[Mosswart Massacre] Error parsing quest line 'big - 1 solves (1)\"x\" 1 99999999999': ",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_quest_frame_is_exactly_the_original_one()
|
||||
{
|
||||
// The original: WebSocket.SendQuestDataAsync serialized
|
||||
// new { type = "quest", timestamp = DateTime.UtcNow.ToString("o"),
|
||||
// character_name = CharacterFilter.Name, quest_name, countdown }
|
||||
// with JsonConvert.SerializeObject, and QuestStreamingService passed
|
||||
// QuestManager.GetFriendlyQuestName(quest.Id) and FormatCountdown.
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
long now = UnixNow(harness);
|
||||
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 3, now - 100, -1, 100 + 93600));
|
||||
|
||||
harness.Feature<QuestManager>().StreamPriorityQuests();
|
||||
|
||||
Assert.Equal(
|
||||
["{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Stipend Collection Timer\",\"countdown\":\"1d 02h\"}"],
|
||||
harness.Frames.Where(IsQuest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_three_priority_quests_stream_once_each_in_answer_order()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
long now = UnixNow(harness);
|
||||
harness.Host.Automation.Chat.Deliver(Line("insatiableeaterjaw", 1, now - 10, -1, 5));
|
||||
harness.Host.Automation.Chat.Deliver(Line("somethingelse", 1, now, -1, 500));
|
||||
harness.Host.Automation.Chat.Deliver(Line("augmentationblankgemacquired", 1, now, -1, 3 * 3600 + 5 * 60 + 30));
|
||||
harness.Host.Automation.Chat.Deliver(Line("insatiableeaterjaw", 9, now, -1, 99999));
|
||||
|
||||
harness.Feature<QuestManager>().StreamPriorityQuests();
|
||||
|
||||
// "insatiableeaterjaw" is in the name table twice; the later entry,
|
||||
// "Insatiable Eater Jaw", is the one the original sent.
|
||||
Assert.Equal(
|
||||
[
|
||||
"{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Insatiable Eater Jaw\",\"countdown\":\"READY\"}",
|
||||
"{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Blank Augmentation Gem Pickup Timer\",\"countdown\":\"3h 05m\"}",
|
||||
],
|
||||
harness.Frames.Where(IsQuest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_stream_runs_every_thirty_seconds_after_login()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
long now = UnixNow(harness);
|
||||
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, now, -1, 3600 + 30));
|
||||
|
||||
harness.Run(29.5);
|
||||
Assert.Empty(harness.FramesOfType("quest"));
|
||||
|
||||
harness.Run(1);
|
||||
Assert.Single(harness.FramesOfType("quest"));
|
||||
Assert.Equal("1h 00m", (string?)harness.FramesOfType("quest").Single()["countdown"]);
|
||||
|
||||
harness.Run(30);
|
||||
Assert.Equal(2, harness.FramesOfType("quest").Count());
|
||||
}
|
||||
|
||||
private static bool IsQuest(string frame) =>
|
||||
frame.StartsWith("{\"type\":\"quest\"", StringComparison.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Nothing_streams_while_streaming_is_off_or_after_logoff()
|
||||
{
|
||||
using var off = new PluginHarness(webSocketEnabled: false);
|
||||
off.Login();
|
||||
off.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(off), -1, 60));
|
||||
off.Feature<QuestManager>().StreamPriorityQuests();
|
||||
Assert.Empty(off.Frames);
|
||||
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(harness), -1, 60));
|
||||
harness.Plugin.OnLogoff();
|
||||
harness.Run(31);
|
||||
Assert.DoesNotContain(harness.Frames, IsQuest);
|
||||
Assert.Equal(0, harness.Host.Automation.Chat.ReceivedSubscriberCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quest_status_lists_the_priority_quests()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(harness), -1, 60));
|
||||
|
||||
harness.Context.Commands.Dispatch("queststatus");
|
||||
|
||||
Assert.Contains("[Mosswart Massacre] Timer Active: True", harness.ChatLines);
|
||||
Assert.Contains("[Mosswart Massacre] Quest Count: 1", harness.ChatLines);
|
||||
Assert.Contains("[Mosswart Massacre] Priority Quests Found: 1", harness.ChatLines);
|
||||
Assert.Contains("[Mosswart Massacre] - Stipend Collection Timer (stipendtimer_0812)", harness.ChatLines);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-5L, "READY")]
|
||||
[InlineData(0L, "READY")]
|
||||
[InlineData(1L, "1s")]
|
||||
[InlineData(59L, "59s")]
|
||||
[InlineData(60L, "1m 00s")]
|
||||
[InlineData(69L, "1m 09s")]
|
||||
[InlineData(3599L, "59m 59s")]
|
||||
[InlineData(3600L, "1h 00m")]
|
||||
[InlineData(3660L, "1h 01m")]
|
||||
[InlineData(86399L, "23h 59m")]
|
||||
[InlineData(86400L, "1d 00h")]
|
||||
[InlineData(93600L, "1d 02h")]
|
||||
[InlineData(10L * 86400 + 23 * 3600 + 3599, "10d 23h")]
|
||||
public void Countdown_formats_like_the_original(long seconds, string expected)
|
||||
{
|
||||
Assert.Equal(expected, QuestStreamingService.FormatCountdown(seconds));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "0s")]
|
||||
[InlineData(-1, "0s")]
|
||||
[InlineData(59, "59s")]
|
||||
[InlineData(60, "1m")]
|
||||
[InlineData(3600, "1h")]
|
||||
[InlineData(72000, "20h")]
|
||||
[InlineData(90061, "1d 1h 1m 1s")]
|
||||
[InlineData(1209600, "14d")]
|
||||
public void Cooldown_formats_like_the_original(int seconds, string expected)
|
||||
{
|
||||
Assert.Equal(expected, QuestManager.FormatSeconds(seconds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Last_solved_is_month_first_in_utc_whatever_the_culture()
|
||||
{
|
||||
System.Globalization.CultureInfo saved = System.Globalization.CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("sv-SE");
|
||||
Assert.Equal("04/04/2018 05:31:53", QuestManager.FormatTimeStamp(1522819913));
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Globalization.CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,8 @@ public sealed class StreamGoldenTests
|
|||
arrange?.Invoke(harness);
|
||||
harness.Login();
|
||||
harness.Frames.Clear();
|
||||
// Login itself submits /myquests; these tests count only their own commands.
|
||||
harness.Host.Automation.Chat.Submitted.Clear();
|
||||
return harness;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
using OpenAC.MosswartMassacre.Metas;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Proves every <c>{Binding}</c> in this slice's markup files resolves to a
|
||||
/// public property of the right shape on its binding object, the check the
|
||||
/// host would otherwise only make when the window is built in game.
|
||||
/// </summary>
|
||||
public sealed class TrackerAndMetasMarkupContractTests
|
||||
{
|
||||
private static readonly string[] InteractiveElementNames =
|
||||
[
|
||||
"tab", "button", "toggle", "slider", "field", "menu", "list", "column",
|
||||
];
|
||||
|
||||
public static TheoryData<string, Type> MarkupFiles => new()
|
||||
{
|
||||
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
|
||||
{ MetaSyncFeature.MarkupFile, typeof(MetasViewModel) },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryBindingResolvesToAPublicProperty(string file, Type bindingType)
|
||||
{
|
||||
XElement root = LoadRoot(file);
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
|
||||
|
||||
foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
|
||||
{
|
||||
string value = attribute.Value;
|
||||
if (!value.Contains('{', StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
Assert.Matches("^\\{[^{}]+\\}$", value);
|
||||
string name = value[1..^1];
|
||||
Assert.True(
|
||||
byName.ContainsKey(name),
|
||||
$"{file}: binding {value} on <{attribute.Parent?.Name}> has no public {bindingType.Name} property.");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryBindingHasTheShapeTheHostReads(string file, Type bindingType)
|
||||
{
|
||||
XElement root = LoadRoot(file);
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
|
||||
|
||||
foreach (XElement element in root.DescendantsAndSelf())
|
||||
AssertElementBindings(file, element, byName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryInteractiveControlDeclaresAHandler(string file, Type bindingType)
|
||||
{
|
||||
_ = bindingType;
|
||||
XElement root = LoadRoot(file);
|
||||
HashSet<string> interactive = new(InteractiveElementNames, StringComparer.Ordinal);
|
||||
|
||||
XElement[] controls = [.. root.Descendants().Where(element => interactive.Contains(element.Name.LocalName))];
|
||||
Assert.NotEmpty(controls);
|
||||
|
||||
foreach (XElement control in controls)
|
||||
{
|
||||
if (control.Name.LocalName == "column" && (string?)control.Attribute("type") == "text")
|
||||
continue;
|
||||
|
||||
XAttribute? handler = control.Attribute("onclick")
|
||||
?? control.Attribute("onchange")
|
||||
?? control.Attribute("onsubmit");
|
||||
Assert.True(handler is not null, $"{file}: <{control.Name.LocalName}> declares no handler.");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType)
|
||||
{
|
||||
XElement root = LoadRoot(file);
|
||||
Assert.Equal("panel", root.Name.LocalName);
|
||||
Assert.Equal("{IsVisible}", (string?)root.Attribute("visible"));
|
||||
PropertyInfo isVisible = PropertiesOf(bindingType)["IsVisible"];
|
||||
Assert.Equal(typeof(bool), isVisible.PropertyType);
|
||||
Assert.True(isVisible.CanWrite && isVisible.SetMethod!.IsPublic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
public void EveryListColumnHasASaneWidth(string file, Type bindingType)
|
||||
{
|
||||
_ = bindingType;
|
||||
XElement root = LoadRoot(file);
|
||||
|
||||
foreach (XElement list in root.Descendants("list"))
|
||||
{
|
||||
XElement[] columns = [.. list.Elements("column")];
|
||||
for (int index = 0; index < columns.Length; index++)
|
||||
{
|
||||
string? width = (string?)columns[index].Attribute("width");
|
||||
Assert.True(width is not null, $"{file}: <column> {index} has no width.");
|
||||
bool isStar = width == "*";
|
||||
bool isPositive = double.TryParse(
|
||||
width,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out double parsed)
|
||||
&& parsed > 0d;
|
||||
Assert.True(isStar || isPositive, $"{file}: <column width=\"{width}\"> at {index}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFlagTrackerTabsAreTheOriginalPagesPlusWeapons()
|
||||
{
|
||||
XElement root = LoadRoot(FlagTrackerFeature.MarkupFile);
|
||||
string[] tabs = [.. root.Elements("tab").Select(tab => (string?)tab.Attribute("text") ?? string.Empty)];
|
||||
|
||||
Assert.Equal(["Augs", "Lum", "Recalls", "Cantrips", "Weapons", "Quests"], tabs);
|
||||
Assert.Equal(6, root.Elements("group").Count());
|
||||
Assert.Equal("Mossy Tracker", (string?)root.Attribute("title"));
|
||||
}
|
||||
|
||||
private static void AssertElementBindings(string file, XElement element, IReadOnlyDictionary<string, PropertyInfo> byName)
|
||||
{
|
||||
if (element.Name.LocalName == "column")
|
||||
{
|
||||
AssertBindingType(file, element, "onchange", typeof(Action<int>), byName);
|
||||
AssertBindingType(file, element, "onclick", typeof(Action<int>), byName);
|
||||
AssertListBinding(file, element, "items", typeof(string), byName);
|
||||
AssertListBinding(file, element, "colors", typeof(uint), byName);
|
||||
switch ((string?)element.Attribute("type"))
|
||||
{
|
||||
case "check":
|
||||
AssertListBinding(file, element, "values", typeof(bool), byName);
|
||||
break;
|
||||
case "icon":
|
||||
AssertListBinding(file, element, "values", typeof(uint), byName);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
AssertBindingType(file, element, "onclick", typeof(Action), byName);
|
||||
AssertBindingType(file, element, "onsubmit", typeof(Action<string>), byName);
|
||||
AssertBindingType(file, element, "visible", typeof(bool), byName);
|
||||
AssertBindingType(file, element, "enabled", typeof(bool), byName);
|
||||
AssertBindingType(file, element, "checked", typeof(bool), byName);
|
||||
Type? selectedType = element.Name.LocalName switch
|
||||
{
|
||||
"tab" => typeof(bool),
|
||||
"list" => typeof(int),
|
||||
"menu" => null,
|
||||
_ => null,
|
||||
};
|
||||
AssertBindingType(file, element, "selected", selectedType, byName);
|
||||
|
||||
Type? changeType = element.Name.LocalName switch
|
||||
{
|
||||
"field" or "menu" => typeof(Action<string>),
|
||||
"slider" => typeof(Action<float>),
|
||||
"list" => typeof(Action<int>),
|
||||
_ => null,
|
||||
};
|
||||
AssertBindingType(file, element, "onchange", changeType, byName);
|
||||
|
||||
if (element.Name.LocalName is "list" or "menu")
|
||||
AssertListBinding(file, element, "items", typeof(string), byName);
|
||||
}
|
||||
|
||||
private static void AssertBindingType(
|
||||
string file,
|
||||
XElement element,
|
||||
string attributeName,
|
||||
Type? expected,
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName)
|
||||
{
|
||||
if (expected is null)
|
||||
return;
|
||||
string? value = (string?)element.Attribute(attributeName);
|
||||
if (value is null || !value.StartsWith('{'))
|
||||
return;
|
||||
|
||||
PropertyInfo property = byName[value[1..^1]];
|
||||
Assert.True(
|
||||
expected.IsAssignableFrom(property.PropertyType),
|
||||
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {property.PropertyType.Name}, expected {expected.Name}.");
|
||||
}
|
||||
|
||||
private static void AssertListBinding(
|
||||
string file,
|
||||
XElement element,
|
||||
string attributeName,
|
||||
Type elementType,
|
||||
IReadOnlyDictionary<string, PropertyInfo> byName)
|
||||
{
|
||||
string? value = (string?)element.Attribute(attributeName);
|
||||
if (value is null || !value.StartsWith('{'))
|
||||
return;
|
||||
|
||||
Type type = byName[value[1..^1]].PropertyType;
|
||||
Assert.True(
|
||||
typeof(IReadOnlyList<>).MakeGenericType(elementType).IsAssignableFrom(type),
|
||||
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {type.Name}, expected IReadOnlyList<{elementType.Name}>.");
|
||||
}
|
||||
|
||||
private static XElement LoadRoot(string file)
|
||||
{
|
||||
XDocument document = XDocument.Load(Path.Combine(AppContext.BaseDirectory, file));
|
||||
return Assert.IsType<XElement>(document.Root);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PropertyInfo> PropertiesOf(Type type) =>
|
||||
type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
||||
.ToDictionary(static property => property.Name, StringComparer.Ordinal);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue