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

@ -160,7 +160,11 @@ internal sealed class AppAutomationSurface
{
if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta))
continue;
if (!meta.IsSelfTargeted || !meta.IsBeneficial || meta.IsDebuff)
// Beneficial and not a debuff is the whole filter. Requiring the
// self-targeted flag here is what hid every bane: they are cast by
// selecting yourself, and the flag only says "needs no selection".
// Whether a given target accepts the spell is EvaluateGate's job.
if (!meta.IsBeneficial || meta.IsDebuff || meta.IsUntargeted)
continue;
built.Add(Project(meta));
}
@ -236,6 +240,17 @@ internal sealed class AppAutomationSurface
// ── ICharacterInfo ────────────────────────────────────────────────────
public bool IsInWorld => IsAvailable;
public uint ObjectId
{
get
{
GameRuntime? runtime;
lock (_gate)
runtime = _runtime;
return runtime?.Lifecycle.PlayerGuid ?? 0u;
}
}
public uint CurrentHealth => Vital(LocalPlayerState.VitalKind.Health).Current;
public uint MaxHealth => Vital(LocalPlayerState.VitalKind.Health).Maximum;
public uint CurrentStamina => Vital(LocalPlayerState.VitalKind.Stamina).Current;

View file

@ -93,6 +93,14 @@ public interface ICharacterInfo
{
bool IsInWorld { get; }
/// <summary>
/// The local player's own object id, or 0 when not in world. Needed to
/// target yourself: retail's banes are Item Enchantments whose description
/// says "Target yourself to cast this spell on all of your equipped armor",
/// so a plugin has to select the player before casting them.
/// </summary>
uint ObjectId { get; }
uint CurrentHealth { get; }
uint MaxHealth { get; }
uint CurrentStamina { get; }
@ -119,9 +127,18 @@ public interface ICharacterInfo
public interface ISpellCatalog
{
/// <summary>
/// Every spell in the character's spellbook that targets self and is
/// beneficial.
/// Every beneficial spell in the character's spellbook that can be cast on
/// the player.
/// </summary>
/// <remarks>
/// This is NOT "spells with the self-targeted flag". That flag means the
/// spell needs no selection; it does not mean a spell without it cannot be
/// cast on you. Banes carry no self flag yet are cast by targeting
/// yourself, so filtering on the flag silently loses every one of them.
/// Spells needing a selection are included here, and
/// <see cref="IMagicCommands.EvaluateGate"/> decides whether the current
/// target actually accepts them.
/// </remarks>
IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; }
bool TryGet(uint spellId, out PluginSpellInfo info);
@ -196,6 +213,7 @@ public sealed class NoOpAutomationSurface
}
public bool IsInWorld => false;
public uint ObjectId => 0;
public uint CurrentHealth => 0;
public uint MaxHealth => 0;
public uint CurrentStamina => 0;

View file

@ -21,6 +21,31 @@ public sealed class BuffSettings
/// <summary>Buff every attribute (VTank's default).</summary>
public bool BuffAttributes { get; set; } = true;
/// <summary>
/// The elemental/physical protections and Armor Self. VTank keeps these in
/// their own profile (<c>BuffProfile_Prots</c>) and casts them by default.
/// </summary>
public bool BuffProtections { get; set; } = true;
/// <summary>
/// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift
/// Killer, Defender, Spirit Drinker.
/// </summary>
public bool BuffAuras { get; set; } = true;
/// <summary>
/// Banes — armour resistance, cast by targeting yourself. VTank keeps them
/// in their own profile (<c>BuffProfile_Banes</c>) and casts them by default.
/// </summary>
public bool BuffBanes { get; set; } = true;
/// <summary>
/// Anything else self-targeted with a duration (regeneration and friends).
/// Off by default: useful to some characters, wasted mana for others, and
/// it is the bucket anything unrecognised falls into.
/// </summary>
public bool BuffOther { get; set; }
/// <summary>
/// Buff trained and specialised skills only — VTank's stated default:
/// "automatically buffs every Attribute and Skill you have trained".
@ -94,6 +119,10 @@ public static class BuffPlan
BuffTargetKind.Attribute =>
settings.BuffAttributes && attributeNames.Contains(line.TargetName),
BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName),
BuffTargetKind.Protection => settings.BuffProtections,
BuffTargetKind.Aura => settings.BuffAuras,
BuffTargetKind.Bane => settings.BuffBanes,
BuffTargetKind.Other => settings.BuffOther,
_ => false,
};
if (!wanted)

View file

