acdream/src/AcDream.Plugins.MossTank/BuffPlan.cs
Erik 46ce6f238c feat: regen buffs, wand aura, spellbook assess, indicator press flash
Four reports from one gate round. Three were mine; the fourth I first
mis-explained, and the correction is the useful part.

**The vital regeneration rates were never cast.** Regeneration (health),
Rejuvenation (stamina) and Mana Renewal (mana) all landed in the catch-all
Other bucket, which is off by default. Retail words each of the three
differently and two of the six phrasings do not begin with "Increases the
caster's" at all:

    Increase caster's natural healing rate by 10%.                 <- and note "Increase"
    Increases your Health Regeneration Rate by 50%.                (Empyrean)
    Increases the rate at which the caster regains Stamina by 10%.
    Increases the caster's natural mana rate by 10%.

They are matched per vital, on by default, and ranked at the very tail of the
Life group so they finish the pass. The mana line had to be checked BEFORE the
generic "Increases the caster's X by N" match, which would otherwise read it as
a buff to a stat named "natural mana rate".

**Aura of Hermetic Link was the sixth aura line and the only one missed.**
"a magic casting implement's" is reached by none of the other alternatives, so
the wand's mana-conversion buff was silently in Other too.

**Right-clicking a spell in the spellbook did nothing.** I claimed this had
never worked; the user said it used to, and they were right -- I had checked
one file's history and concluded from it. The regression is 3e31b0ac, which
gave UiCatalogSlot its own RightClick case returning true unconditionally. On
any list that had not wired the examine seam -- the spellbook among them -- the
event was reported handled and UiRoot stopped bubbling. Two fixes: the row now
reports an unwired right-click UNHANDLED so bubbling continues, and the
spellbook wires the seam to the same appraisal window the spell bar uses.

Retail does this generically in the list rather than per window
(UIElement_ItemList::ListenToElementMessage @ 0x004E4F1F -> ExamineSpell
@ 0x00564A70), which is exactly why a per-controller seam could be forgotten
for one window and not another.

**No green flash when pressing an indicator.** Every indicator button authors
a full-size 0x100000F2 child whose DirectState is a draw-nothing File=0 image
and whose only other state, Normal_pressed, carries the green selector sprite
0x06004CE8 -- and the buttons author Normal_pressed with PassToChildren. But
UiButton.ConsumesDatChildren drops dat children at import, so the cascade had
nothing left to reach. The child is re-attached through the same repair the map
hotspot's rollover highlight already uses.

**tools/LayoutDump** is new, and is why the last two are diagnoses rather than
guesses: it prints an authored LayoutDesc tree -- geometry, edge modes, state
sets, PassToChildren, per-state media -- straight from the installed DATs.
"Does this button even have a pressed state?" was being answered by reading our
own importer and inferring; now it is read from the data.

Solution builds clean; 14,464 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:38:28 +02:00

295 lines
11 KiB
C#

using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Settings that shape a buff pass. Defaults follow Virindi Tank's.</summary>
public sealed class BuffSettings
{
/// <summary>
/// VTank recasts buffs once they drop below five minutes remaining
/// ("all buff spells are recast when they go below 5 minutes").
/// </summary>
public double RebuffWhenUnderSeconds { get; set; } = 300.0;
/// <summary>
/// How far the casting skill must exceed a spell's difficulty before the
/// tier is considered reliable — VTank's
/// <c>SpellDiffExcessThreshold-Buff</c>.
/// </summary>
public int SkillExcessOverDifficulty { get; set; } = 10;
/// <summary>Buff every attribute (VTank's default).</summary>
public bool BuffAttributes { get; set; } = true;
/// <summary>
/// The elemental/physical protections and Armor Self. VTank keeps these in
/// their own profile (<c>BuffProfile_Prots</c>) and casts them by default.
/// </summary>
public bool BuffProtections { get; set; } = true;
/// <summary>
/// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift
/// Killer, Defender, Spirit Drinker.
/// </summary>
public bool BuffAuras { get; set; } = true;
/// <summary>
/// Banes — armour resistance, cast by targeting yourself. VTank keeps them
/// in their own profile (<c>BuffProfile_Banes</c>) and casts them by default.
/// </summary>
public bool BuffBanes { get; set; } = true;
/// <summary>
/// The vital regeneration rates — Regeneration (health), Rejuvenation
/// (stamina), Mana Renewal (mana). Cast last, as the tail of a pass.
/// </summary>
public bool BuffRegeneration { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public bool BuffOther { get; set; }
/// <summary>
/// Buff trained and specialised skills only — VTank's stated default:
/// "automatically buffs every Attribute and Skill you have trained".
/// </summary>
public bool BuffTrainedSkillsOnly { get; set; } = true;
}
/// <summary>
/// Chooses which buffs to cast, at which tier, in which order.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class BuffPlan
{
/// <param name="force">
/// 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.
/// </param>
public static List<PluginSpellInfo> Build(
IReadOnlyList<BuffLine> lines,
IReadOnlyList<PluginSkillInfo> skills,
IReadOnlyList<PluginAttributeInfo> attributes,
IReadOnlyList<PluginActiveEnchantment> active,
BuffSettings settings,
bool force = false)
{
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
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<string>(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<uint, (int Tier, double Seconds)>();
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<uint, uint>();
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<PluginSpellInfo>(plan.Count);
foreach ((int _, PluginSpellInfo spell) in plan)
ordered.Add(spell);
return ordered;
}
/// <summary>Skill ids of the three schools that carry self-buffs.</summary>
private const uint CreatureEnchantmentSkill = 31;
private const uint ItemEnchantmentSkill = 32;
private const uint LifeMagicSkill = 33;
/// <summary>
/// Where a buff falls in retail's casting order. Lower casts earlier.
/// </summary>
/// <remarks>
/// <para>
/// This is a <b>dependency order</b>, 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:
/// </para>
/// <list type="number">
/// <item><b>Creature Enchantment</b>, and within it, in this order:
/// <list type="bullet">
/// <item>the <b>Creature Enchantment</b> skill itself, which every
/// remaining creature buff is then cast with;</item>
/// <item><b>Focus</b>, then <b>Willpower</b>, then <b>Endurance</b> —
/// 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;</item>
/// <item>everything else creature.</item>
/// </list>
/// </item>
/// <item><b>Item Enchantment</b> — the banes and weapon auras.</item>
/// <item><b>Life Magic</b> last — the protections and Armor Self, then
/// the vital regeneration rates to finish.</item>
/// </list>
/// <para>
/// <b>Willpower is matched as "Self".</b> 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.
/// </para>
/// </remarks>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// The strongest tier the character's skill in that school can carry.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static bool TryPickTier(
BuffLine line,
IReadOnlyDictionary<uint, uint> 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;
}
}