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;
}
}