@ -3,15 +3,35 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>What a buff line raises.</summary>
/// <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>Any other self-targeted duration buff (regeneration and friends).</summary>
Other,
}
/// <summary>One buff line: a family, what it raises, and its known tiers.</summary>
/// <summary>One buff line: a family, what it does, and its known tiers.</summary>
public sealed record BuffLine(
uint Family,
BuffTargetKind Kind,
@ -19,26 +39,33 @@ public sealed record BuffLine(
List<PluginSpellInfo> Tiers);
/// <summary>
/// Works out which stat each known self-buff raises, straight from retail data.
/// 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:
/// 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.
/// Increases the caster's Strength by 10 points.
/// 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>
/// So the mapping is derived from shipped data rather than hard-coded, which
/// matters because the naming is genuinely irregular and no rule would cover
/// it: <b>Invulnerability</b> raises Melee Defense, <b>Impregnability</b>
/// raises Missile Defense, <b>Fealty</b> raises Loyalty, <b>Sprint</b> raises
/// Run, <b>Arcane Enlightenment</b> raises Arcane Lore, and — the one that
/// would silently poison any name-matching scheme — the spell line called
/// <b>Willpower</b> raises the attribute named <b>Self</b>.
/// 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
@ -48,6 +75,32 @@ public static partial class BuffProfile
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.
/// </summary>
[GeneratedRegex(
@"\b(a weapon's|weapon or magic caster|magic caster|missile weapon'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>
/// Retail's spell text says "Assess Monster" where the skill table says
/// "Assess Creature". Without this the skill silently never matches and its
@ -59,9 +112,16 @@ public static partial class BuffProfile
["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
/// lines that raise a stat and last long enough to be worth maintaining.
/// those that last long enough to be worth maintaining.
/// </summary>
public static List<BuffLine> Build(IReadOnlyList<PluginSpellInfo> knownSelfBuffs)
{
@ -75,8 +135,16 @@ public static partial class BuffProfile
if (spell.DurationSeconds <= 0f)
continue;
if (!TryParseTarget(spell.Description, out BuffTargetKind kind, out string target))
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))
{
@ -92,28 +160,77 @@ public static partial class BuffProfile
return byFamily.Values.ToList();
}
/// <summary>Parse "Increases the caster's X [skill] by N points."</summary>
public static bool TryParseTarget(
/// <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.IsNullOrEmpty(description))
return false;
if (string.IsNullOrWhiteSpace(description))
return;
Match match = IncreasesPattern().Match(description);
if (!match.Success)
return false;
// 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;
}
target = match.Groups["target"].Value.Trim();
// 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 false;
if (SkillNameAliases.TryGetValue(target, out string? alias))
target = alias;
return;
// The word "skill" is what separates a skill buff from an attribute
// buff in retail's own wording.
kind = match.Groups["skill"].Success ? BuffTargetKind.Skill : BuffTargetKind.Attribute;
return true;
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;
}
}

View file

@ -35,6 +35,12 @@ internal sealed class MossTankPanel
private int _castThisPass;
private string _status = "Idle.";
/// <summary>
/// What the player had selected before the pass, so targeting yourself for
/// banes does not quietly steal the selection and leave it changed.
/// </summary>
private uint? _selectionBeforePass;
public MossTankPanel(IPluginHost host) => _host = host;
// ── main panel bindings ───────────────────────────────────────────────
@ -116,6 +122,14 @@ internal sealed class MossTankPanel
$"Trained skills only: {OnOff(_buffSettings.BuffTrainedSkillsOnly)}";
public string AttributesText =>
$"Buff attributes: {OnOff(_buffSettings.BuffAttributes)}";
public string ProtectionsText =>
$"Buff protections: {OnOff(_buffSettings.BuffProtections)}";
public string AurasText =>
$"Buff weapon auras: {OnOff(_buffSettings.BuffAuras)}";
public string BanesText =>
$"Buff banes (armor): {OnOff(_buffSettings.BuffBanes)}";
public string OtherText =>
$"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}";
public Action DifficultyDown => () => _buffSettings.SkillExcessOverDifficulty =
Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5);
@ -141,6 +155,11 @@ internal sealed class MossTankPanel
_buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly;
public Action ToggleAttributes => () =>
_buffSettings.BuffAttributes = !_buffSettings.BuffAttributes;
public Action ToggleProtections => () =>
_buffSettings.BuffProtections = !_buffSettings.BuffProtections;
public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras;
public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes;
public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther;
private static double Step(double value, int direction) =>
Math.Clamp(Math.Round(value + direction * 0.05, 2), 0.0, 1.0);
@ -179,6 +198,7 @@ internal sealed class MossTankPanel
_queueIndex = 0;
_castThisPass = 0;
_sinceProgress = 0;
_selectionBeforePass = _host.Selection.SelectedObjectId;
_running = _queue.Count > 0;
_status = _queue.Count == 0
? "Nothing to buff."
@ -195,6 +215,16 @@ internal sealed class MossTankPanel
_queue = new List<PluginSpellInfo>();
_queueIndex = 0;
_status = status;
RestoreSelection();
}
private void RestoreSelection()
{
if (_selectionBeforePass is { } previous && previous != 0)
_host.Selection.Select(previous);
else
_host.Selection.Clear();
_selectionBeforePass = null;
}
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation, bool force) =>
@ -286,6 +316,22 @@ internal sealed class MossTankPanel
private bool TryCast(
IAutomationSurface automation, PluginSpellInfo spell, string label)
{
// A spell without the self-targeted flag still needs a target, and for
// a bane that target is the player: retail's text is "Target yourself
// to cast this spell on all of your equipped armor". Select first, or
// the gate refuses for want of a target.
if (!spell.IsSelfTargeted)
{
uint self = automation.Character.ObjectId;
if (self == 0)
{
_status = $"{spell.Name}: no self target";
return false;
}
if (_host.Selection.SelectedObjectId != self)
_host.Selection.Select(self);
}
PluginCastGate gate = automation.Magic.EvaluateGate(spell.SpellId);
if (gate != PluginCastGate.Ready)
{

View file

@ -4,7 +4,7 @@
vocabulary, and the toggle is just another Action.
Adjuster buttons rather than typed fields, because editable text in a
plugin panel needs keyboard routing plumbed through first. -->
<panel x="40" y="120" w="360" h="286" title="MossTank — Settings" visible="{SettingsVisible}">
<panel x="40" y="120" w="360" h="404" title="MossTank — Settings" visible="{SettingsVisible}">
<label x="12" y="30" text="{DifficultyText}" color="#FFE8E4C8" />
<button x="292" y="26" w="26" h="22" text="-" onclick="{DifficultyDown}" />
<button x="322" y="26" w="26" h="22" text="+" onclick="{DifficultyUp}" />
@ -28,11 +28,23 @@
<label x="12" y="172" text="{VitalUpkeepText}" color="#FF8F9C78" />
<button x="292" y="168" w="56" h="22" text="toggle" onclick="{ToggleVitalUpkeep}" />
<label x="12" y="200" text="{TrainedOnlyText}" color="#FF8F9C78" />
<button x="292" y="196" w="56" h="22" text="toggle" onclick="{ToggleTrainedOnly}" />
<label x="12" y="200" text="{AttributesText}" color="#FF8F9C78" />
<button x="292" y="196" w="56" h="22" text="toggle" onclick="{ToggleAttributes}" />
<label x="12" y="228" text="{AttributesText}" color="#FF8F9C78" />
<button x="292" y="224" w="56" h="22" text="toggle" onclick="{ToggleAttributes}" />
<label x="12" y="228" text="{TrainedOnlyText}" color="#FF8F9C78" />
<button x="292" y="224" w="56" h="22" text="toggle" onclick="{ToggleTrainedOnly}" />
<button x="12" y="252" w="108" h="26" text="Back" onclick="{CloseSettings}" />
<label x="12" y="256" text="{ProtectionsText}" color="#FF8F9C78" />
<button x="292" y="252" w="56" h="22" text="toggle" onclick="{ToggleProtections}" />
<label x="12" y="284" text="{AurasText}" color="#FF8F9C78" />
<button x="292" y="280" w="56" h="22" text="toggle" onclick="{ToggleAuras}" />
<label x="12" y="312" text="{BanesText}" color="#FF8F9C78" />
<button x="292" y="308" w="56" h="22" text="toggle" onclick="{ToggleBanes}" />
<label x="12" y="340" text="{OtherText}" color="#FF8F9C78" />
<button x="292" y="336" w="56" h="22" text="toggle" onclick="{ToggleOther}" />
<button x="12" y="368" w="108" h="26" text="Back" onclick="{CloseSettings}" />
</panel>

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

View file

@ -16,6 +16,39 @@ MagicCatalog catalog = MagicCatalog.Load(adapter);
SpellTable table = catalog.SpellTable;
Console.WriteLine($"spells loaded: {table.Count}");
if (args.Length > 0 && args[0] == "--cursors")
{
// Resolve the retail global-cursor enum table (6) to DAT surface ids, the
// same walk RetailCursorResolver does: portal master map -> table 6 -> id.
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master) || master is null)
throw new InvalidOperationException("no master enum map");
if (!master.ClientEnumToID.TryGetValue(6u, out uint cursorMapDid))
throw new InvalidOperationException("no cursor enum table 6");
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(cursorMapDid, out var cursorMap) || cursorMap is null)
throw new InvalidOperationException("cursor map missing");
foreach (var kv in cursorMap.ClientEnumToID.OrderBy(k => k.Key))
Console.WriteLine($"cursorEnum 0x{kv.Key:X2} -> surface 0x{kv.Value:X8}");
return;
}
if (args.Length > 0 && args[0] == "--flags")
{
string want = args.Length > 1 ? args[1].ToLowerInvariant() : "bane";
foreach (uint id in table.SpellIds.OrderBy(i => i))
{
if (!table.TryGet(id, out var m)) continue;
if (!m.Name.ToLowerInvariant().Contains(want)) continue;
if (m.Generation != 1 && !m.Name.Contains(" I", StringComparison.Ordinal)) continue;
Console.WriteLine(
$"0x{m.SpellId:X4} fam{m.Family,-5} gen{m.Generation,-3} " +
$"flags=0x{m.Flags:X4} mask=0x{m.TargetMask:X4} " +
$"self={m.IsSelfTargeted,-5} unt={m.IsUntargeted,-5} ben={m.IsBeneficial,-5} " +
$"school={m.School,-20} {m.Name,-28} | {m.Description}");
}
return;
}
if (args.Length > 0 && args[0] == "--desc")
{
foreach (uint id in table.SpellIds.OrderBy(i => i))