using System.Text.RegularExpressions; using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// What a buff line does, which is also how it is toggled. public enum BuffTargetKind { Unknown = 0, /// Raises a skill: "Increases the caster's Life Magic skill by 10 points." Skill, /// Raises an attribute: "Increases the caster's Strength by 10 points." Attribute, /// /// Defensive self-buff: the elemental/physical protections, and Armor Self. /// Protection, /// /// A self-cast aura that buffs the wielded weapon or caster — Blood Drinker, /// Heart Seeker, Swift Killer, Defender, Spirit Drinker. /// Aura, /// /// A bane: an Item Enchantment raising armour resistance. Cast by selecting /// YOURSELF — retail's own description says "Target yourself to cast this /// spell on all of your equipped armor" — so it needs a selection even /// though it is, in effect, a self buff. /// Bane, /// /// A vital regeneration rate buff: Regeneration (health), Rejuvenation /// (stamina), Mana Renewal (mana), and their Empyrean/Prodigal kin. All /// Life Magic, and cast at the very end of a pass. /// Regeneration, /// Any other self-targeted duration buff. Other, } /// One buff line: a family, what it does, and its known tiers. public sealed record BuffLine( uint Family, BuffTargetKind Kind, string TargetName, List Tiers); /// /// Works out what each known self-buff does, straight from retail data. /// /// /// /// The client's spell table carries no link between a spell and the stat it /// modifies — that arrives from the server with the enchantment. But retail /// writes it in the spell's own description, so the classification is derived /// from shipped data rather than hard-coded: /// /// /// Increases the caster's Life Magic skill by 10 points. -> Skill /// Increases the caster's Strength by 10 points. -> Attribute /// Reduces damage the caster takes from Fire by 9%. -> Protection /// Increases the caster's natural armor by 20 points. -> Protection /// Increases a weapon's damage value by 2 points. -> Aura /// /// /// The irregular naming is why this reads descriptions instead of names: /// Invulnerability raises Melee Defense, Impregnability raises /// Missile Defense, Fealty raises Loyalty, Sprint raises Run, and /// the line called Willpower raises the attribute named Self. /// /// /// Banes are included. They carry no self-targeted flag, but that flag /// means "needs no selection", not "cannot be cast on you": retail's own text /// says "Target yourself to cast this spell on all of your equipped armor". So /// they are classified here and the caller selects the player before casting. /// /// public static partial class BuffProfile { [GeneratedRegex( @"^Increases (?:the caster's|your) (?.+?)(?\s+skill)? by ", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex IncreasesPattern(); [GeneratedRegex( @"^Reduces damage (?:the caster|you) takes? from (?.+?) by ", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex ProtectionPattern(); /// /// Retail's own wording for the weapon/caster auras. Matched on the /// description rather than the "Aura of" name prefix so the older /// non-aura phrasings classify the same way. /// /// "magic casting implement's" is the wand buff Aura of Hermetic Link, and /// it is the ONLY one of retail's six aura lines that none of the other /// alternatives reach -- it was silently landing in Other. /// [GeneratedRegex( @"\b(a weapon's|weapon or magic caster|magic caster|missile weapon's|magic casting implement's)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex AuraPattern(); /// /// Banes, matched on retail's own instruction rather than on the word /// "Bane": "Target yourself to cast this spell on all of your equipped /// armor." That sentence is what says these are cast at the player, which /// the self-targeted flag does not. /// [GeneratedRegex( @"Target yourself to cast this spell on all of your equipped", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex BanePattern(); /// /// The three vital-rate lines, matched one vital at a time. Retail words /// each of them differently and two of the six phrasings do not even begin /// with "Increases the caster's": /// /// Increase caster's natural healing rate by 10%. (Regeneration) /// Increases your Health Regeneration Rate by 50%. (Empyrean) /// Increases the rate at which the caster regains Stamina by 10%. (Rejuvenation) /// Increases your Stamina Regeneration Rate by 50%. (Empyrean) /// Increases the caster's natural mana rate by 10%. (Mana Renewal) /// Increases your Mana Regeneration Rate by 50%. (Empyrean) /// /// Note "Increase", not "Increases", in the health line — retail's own typo, /// which is exactly why matching a strict sentence shape lost these. /// [GeneratedRegex( "natural healing rate|Health Regeneration Rate", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex HealthRegenPattern(); [GeneratedRegex( "rate at which the caster regains Stamina|Stamina Regeneration Rate", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex StaminaRegenPattern(); [GeneratedRegex( "natural mana rate|Mana Regeneration Rate", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex ManaRegenPattern(); /// /// Retail's spell text says "Assess Monster" where the skill table says /// "Assess Creature". Without this the skill silently never matches and its /// buff is quietly dropped from every plan. /// private static readonly Dictionary SkillNameAliases = new(StringComparer.OrdinalIgnoreCase) { ["Assess Monster"] = "Assess Creature", }; /// The six primary attributes, by the names retail's spells use. private static readonly HashSet AttributeNames = new(StringComparer.OrdinalIgnoreCase) { "Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self", }; /// /// Group the character's known self-buffs into buff lines, keeping only /// those that last long enough to be worth maintaining. /// public static List Build(IReadOnlyList knownSelfBuffs) { var byFamily = new Dictionary(); foreach (PluginSpellInfo spell in knownSelfBuffs) { // Instantaneous spells (the vital transfers, heals) are not buffs; // they also share families across unrelated lines, so grouping them // by family would be wrong twice over. if (spell.DurationSeconds <= 0f) continue; Classify(spell.Description, out BuffTargetKind kind, out string target); if (kind == BuffTargetKind.Unknown) { // Nothing self-targeted is discarded for being unrecognised. // Dropping what the patterns do not match is how protections // and weapon auras went missing without a word; an unknown // spell belongs in Other, which the user can switch on. kind = BuffTargetKind.Other; target = spell.Name; } if (!byFamily.TryGetValue(spell.Family, out BuffLine? line)) { line = new BuffLine(spell.Family, kind, target, new List()); byFamily.Add(spell.Family, line); } line.Tiers.Add(spell); } foreach (BuffLine line in byFamily.Values) line.Tiers.Sort(static (a, b) => b.Tier.CompareTo(a.Tier)); // strongest first return byFamily.Values.ToList(); } /// Classify one spell from its retail description. public static void Classify( string? description, out BuffTargetKind kind, out string target) { kind = BuffTargetKind.Unknown; target = string.Empty; if (string.IsNullOrWhiteSpace(description)) return; // Banes first: their text also mentions armour resistance, and the // "target yourself" instruction is what actually identifies them. if (BanePattern().IsMatch(description)) { kind = BuffTargetKind.Bane; target = "equipped armor"; return; } // Regeneration before the generic "Increases the caster's X by N" match // further down, which would otherwise read Mana Renewal as a buff to a // stat named "natural mana rate". if (HealthRegenPattern().IsMatch(description)) { kind = BuffTargetKind.Regeneration; target = "Health"; return; } if (StaminaRegenPattern().IsMatch(description)) { kind = BuffTargetKind.Regeneration; target = "Stamina"; return; } if (ManaRegenPattern().IsMatch(description)) { kind = BuffTargetKind.Regeneration; target = "Mana"; return; } // Auras next: "Increases a weapon's damage value" would otherwise be // read as raising something on the caster. if (AuraPattern().IsMatch(description)) { kind = BuffTargetKind.Aura; target = "weapon"; return; } Match protection = ProtectionPattern().Match(description); if (protection.Success) { kind = BuffTargetKind.Protection; target = protection.Groups["target"].Value.Trim(); return; } Match increases = IncreasesPattern().Match(description); if (!increases.Success) return; target = increases.Groups["target"].Value.Trim(); if (target.Length == 0) return; if (increases.Groups["skill"].Success) { // The word "skill" is what separates a skill buff from an attribute // buff in retail's own wording. kind = BuffTargetKind.Skill; if (SkillNameAliases.TryGetValue(target, out string? alias)) target = alias; return; } if (AttributeNames.Contains(target)) { kind = BuffTargetKind.Attribute; return; } // "Increases the caster's natural armor by 20 points" — defensive, but // neither a skill nor an attribute. kind = target.Contains("armor", StringComparison.OrdinalIgnoreCase) ? BuffTargetKind.Protection : BuffTargetKind.Other; } /// Back-compatible shim for the skill/attribute cases. public static bool TryParseTarget( string? description, out BuffTargetKind kind, out string target) { Classify(description, out kind, out target); return kind != BuffTargetKind.Unknown; } }