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>
247 lines
9.4 KiB
C#
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));
|
|
}
|
|
}
|