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