using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank;
namespace AcDream.Plugins.MossTank.Tests;
///
/// The buff policy decides what gets cast, so it is the part worth pinning. It
/// is a pure function of (buff lines, character state) precisely so these tests
/// need no host and no session.
///
public class BuffPlanTests
{
private const uint LifeMagicSkill = 33u;
private const uint CreatureEnchantmentSkill = 31u;
private static readonly BuffSettings Default = new();
private static PluginSpellInfo Spell(
uint id, uint family, int tier, string description,
int difficulty = 50, int mana = 10, uint school = CreatureEnchantmentSkill,
float duration = 1800f) =>
new(id, $"spell-{id}", family, tier, difficulty, mana, duration, school,
description, IsSelfTargeted: true, IsBeneficial: true);
private static PluginSkillInfo Skill(
uint id, string name, PluginSkillTraining training, uint level = 300) =>
new(id, name, training, level);
private static PluginAttributeInfo Attribute(int kind, string name) =>
new(kind, name, 200);
private static List Lines(params PluginSpellInfo[] spells) =>
BuffProfile.Build(spells);
// ── BuffProfile: deriving the target from retail's own description ────
[Theory]
[InlineData("Increases the caster's Life Magic skill by 10 points.",
BuffTargetKind.Skill, "Life Magic")]
[InlineData("Increases the caster's Strength by 10 points.",
BuffTargetKind.Attribute, "Strength")]
// The trap that would break any name-matching scheme: the spell line called
// Willpower raises the attribute named Self.
[InlineData("Increases the caster's Self by 10 points.",
BuffTargetKind.Attribute, "Self")]
// Retail's spell text and skill table disagree on this skill's name.
[InlineData("Increases the caster's Assess Monster skill by 10 points.",
BuffTargetKind.Skill, "Assess Creature")]
public void ParsesBuffTargetFromDescription(
string description, BuffTargetKind expectedKind, string expectedTarget)
{
Assert.True(BuffProfile.TryParseTarget(description, out var kind, out string target));
Assert.Equal(expectedKind, kind);
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(),
Array.Empty(), Array.Empty(), Default);
Assert.Equal(2, all.Count);
var none = BuffPlan.Build(lines, Array.Empty(),
Array.Empty(), Array.Empty(),
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(),
Array.Empty(), Array.Empty(), Default));
Assert.Empty(BuffPlan.Build(lines, Array.Empty(),
Array.Empty(), Array.Empty(),
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(),
Array.Empty(), Array.Empty(), Default));
Assert.Single(BuffPlan.Build(lines, Array.Empty(),
Array.Empty(), Array.Empty(),
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 _));
}
[Fact]
public void ExcludesInstantaneousSpellsFromBuffLines()
{
// Vital transfers have no duration and share families across unrelated
// lines, so treating them as buffs would be wrong twice over.
var instant = Spell(1, family: 89, tier: 1,
"Increases the caster's Strength by 10 points.", duration: 0f);
Assert.Empty(BuffProfile.Build(new[] { instant }));
}
// ── BuffPlan: what actually gets cast ────────────────────────────────
[Fact]
public void BuffsTrainedSkillsAndSkipsUntrainedOnes()
{
var lines = Lines(
Spell(1, 47, 1, "Increases the caster's Life Magic skill by 10 points."),
Spell(2, 71, 1, "Increases the caster's Leadership skill by 10 points."));
var plan = BuffPlan.Build(
lines,
new[]
{
Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Specialized),
Skill(35, "Leadership", PluginSkillTraining.Untrained),
},
Array.Empty(),
Array.Empty(),
Default);
Assert.Single(plan);
Assert.Equal(1u, plan[0].SpellId);
}
[Fact]
public void BuffsEveryAttribute()
{
var lines = Lines(
Spell(1, 1, 1, "Increases the caster's Strength by 10 points."),
Spell(2, 11, 1, "Increases the caster's Self by 10 points."));
var plan = BuffPlan.Build(
lines,
Array.Empty(),
new[] { Attribute(0, "Strength"), Attribute(5, "Self") },
Array.Empty(),
Default);
Assert.Equal(2, plan.Count);
}
[Fact]
public void PicksTheStrongestTierTheCastingSkillCanCarry()
{
// Skill 300; tiers at difficulty 400 and 200. With the default excess
// of 10, only the 200 tier is reliable.
var lines = Lines(
Spell(1, 47, 7, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 400, school: LifeMagicSkill),
Spell(2, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 200, school: LifeMagicSkill));
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained, 300) },
Array.Empty(),
Array.Empty(),
Default);
Assert.Single(plan);
Assert.Equal(2u, plan[0].SpellId);
}
[Fact]
public void SkipsAFamilyAlreadyInForceAtAnEqualTierWithTimeLeft()
{
var lines = Lines(
Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) },
Array.Empty(),
new[] { new PluginActiveEnchantment(1, 47, 4, 900) },
Default);
Assert.Empty(plan);
}
[Fact]
public void RecastsWhenTheBuffInForceIsWeaker()
{
var lines = Lines(
Spell(2, 47, 7, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) },
Array.Empty(),
new[] { new PluginActiveEnchantment(1, 47, 2, 900) },
Default);
Assert.Single(plan);
}
[Fact]
public void RefreshesBelowVirindiTanksFiveMinuteThreshold()
{
var lines = Lines(
Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var skills = new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) };
var comfortable = BuffPlan.Build(lines, skills, Array.Empty(),
new[] { new PluginActiveEnchantment(1, 47, 4, 301) }, Default);
var expiring = BuffPlan.Build(lines, skills, Array.Empty(),
new[] { new PluginActiveEnchantment(1, 47, 4, 299) }, Default);
Assert.Empty(comfortable);
Assert.Single(expiring);
}
[Fact]
public void OrdersCheapestFirstSoAPartialPassLandsMoreBuffs()
{
var lines = Lines(
Spell(1, 1, 1, "Increases the caster's Strength by 10 points.", mana: 500),
Spell(2, 3, 1, "Increases the caster's Endurance by 10 points.", mana: 5),
Spell(3, 5, 1, "Increases the caster's Quickness by 10 points.", mana: 50));
var plan = BuffPlan.Build(
lines, Array.Empty(),
new[] { Attribute(0, "Strength"), Attribute(1, "Endurance"), Attribute(2, "Quickness") },
Array.Empty(), Default);
Assert.Equal(new uint[] { 2, 3, 1 }, plan.Select(s => s.SpellId).ToArray());
}
[Fact]
public void ForceQueuesBuffsThatAreAlreadyInForce()
{
// Virindi Tank's Force Buff recasts everything rather than only what
// has lapsed, which is what the Buff button does.
var lines = Lines(
Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var skills = new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) };
var active = new[] { new PluginActiveEnchantment(1, 47, 4, 1800) };
Assert.Empty(BuffPlan.Build(lines, skills, Array.Empty(),
active, Default));
Assert.Single(BuffPlan.Build(lines, skills, Array.Empty(),
active, Default, force: true));
}
[Fact]
public void ForceStillRespectsSkillAndTrainingFilters()
{
// Forcing means "ignore what is already up", not "ignore the settings".
var lines = Lines(
Spell(1, 71, 1, "Increases the caster's Leadership skill by 10 points."));
var plan = BuffPlan.Build(
lines,
new[] { Skill(35, "Leadership", PluginSkillTraining.Untrained) },
Array.Empty(),
Array.Empty(),
Default, force: true);
Assert.Empty(plan);
}
[Fact]
public void EmptySpellbookProducesNoPlan()
{
Assert.Empty(BuffPlan.Build(
Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(),
Default));
}
// ── Cast order: a dependency order, not a preference ─────────────────
private const uint ItemEnchantmentSkill = 32u;
///
/// A spellbook holding one line of every group, with mana costs chosen to
/// fight the ordering: the buffs that must go FIRST are the most expensive,
/// so a plan that still sorted cheapest-first would come out backwards.
///
private static List OrderingSpellbook() => Lines(
// Creature Enchantment (school 31)
Spell(1, 101, 1, "Increases the caster's Creature Enchantment skill by 10 points.",
mana: 90, school: CreatureEnchantmentSkill),
Spell(2, 102, 1, "Increases the caster's Focus by 10 points.",
mana: 80, school: CreatureEnchantmentSkill),
Spell(3, 103, 1, "Increases the caster's Self by 10 points.",
mana: 70, school: CreatureEnchantmentSkill),
Spell(4, 104, 1, "Increases the caster's Endurance by 10 points.",
mana: 60, school: CreatureEnchantmentSkill),
Spell(5, 105, 1, "Increases the caster's Strength by 10 points.",
mana: 50, school: CreatureEnchantmentSkill),
Spell(6, 106, 1, "Increases the caster's Life Magic skill by 10 points.",
mana: 40, school: CreatureEnchantmentSkill),
// Item Enchantment (school 32)
Spell(7, 107, 1,
"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.",
mana: 30, school: ItemEnchantmentSkill),
Spell(8, 108, 1, "Increases a weapon's damage value by 2 points.",
mana: 20, school: ItemEnchantmentSkill),
// Life Magic (school 33)
Spell(9, 109, 1, "Reduces damage the caster takes from Fire by 9%.",
mana: 10, school: LifeMagicSkill));
private static List OrderedPlan() =>
BuffPlan.Build(
OrderingSpellbook(),
new[]
{
Skill(CreatureEnchantmentSkill, "Creature Enchantment",
PluginSkillTraining.Specialized),
Skill(ItemEnchantmentSkill, "Item Enchantment", PluginSkillTraining.Trained),
Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained),
},
new[]
{
Attribute(0, "Strength"), Attribute(1, "Endurance"),
Attribute(4, "Focus"), Attribute(5, "Self"),
},
Array.Empty(),
Default,
force: true);
[Fact]
public void CreatureSpellsCastFirst_ThenItem_ThenLifeLast()
{
List plan = OrderedPlan();
// Every creature spell precedes every item spell, which precedes every
// life spell -- and the life protection is the CHEAPEST spell in the
// book, so this cannot be the cheapest-first sort passing by accident.
int lastCreature = plan.FindLastIndex(s => s.School == CreatureEnchantmentSkill);
int firstItem = plan.FindIndex(s => s.School == ItemEnchantmentSkill);
int lastItem = plan.FindLastIndex(s => s.School == ItemEnchantmentSkill);
int firstLife = plan.FindIndex(s => s.School == LifeMagicSkill);
Assert.True(lastCreature < firstItem,
"creature spells must all be cast before item spells");
Assert.True(lastItem < firstLife,
"item spells must all be cast before life spells");
Assert.Equal(plan.Count - 1, firstLife); // the protection goes last
}
[Fact]
public void CreatureGroupLeadsWithMagicSkill_ThenFocus_Willpower_Endurance()
{
List plan = OrderedPlan();
// Spell ids 1..4 are, in order: Creature Enchantment skill, Focus,
// Willpower (which retail words as "Self"), Endurance. Everything else
// creature follows them.
Assert.Equal(new uint[] { 1, 2, 3, 4 }, plan.Take(4).Select(s => s.SpellId));
// ...and "the rest of the creature spells" really are after, not mixed in.
Assert.Equal(
new uint[] { 5, 6 },
plan.Skip(4)
.Where(s => s.School == CreatureEnchantmentSkill)
.Select(s => s.SpellId)
.OrderBy(id => id));
}
[Fact]
public void WithinOneGroupTheCheapestStillCastsFirst()
{
// Ordering outranks cost, but among equals cost still decides -- if mana
// runs out mid-pass the casualties should be the priciest of the group.
List plan = OrderedPlan();
List item =
plan.Where(s => s.School == ItemEnchantmentSkill).ToList();
Assert.Equal(2, item.Count);
Assert.True(item[0].ManaCost <= item[1].ManaCost);
Assert.Equal(8u, item[0].SpellId); // the 20-mana aura before the 30-mana bane
}
// ── The vital regeneration rates ─────────────────────────────────────
[Theory]
// Retail writes "Increase", not "Increases", and omits "the" -- a typo in
// shipped data, and the reason the strict sentence shape lost this line.
[InlineData("Increase caster's natural healing rate by 10%.", "Health")]
[InlineData("Increases your Health Regeneration Rate by 50%. "
+ "This effect can be layered with normal spell effects.", "Health")]
// This one does not begin with "Increases the caster's" at all.
[InlineData("Increases the rate at which the caster regains Stamina by 10%.", "Stamina")]
[InlineData("Increases your Stamina Regeneration Rate by 50%.", "Stamina")]
// This one DOES match the generic shape, and would have been read as a buff
// to a stat called "natural mana rate" had it been checked in that order.
[InlineData("Increases the caster's natural mana rate by 10%.", "Mana")]
[InlineData("Increases your Mana Regeneration Rate by 50%.", "Mana")]
public void RegenerationRatesAreClassifiedPerVital(string description, string vital)
{
BuffProfile.Classify(description, out var kind, out string target);
Assert.Equal(BuffTargetKind.Regeneration, kind);
Assert.Equal(vital, target);
}
[Fact]
public void HermeticLinkIsAWandAura()
{
// The only one of retail's six aura lines that none of the other
// alternatives reach; it was landing in Other and never being cast.
BuffProfile.Classify(
"Increases a magic casting implement's mana conversion bonus by 10%.",
out var kind, out _);
Assert.Equal(BuffTargetKind.Aura, kind);
}
[Fact]
public void RegenerationRatesFinishThePass()
{
List book = Lines(
Spell(1, 201, 1, "Increases the caster's Focus by 10 points.",
mana: 10, school: CreatureEnchantmentSkill),
Spell(2, 202, 1, "Reduces damage the caster takes from Fire by 9%.",
mana: 10, school: LifeMagicSkill),
Spell(3, 203, 1, "Increases the caster's natural armor by 20 points.",
mana: 10, school: LifeMagicSkill),
// The three regen lines, deliberately the CHEAPEST in the book.
Spell(4, 204, 1, "Increase caster's natural healing rate by 10%.",
mana: 1, school: LifeMagicSkill),
Spell(5, 205, 1, "Increases the rate at which the caster regains Stamina by 10%.",
mana: 1, school: LifeMagicSkill),
Spell(6, 206, 1, "Increases the caster's natural mana rate by 10%.",
mana: 1, school: LifeMagicSkill));
List plan = BuffPlan.Build(
book,
new[]
{
Skill(CreatureEnchantmentSkill, "Creature Enchantment",
PluginSkillTraining.Specialized),
Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained),
},
new[] { Attribute(4, "Focus") },
Array.Empty(),
Default,
force: true);
// All six are cast -- regen is on by default...
Assert.Equal(6, plan.Count);
// ...the protections precede them...
Assert.Equal(new uint[] { 2, 3 }, plan.Skip(1).Take(2).Select(s => s.SpellId));
// ...and the three regen lines are the tail, despite being cheapest.
Assert.Equal(
new uint[] { 4, 5, 6 },
plan.TakeLast(3).Select(s => s.SpellId).OrderBy(id => id));
}
[Fact]
public void RegenerationRatesCanBeSwitchedOff()
{
List book = Lines(
Spell(1, 301, 1, "Increase caster's natural healing rate by 10%.",
school: LifeMagicSkill));
var off = new BuffSettings { BuffRegeneration = false };
Assert.Single(BuffPlan.Build(book, Array.Empty(),
Array.Empty(),
Array.Empty(), Default, force: true));
Assert.Empty(BuffPlan.Build(book, Array.Empty(),
Array.Empty(),
Array.Empty(), off, force: true));
}
}