diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs index ee808bf8..235428f3 100644 --- a/src/AcDream.Plugins.MossTank/BuffPlan.cs +++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs @@ -110,7 +110,7 @@ public static class BuffPlan foreach (PluginSkillInfo skill in skills) skillLevels[skill.SkillId] = skill.Current; - var plan = new List(); + var plan = new List<(int Rank, PluginSpellInfo Spell)>(); foreach (BuffLine line in lines) { @@ -139,18 +139,102 @@ public static class BuffPlan continue; // already covered at this strength, and not expiring } - plan.Add(pick); + plan.Add((CastRank(line, pick), pick)); } - // Cheapest first: if mana runs out mid-pass, more buffs land than if the - // expensive ones had gone first. + // Retail's order first; cheapest-first only breaks ties, so that if mana + // runs out mid-pass the casualties are the cheapest of the last group + // rather than something the rest of the pass depended on. plan.Sort(static (a, b) => - a.ManaCost != b.ManaCost - ? a.ManaCost.CompareTo(b.ManaCost) - : a.SpellId.CompareTo(b.SpellId)); - return plan; + { + if (a.Rank != b.Rank) + return a.Rank.CompareTo(b.Rank); + if (a.Spell.ManaCost != b.Spell.ManaCost) + return a.Spell.ManaCost.CompareTo(b.Spell.ManaCost); + return a.Spell.SpellId.CompareTo(b.Spell.SpellId); + }); + + var ordered = new List(plan.Count); + foreach ((int _, PluginSpellInfo spell) in plan) + ordered.Add(spell); + return ordered; } + /// Skill ids of the three schools that carry self-buffs. + private const uint CreatureEnchantmentSkill = 31; + private const uint ItemEnchantmentSkill = 32; + private const uint LifeMagicSkill = 33; + + /// + /// Where a buff falls in retail's casting order. Lower casts earlier. + /// + /// + /// + /// This is a dependency order, not a preference. Each group raises + /// what the next group is cast with, so casting out of order means casting + /// at a lower skill than the character could have had: + /// + /// + /// Creature Enchantment, and within it, in this order: + /// + /// the Creature Enchantment skill itself, which every + /// remaining creature buff is then cast with; + /// Focus, then Willpower, then Endurance — + /// Focus and Self are the attributes the Item Enchantment and Life + /// Magic skills derive from, so raising them raises the skill that + /// groups 2 and 3 are cast with; + /// everything else creature. + /// + /// + /// Item Enchantment — the banes and weapon auras. + /// Life Magic last — the protections and Armor Self. + /// + /// + /// Willpower is matched as "Self". Retail's spell is named Willpower + /// but its description reads "Increases the caster's Self by 10 points", and + /// classification comes from the description, so the attribute name here is + /// the one retail actually writes. + /// + /// + public static int CastRank(BuffLine line, PluginSpellInfo pick) + { + int school = pick.School switch + { + CreatureEnchantmentSkill => 0, + ItemEnchantmentSkill => 1, + LifeMagicSkill => 2, + _ => 3, // war/void and anything unschooled trail the rest + }; + + // Only the creature group has an internal order; the other groups are + // cast in whatever order the tiebreak gives. + return (school * 10) + (school == 0 ? CreatureOrder(line) : 0); + } + + private static int CreatureOrder(BuffLine line) + { + if (line.Kind == BuffTargetKind.Skill + && Named(line.TargetName, "Creature Enchantment")) + { + return 0; + } + + if (line.Kind == BuffTargetKind.Attribute) + { + if (Named(line.TargetName, "Focus")) + return 1; + if (Named(line.TargetName, "Self")) // retail's Willpower line + return 2; + if (Named(line.TargetName, "Endurance")) + return 3; + } + + return 4; // the rest of the creature spells + } + + private static bool Named(string target, string name) => + string.Equals(target, name, StringComparison.OrdinalIgnoreCase); + /// /// The strongest tier the character's skill in that school can carry. /// diff --git a/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs b/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs index a5ee5d79..67caab9f 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs @@ -327,4 +327,110 @@ public class BuffPlanTests 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 + } }