using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// /// Decides which self-buffs are missing and in what order to cast them. /// /// /// /// Pure function of (known self-buffs, active enchantments). No host calls, no /// state, no clock — so the buff policy can be reasoned about and tested /// without a live session, which is the whole reason it lives here rather than /// in the host. /// /// /// Why the spellbook is the source of truth. The obvious reading of /// "buff every trained and specialised skill" is to enumerate skills and map /// each to its buff line. The client cannot do that honestly: the link between /// a spell and the stat it modifies arrives from the server, in the /// enchantment message, and is absent from the client's own spell table. What /// the client does know is which spells the character has learned — and a /// character only learns the buffs for the skills they actually use. Driving /// from the spellbook reaches the same set without inventing a mapping the /// client has no grounds for. /// /// internal static class BuffPlan { /// /// The strongest known buff per family that is not already in force at an /// equal or higher tier, ordered so the plan is stable between passes. /// public static List Build( IReadOnlyList knownSelfBuffs, IReadOnlyList active, double refreshWhenUnderSeconds) { // Best known candidate per family. Family 0 is retail's "does not // stack" bucket: those spells share no family identity, so collapsing // them would drop all but one unrelated buff. var bestByFamily = new Dictionary(); var unstackable = new List(); foreach (PluginSpellInfo spell in knownSelfBuffs) { if (spell.Family == 0) { unstackable.Add(spell); continue; } if (!bestByFamily.TryGetValue(spell.Family, out PluginSpellInfo held) || spell.Tier > held.Tier) { bestByFamily[spell.Family] = spell; } } // Strongest in-force tier per family, and how long it has left. var activeByFamily = new Dictionary(); var activeSpellSeconds = new Dictionary(); foreach (PluginActiveEnchantment enchantment in active) { activeSpellSeconds[enchantment.SpellId] = enchantment.SecondsRemaining; if (enchantment.Family == 0) continue; if (!activeByFamily.TryGetValue(enchantment.Family, out var held) || enchantment.Tier > held.Tier) { activeByFamily[enchantment.Family] = (enchantment.Tier, enchantment.SecondsRemaining); } } var plan = new List(); foreach (PluginSpellInfo candidate in bestByFamily.Values) { if (!activeByFamily.TryGetValue(candidate.Family, out var inForce)) { plan.Add(candidate); continue; } // A weaker enchantment in force is still worth replacing: recasting // at a higher tier supersedes it. if (candidate.Tier > inForce.Tier || inForce.Seconds < refreshWhenUnderSeconds) { plan.Add(candidate); } } foreach (PluginSpellInfo candidate in unstackable) { if (!activeSpellSeconds.TryGetValue(candidate.SpellId, out double seconds) || seconds < refreshWhenUnderSeconds) { plan.Add(candidate); } } // Cheapest first: if mana runs out mid-pass, more buffs landed than if // the expensive ones had gone first. plan.Sort(static (a, b) => a.ManaCost != b.ManaCost ? a.ManaCost.CompareTo(b.ManaCost) : a.SpellId.CompareTo(b.SpellId)); return plan; } }