using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// Settings that shape a buff pass. Defaults follow Virindi Tank's. public sealed class BuffSettings { /// /// VTank recasts buffs once they drop below five minutes remaining /// ("all buff spells are recast when they go below 5 minutes"). /// public double RebuffWhenUnderSeconds { get; set; } = 300.0; /// /// How far the casting skill must exceed a spell's difficulty before the /// tier is considered reliable — VTank's /// SpellDiffExcessThreshold-Buff. /// public int SkillExcessOverDifficulty { get; set; } = 10; /// Buff every attribute (VTank's default). public bool BuffAttributes { get; set; } = true; /// /// The elemental/physical protections and Armor Self. VTank keeps these in /// their own profile (BuffProfile_Prots) and casts them by default. /// public bool BuffProtections { get; set; } = true; /// /// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift /// Killer, Defender, Spirit Drinker. /// public bool BuffAuras { get; set; } = true; /// /// Banes — armour resistance, cast by targeting yourself. VTank keeps them /// in their own profile (BuffProfile_Banes) and casts them by default. /// public bool BuffBanes { get; set; } = true; /// /// The vital regeneration rates — Regeneration (health), Rejuvenation /// (stamina), Mana Renewal (mana). Cast last, as the tail of a pass. /// public bool BuffRegeneration { get; set; } = true; /// /// Anything else self-targeted with a duration. Off by default: useful to /// some characters, wasted mana for others, and it is the bucket anything /// unrecognised falls into. /// public bool BuffOther { get; set; } /// /// Buff trained and specialised skills only — VTank's stated default: /// "automatically buffs every Attribute and Skill you have trained". /// public bool BuffTrainedSkillsOnly { get; set; } = true; } /// /// Chooses which buffs to cast, at which tier, in which order. /// /// /// A pure function of (buff lines, character state) so the policy can be tested /// without a session. This is the part that stays in plugin-land: the host /// supplies spell data and a cast primitive, MossTank decides. /// public static class BuffPlan { /// /// Virindi Tank's Force Buff: queue every wanted line at its best castable /// tier, ignoring what is already in force and how long it has left. The /// ordinary path skips buffs that are already covered; forcing does not. /// public static List Build( IReadOnlyList lines, IReadOnlyList skills, IReadOnlyList attributes, IReadOnlyList active, BuffSettings settings, bool force = false) { var trainedSkills = new Dictionary( StringComparer.OrdinalIgnoreCase); foreach (PluginSkillInfo skill in skills) { if (!settings.BuffTrainedSkillsOnly || skill.Training is PluginSkillTraining.Trained or PluginSkillTraining.Specialized) { trainedSkills[skill.Name] = skill; } } var attributeNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (PluginAttributeInfo attribute in attributes) attributeNames.Add(attribute.Name); // Strongest in-force tier per family, and its remaining time. var inForce = new Dictionary(); foreach (PluginActiveEnchantment enchantment in active) { if (enchantment.Family == 0) continue; if (!inForce.TryGetValue(enchantment.Family, out var held) || enchantment.Tier > held.Tier) { inForce[enchantment.Family] = (enchantment.Tier, enchantment.SecondsRemaining); } } var skillLevels = new Dictionary(); foreach (PluginSkillInfo skill in skills) skillLevels[skill.SkillId] = skill.Current; var plan = new List<(int Rank, PluginSpellInfo Spell)>(); foreach (BuffLine line in lines) { bool wanted = line.Kind switch { BuffTargetKind.Attribute => settings.BuffAttributes && attributeNames.Contains(line.TargetName), BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName), BuffTargetKind.Protection => settings.BuffProtections, BuffTargetKind.Aura => settings.BuffAuras, BuffTargetKind.Bane => settings.BuffBanes, BuffTargetKind.Regeneration => settings.BuffRegeneration, BuffTargetKind.Other => settings.BuffOther, _ => false, }; if (!wanted) continue; if (!TryPickTier(line, skillLevels, settings, out PluginSpellInfo pick)) continue; if (!force && inForce.TryGetValue(line.Family, out var held) && held.Tier >= pick.Tier && held.Seconds >= settings.RebuffWhenUnderSeconds) { continue; // already covered at this strength, and not expiring } plan.Add((CastRank(line, pick), pick)); } // 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) => { 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, then /// the vital regeneration rates to finish. /// /// /// 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 }; int within = school switch { 0 => CreatureOrder(line), 2 => LifeOrder(line), _ => 0, // the item group has no internal order }; return (school * 10) + within; } /// /// Inside Life Magic the protections go first and the vital regeneration /// rates finish the pass — they are the buffs that matter least if mana /// runs out, and the ones a character most often wants topped up last. /// private static int LifeOrder(BuffLine line) => line.Kind == BuffTargetKind.Regeneration ? 1 : 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. /// /// /// Tiers are pre-sorted strongest-first, so the first one clearing the /// difficulty threshold is the answer. When no school skill is known the /// threshold cannot be evaluated, and the weakest tier is chosen rather /// than none — a buff that lands beats a buff that was never attempted. /// public static bool TryPickTier( BuffLine line, IReadOnlyDictionary skillLevels, BuffSettings settings, out PluginSpellInfo pick) { pick = default; if (line.Tiers.Count == 0) return false; foreach (PluginSpellInfo tier in line.Tiers) { if (tier.School == 0 || !skillLevels.TryGetValue(tier.School, out uint level)) continue; if (level >= tier.Difficulty + settings.SkillExcessOverDifficulty) { pick = tier; return true; } } PluginSpellInfo weakest = line.Tiers[^1]; if (weakest.School != 0 && skillLevels.ContainsKey(weakest.School)) return false; // school known, but even the weakest tier is out of reach pick = weakest; return true; } }