From fe1f124cd8bbf0f13cf9ab3537087bb2f3af2180 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 20 Aug 2026 20:07:31 +0200 Subject: [PATCH] feat(mosstank): cast buffs in retail's dependency order MossTank was casting cheapest-first. That is a reasonable rule for surviving a mana shortfall and a bad one for everything else, because AC's buff order is not a preference -- each group raises the skill the next group is cast with: 1. Creature Enchantment, and inside it: - 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 groups 2 and 3 are cast with; - the rest of the creature spells. 2. Item Enchantment -- the banes and weapon auras. 3. Life Magic last -- the protections and Armor Self. Casting out of that order means casting at a lower skill than the character could have had, which shows up as fizzles. Grouping is by the spell's school rather than by name or category, so protections land in group 3 because retail files them under Life Magic, not because anything here says "protections go last". Willpower is matched as "Self": retail's spell is named Willpower but its description reads "Increases the caster's Self by 10 points", and MossTank classifies from the description, so the name it matches is the one retail actually writes. Verified against the spell table rather than assumed (0x05A5 Willpower Self I). Cheapest-first survives as the tiebreak inside a group, so a mana shortfall still costs the cheapest of the last group instead of something the rest of the pass depended on. The two ordering tests were confirmed to fail when CastRank is neutralised -- the third guards the tiebreak and passes either way, by design. Solution builds clean; 14,453 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 --- src/AcDream.Plugins.MossTank/BuffPlan.cs | 100 +++++++++++++++-- .../BuffPlanTests.cs | 106 ++++++++++++++++++ 2 files changed, 198 insertions(+), 8 deletions(-) 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 + } }