acdream/src/AcDream.Plugins.MossTank/BuffProfile.cs
Erik 46ce6f238c feat: regen buffs, wand aura, spellbook assess, indicator press flash
Four reports from one gate round. Three were mine; the fourth I first
mis-explained, and the correction is the useful part.

**The vital regeneration rates were never cast.** Regeneration (health),
Rejuvenation (stamina) and Mana Renewal (mana) all landed in the catch-all
Other bucket, which is off by default. Retail words each of the three
differently and two of the six phrasings do not begin with "Increases the
caster's" at all:

    Increase caster's natural healing rate by 10%.                 <- and note "Increase"
    Increases your Health Regeneration Rate by 50%.                (Empyrean)
    Increases the rate at which the caster regains Stamina by 10%.
    Increases the caster's natural mana rate by 10%.

They are matched per vital, on by default, and ranked at the very tail of the
Life group so they finish the pass. The mana line had to be checked BEFORE the
generic "Increases the caster's X by N" match, which would otherwise read it as
a buff to a stat named "natural mana rate".

**Aura of Hermetic Link was the sixth aura line and the only one missed.**
"a magic casting implement's" is reached by none of the other alternatives, so
the wand's mana-conversion buff was silently in Other too.

**Right-clicking a spell in the spellbook did nothing.** I claimed this had
never worked; the user said it used to, and they were right -- I had checked
one file's history and concluded from it. The regression is 3e31b0ac, which
gave UiCatalogSlot its own RightClick case returning true unconditionally. On
any list that had not wired the examine seam -- the spellbook among them -- the
event was reported handled and UiRoot stopped bubbling. Two fixes: the row now
reports an unwired right-click UNHANDLED so bubbling continues, and the
spellbook wires the seam to the same appraisal window the spell bar uses.

Retail does this generically in the list rather than per window
(UIElement_ItemList::ListenToElementMessage @ 0x004E4F1F -> ExamineSpell
@ 0x00564A70), which is exactly why a per-controller seam could be forgotten
for one window and not another.

**No green flash when pressing an indicator.** Every indicator button authors
a full-size 0x100000F2 child whose DirectState is a draw-nothing File=0 image
and whose only other state, Normal_pressed, carries the green selector sprite
0x06004CE8 -- and the buttons author Normal_pressed with PassToChildren. But
UiButton.ConsumesDatChildren drops dat children at import, so the cascade had
nothing left to reach. The child is re-attached through the same repair the map
hotspot's rollover highlight already uses.

**tools/LayoutDump** is new, and is why the last two are diagnoses rather than
guesses: it prints an authored LayoutDesc tree -- geometry, edge modes, state
sets, PassToChildren, per-state media -- straight from the installed DATs.
"Does this button even have a pressed state?" was being answered by reading our
own importer and inferring; now it is read from the data.

Solution builds clean; 14,464 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:38:28 +02:00

300 lines
12 KiB
C#

using System.Text.RegularExpressions;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>What a buff line does, which is also how it is toggled.</summary>
public enum BuffTargetKind
{
Unknown = 0,
/// <summary>Raises a skill: "Increases the caster's Life Magic skill by 10 points."</summary>
Skill,
/// <summary>Raises an attribute: "Increases the caster's Strength by 10 points."</summary>
Attribute,
/// <summary>
/// Defensive self-buff: the elemental/physical protections, and Armor Self.
/// </summary>
Protection,
/// <summary>
/// A self-cast aura that buffs the wielded weapon or caster — Blood Drinker,
/// Heart Seeker, Swift Killer, Defender, Spirit Drinker.
/// </summary>
Aura,
/// <summary>
/// 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.
/// </summary>
Bane,
/// <summary>
/// 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.
/// </summary>
Regeneration,
/// <summary>Any other self-targeted duration buff.</summary>
Other,
}
/// <summary>One buff line: a family, what it does, and its known tiers.</summary>
public sealed record BuffLine(
uint Family,
BuffTargetKind Kind,
string TargetName,
List<PluginSpellInfo> Tiers);
/// <summary>
/// Works out what each known self-buff does, straight from retail data.
/// </summary>
/// <remarks>
/// <para>
/// 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:
/// </para>
/// <code>
/// 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
/// </code>
/// <para>
/// The irregular naming is why this reads descriptions instead of names:
/// <b>Invulnerability</b> raises Melee Defense, <b>Impregnability</b> raises
/// Missile Defense, <b>Fealty</b> raises Loyalty, <b>Sprint</b> raises Run, and
/// the line called <b>Willpower</b> raises the attribute named <b>Self</b>.
/// </para>
/// <para>
/// <b>Banes are included.</b> 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.
/// </para>
/// </remarks>
public static partial class BuffProfile
{
[GeneratedRegex(
@"^Increases (?:the caster's|your) (?<target>.+?)(?<skill>\s+skill)? by ",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex IncreasesPattern();
[GeneratedRegex(
@"^Reduces damage (?:the caster|you) takes? from (?<target>.+?) by ",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ProtectionPattern();
/// <summary>
/// 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.
/// </summary>
[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();
/// <summary>
/// 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.
/// </summary>
[GeneratedRegex(
@"Target yourself to cast this spell on all of your equipped",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BanePattern();
/// <summary>
/// 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":
/// <code>
/// 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)
/// </code>
/// Note "Increase", not "Increases", in the health line — retail's own typo,
/// which is exactly why matching a strict sentence shape lost these.
/// </summary>
[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();
/// <summary>
/// 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.
/// </summary>
private static readonly Dictionary<string, string> SkillNameAliases =
new(StringComparer.OrdinalIgnoreCase)
{
["Assess Monster"] = "Assess Creature",
};
/// <summary>The six primary attributes, by the names retail's spells use.</summary>
private static readonly HashSet<string> AttributeNames =
new(StringComparer.OrdinalIgnoreCase)
{
"Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self",
};
/// <summary>
/// Group the character's known self-buffs into buff lines, keeping only
/// those that last long enough to be worth maintaining.
/// </summary>
public static List<BuffLine> Build(IReadOnlyList<PluginSpellInfo> knownSelfBuffs)
{
var byFamily = new Dictionary<uint, BuffLine>();
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<PluginSpellInfo>());
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();
}
/// <summary>Classify one spell from its retail description.</summary>
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;
}
/// <summary>Back-compatible shim for the skill/attribute cases.</summary>
public static bool TryParseTarget(
string? description, out BuffTargetKind kind, out string target)
{
Classify(description, out kind, out target);
return kind != BuffTargetKind.Unknown;
}
}