acdream/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs
Erik 5ca4a63272 feat(mosstank): the button is Force Buff — always recast everything
Virindi Tank's own term: Force Buff recasts the lot rather than only what has
lapsed. The button now does that, and says so.

BuffPlan gained a force flag that skips the already-in-force check. Forcing
means "ignore what is already up", NOT "ignore the settings" -- the trained-
skill filter, the attribute toggle and the difficulty margin all still apply,
and there is a test pinning that.

The loop had to change shape for this. It used to re-derive the plan every
tick and treat "plan is empty" as done, which works only because the ordinary
plan shrinks as buffs land. A forced plan never shrinks -- that is the point --
so the same loop would have cast forever. A pass now captures a queue at the
start and works through it by index, which is also cheaper: no rebuilding 80-odd
buff lines every frame.

A spell that will not go now advances the queue rather than blocking it. One
missing component used to mean everything behind it waited for the stall
timeout; now the status line names the refusal and the pass carries on.

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

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

247 lines
9.4 KiB
C#

using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank;
namespace AcDream.Plugins.MossTank.Tests;
/// <summary>
/// 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.
/// </summary>
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<BuffLine> 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);
}
[Fact]
public void IgnoresSpellsWhoseDescriptionSaysNothingAboutAStat()
{
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<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
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<PluginSkillInfo>(),
new[] { Attribute(0, "Strength"), Attribute(5, "Self") },
Array.Empty<PluginActiveEnchantment>(),
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<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
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<PluginAttributeInfo>(),
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<PluginAttributeInfo>(),
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<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 4, 301) }, Default);
var expiring = BuffPlan.Build(lines, skills, Array.Empty<PluginAttributeInfo>(),
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<PluginSkillInfo>(),
new[] { Attribute(0, "Strength"), Attribute(1, "Endurance"), Attribute(2, "Quickness") },
Array.Empty<PluginActiveEnchantment>(), 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<PluginAttributeInfo>(),
active, Default));
Assert.Single(BuffPlan.Build(lines, skills, Array.Empty<PluginAttributeInfo>(),
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<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Default, force: true);
Assert.Empty(plan);
}
[Fact]
public void EmptySpellbookProducesNoPlan()
{
Assert.Empty(BuffPlan.Build(
Array.Empty<BuffLine>(), Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(),
Default));
}
}