feat(mosstank): banes, protections and weapon auras

Three whole categories of buff were missing, for two different reasons, and
both were my errors.

**Protections and weapon auras were silently dropped by the description
parser.** They are self-targeted and were sitting in the spellbook the whole
time, but retail words them differently and the pattern only accepted
"Increases the caster's X by N":

    Fire Protection Self  -> "Reduces damage the caster takes from Fire by 9%."
    Armor Self            -> "Increases the caster's natural armor by 20 points."
    Aura of Blood Drinker -> "Increases a weapon's damage value by 2 points."

So the weapon and wand buffs do exist as self-cast "Aura of" lines and are now
cast. Each category has its own toggle, matching VTank's separate
BuffProfile_Prots and BuffProfile_Banes.

The underlying flaw mattered more than the two missing patterns: anything
unmatched was DISCARDED. It now falls into an Other bucket (off by default)
instead, so nothing self-targeted is lost without a word. A test caught a
second instance immediately -- regeneration spells say "Restores..." and were
vanishing the same way.

**Banes were excluded because I misread a flag.** I took IsSelfTargeted as
"can be cast on you". It means "needs no selection". Retail's own bane text
says exactly how they work:

    "Increases a shield or piece of armor's resistance to slashing damage by
     10%. Target yourself to cast this spell on all of your equipped armor."

So banes ARE cast on the person, and the catalogue now includes every
beneficial non-untargeted spell rather than only flagged self-casts, leaving
EvaluateGate to decide what a given target accepts. Before casting anything
without the self flag, MossTank selects the player -- and restores whatever
was selected before the pass, so targeting yourself does not quietly steal
the selection.

They are matched on retail's "Target yourself..." sentence rather than on the
word "Bane", so the classification comes from what the spell says it does.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 19:49:51 +02:00
parent 5ca4a63272
commit 81e6a48603
9 changed files with 394 additions and 40 deletions

View file

@ -54,9 +54,92 @@ public class BuffPlanTests
Assert.Equal(expectedTarget, target);
}
[Theory]
// Protections are self-buffs, but retail words them as damage reduction --
// the "Increases the caster's..." pattern alone silently dropped every one.
[InlineData("Reduces damage the caster takes from Fire by 9%.",
BuffTargetKind.Protection)]
[InlineData("Increases the caster's natural armor by 20 points.",
BuffTargetKind.Protection)]
// The self-cast weapon/caster auras: Blood Drinker, Heart Seeker, and kin.
[InlineData("Increases a weapon's damage value by 2 points.", BuffTargetKind.Aura)]
[InlineData("Improves a weapon's speed by 10 points.", BuffTargetKind.Aura)]
[InlineData("Increases the Melee Defense skill modifier of a weapon or magic caster by 3%.",
BuffTargetKind.Aura)]
[InlineData("Increases the elemental damage bonus of an elemental magic caster by 1%.",
BuffTargetKind.Aura)]
public void ClassifiesProtectionsAndAuras(string description, BuffTargetKind expected)
{
BuffProfile.Classify(description, out BuffTargetKind kind, out _);
Assert.Equal(expected, kind);
}
[Fact]
public void ProtectionsAndAurasAreCastWhenEnabledAndSkippedWhenNot()
{
var lines = Lines(
Spell(1, 109, 1, "Reduces damage the caster takes from Fire by 9%."),
Spell(2, 154, 1, "Increases a weapon's damage value by 2 points."));
var all = BuffPlan.Build(lines, Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(), Default);
Assert.Equal(2, all.Count);
var none = BuffPlan.Build(lines, Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(),
new BuffSettings { BuffProtections = false, BuffAuras = false });
Assert.Empty(none);
}
[Fact]
public void BanesAreClassifiedFromRetailsTargetYourselfInstruction()
{
// Banes carry no self-targeted flag, but retail's own text says how they
// are cast. Filtering on the flag is what hid every one of them.
const string bane =
"Increases a shield or piece of armor's resistance to slashing damage by 10%. "
+ "Target yourself to cast this spell on all of your equipped armor.";
BuffProfile.Classify(bane, out BuffTargetKind kind, out _);
Assert.Equal(BuffTargetKind.Bane, kind);
}
[Fact]
public void BanesAreCastWhenEnabledAndSkippedWhenNot()
{
var bane = new PluginSpellInfo(
1, "Blade Bane I", Family: 174, Tier: 1, Difficulty: 50, ManaCost: 10,
DurationSeconds: 1800f, School: CreatureEnchantmentSkill,
Description: "Increases a shield or piece of armor's resistance to slashing "
+ "damage by 10%. Target yourself to cast this spell on all of your equipped armor.",
IsSelfTargeted: false, IsBeneficial: true);
var lines = BuffProfile.Build(new[] { bane });
Assert.Single(BuffPlan.Build(lines, Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(), Default));
Assert.Empty(BuffPlan.Build(lines, Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(),
new BuffSettings { BuffBanes = false }));
}
[Fact]
public void UnrecognisedSelfBuffsFallIntoOtherAndAreOffByDefault()
{
var lines = Lines(
Spell(1, 93, 1, "Restores 10 points of the caster's Health over 20 seconds."));
Assert.Equal(BuffTargetKind.Other, lines[0].Kind);
Assert.Empty(BuffPlan.Build(lines, Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(), Default));
Assert.Single(BuffPlan.Build(lines, Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(),
new BuffSettings { BuffOther = true }));
}
[Fact]
public void IgnoresSpellsWhoseDescriptionSaysNothingAboutAStat()
{
// A vital transfer describes a drain, not a buff.
Assert.False(BuffProfile.TryParseTarget(
"Drains one-half of the caster's Stamina and gives 90% of that to his/her Mana.",
out _, out _));

View file

@ -14,6 +14,7 @@ public class VitalPlanTests
private sealed class Character : ICharacterInfo
{
public bool IsInWorld => true;
public uint ObjectId => 1u;
public uint CurrentHealth { get; init; }
public uint MaxHealth { get; init; } = 100;
public uint CurrentStamina { get; init; }