feat(mosstank): buff trained skills and attributes, pick tiers by skill, manage mana

Reworks MossTank against user feedback and the Virindi Tank feature docs
(virindi.net is reachable again over https with a self-signed cert; the
research doc's "unreachable" note is stale).

VTank's stated default is the spec: "automatically buffs every Attribute and
Skill you have trained", and "all buff spells are recast when they go below 5
minutes". The previous pass buffed the whole spellbook and refreshed at 60s;
both are corrected.

The hard problem was working out WHICH stat each buff raises. The client's
spell table has no such link -- it arrives from the server with the
enchantment -- and the naming is too irregular to infer: Invulnerability
raises Melee Defense, Impregnability raises Missile Defense, Fealty raises
Loyalty, Sprint raises Run, Arcane Enlightenment raises Arcane Lore, and the
line called Willpower raises the attribute named Self. Any name-matching
scheme dies on that last one.

Retail states it outright in each spell's own description ("Increases the
caster's Life Magic skill by 10 points"), so BuffProfile derives the whole
mapping from shipped data at runtime. It also carries the one alias the data
needs: the spell text says "Assess Monster" where the skill table says "Assess
Creature", and without that the skill silently never matches.

Two data facts that would each have caused a real bug, found by dumping the
spell table rather than assuming:

* Family is NOT a spell-line identity in general. Retail groups the
  instantaneous vital transfers by SOURCE vital, so family 89 holds both
  "Stamina to Health" and "Stamina to Mana". Picking the strongest tier in a
  family would convert into the wrong vital about half the time. Buff lines
  group by family (correct for duration buffs, which is retail's own stacking
  bucket); the conversions are found by name stem instead.
* Instantaneous spells have no duration and must be excluded from buff lines
  entirely, or they are treated as buffs that never appear to land.

Tier selection now follows the character's skill in the casting school against
the spell's difficulty (VTank's SpellDiffExcessThreshold-Buff), which is why
PluginSpellInfo gained School as a SKILL id -- MagicSchool is retail's 1-5
school enum, not something a character trains.

Mana upkeep is the loop asked for: convert stamina to mana when mana is low,
Revitalize when that leaves stamina too low to convert, and refuse to drain
stamina past a floor. Unknown vitals read as zero and are treated as "no
information" rather than "empty", so it will not cast on a healthy character.

Panel no longer shows at character select. IsAvailable is now the runtime's
own lifecycle state rather than a proxy, and markup gained visible="{Binding}"
plus UiElement.VisibleSource -- evaluated before the visible gate, because
TickSelfAndChildren returns early when hidden and an element could otherwise
never un-hide itself.

Also: a generated SpellId enum of all 6,266 spells (tools/SpellDump --enum),
generated from portal.dat rather than copied, so it cannot drift and carries
no third-party licence; skill and spell names now come from the retail tables
for display; and the Buff click logs unconditionally, so "nothing happened"
can be told apart from "the click never arrived".

Solution builds clean; 14,433 tests pass on the standard hermetic lane filter,
0 failures, including 21 covering the buff profile, tier selection and mana
loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 18:09:02 +02:00
parent 9d1117b923
commit 17ebfc434d
18 changed files with 14052 additions and 279 deletions

View file

@ -4,147 +4,210 @@ using AcDream.Plugins.MossTank;
namespace AcDream.Plugins.MossTank.Tests;
/// <summary>
/// The buff policy is the part of MossTank that decides what gets cast, so it
/// is the part worth pinning. It is a pure function of (known buffs, active
/// enchantments) precisely so these tests need no host and no session.
/// The buff policy decides what gets cast, so it is the part worth pinning. It
/// is a pure function of (buff lines, character state) precisely so these tests
/// need no host and no session.
/// </summary>
public class BuffPlanTests
{
private const double Refresh = 60.0;
private const uint LifeMagicSkill = 33u;
private const uint CreatureEnchantmentSkill = 31u;
private static readonly BuffSettings Default = new();
private static PluginSpellInfo Spell(
uint id, uint family, int tier, int mana = 10) =>
new(id, $"spell-{id}", family, tier, Difficulty: 100, ManaCost: mana,
DurationSeconds: 1800f, IsSelfTargeted: true, IsBeneficial: true);
uint id, uint family, int tier, string description,
int difficulty = 50, int mana = 10, uint school = CreatureEnchantmentSkill,
float duration = 1800f) =>
new(id, $"spell-{id}", family, tier, difficulty, mana, duration, school,
description, IsSelfTargeted: true, IsBeneficial: true);
private static PluginActiveEnchantment Active(
uint id, uint family, int tier, double seconds) =>
new(id, family, tier, seconds);
private static PluginSkillInfo Skill(
uint id, string name, PluginSkillTraining training, uint level = 300) =>
new(id, name, training, level);
private static PluginAttributeInfo Attribute(int kind, string name) =>
new(kind, name, 200);
private static List<BuffLine> Lines(params PluginSpellInfo[] spells) =>
BuffProfile.Build(spells);
// ── BuffProfile: deriving the target from retail's own description ────
[Theory]
[InlineData("Increases the caster's Life Magic skill by 10 points.",
BuffTargetKind.Skill, "Life Magic")]
[InlineData("Increases the caster's Strength by 10 points.",
BuffTargetKind.Attribute, "Strength")]
// The trap that would break any name-matching scheme: the spell line called
// Willpower raises the attribute named Self.
[InlineData("Increases the caster's Self by 10 points.",
BuffTargetKind.Attribute, "Self")]
// Retail's spell text and skill table disagree on this skill's name.
[InlineData("Increases the caster's Assess Monster skill by 10 points.",
BuffTargetKind.Skill, "Assess Creature")]
public void ParsesBuffTargetFromDescription(
string description, BuffTargetKind expectedKind, string expectedTarget)
{
Assert.True(BuffProfile.TryParseTarget(description, out var kind, out string target));
Assert.Equal(expectedKind, kind);
Assert.Equal(expectedTarget, target);
}
[Fact]
public void WithNothingActive_CastsTheStrongestTierPerFamily()
public void IgnoresSpellsWhoseDescriptionSaysNothingAboutAStat()
{
var known = new[]
{
Spell(1, family: 10, tier: 1),
Spell(2, family: 10, tier: 7),
Spell(3, family: 20, tier: 4),
};
Assert.False(BuffProfile.TryParseTarget(
"Drains one-half of the caster's Stamina and gives 90% of that to his/her Mana.",
out _, out _));
}
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
[Fact]
public void ExcludesInstantaneousSpellsFromBuffLines()
{
// Vital transfers have no duration and share families across unrelated
// lines, so treating them as buffs would be wrong twice over.
var instant = Spell(1, family: 89, tier: 1,
"Increases the caster's Strength by 10 points.", duration: 0f);
Assert.Empty(BuffProfile.Build(new[] { instant }));
}
// ── BuffPlan: what actually gets cast ────────────────────────────────
[Fact]
public void BuffsTrainedSkillsAndSkipsUntrainedOnes()
{
var lines = Lines(
Spell(1, 47, 1, "Increases the caster's Life Magic skill by 10 points."),
Spell(2, 71, 1, "Increases the caster's Leadership skill by 10 points."));
var plan = BuffPlan.Build(
lines,
new[]
{
Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Specialized),
Skill(35, "Leadership", PluginSkillTraining.Untrained),
},
Array.Empty<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Default);
Assert.Single(plan);
Assert.Equal(1u, plan[0].SpellId);
}
[Fact]
public void BuffsEveryAttribute()
{
var lines = Lines(
Spell(1, 1, 1, "Increases the caster's Strength by 10 points."),
Spell(2, 11, 1, "Increases the caster's Self by 10 points."));
var plan = BuffPlan.Build(
lines,
Array.Empty<PluginSkillInfo>(),
new[] { Attribute(0, "Strength"), Attribute(5, "Self") },
Array.Empty<PluginActiveEnchantment>(),
Default);
Assert.Equal(2, plan.Count);
Assert.Contains(plan, s => s.SpellId == 2); // tier 7 beat tier 1
Assert.Contains(plan, s => s.SpellId == 3);
Assert.DoesNotContain(plan, s => s.SpellId == 1);
}
[Fact]
public void SkipsFamiliesAlreadyInForceAtTheSameTier()
public void PicksTheStrongestTierTheCastingSkillCanCarry()
{
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(2, family: 10, tier: 7, seconds: 900) };
// Skill 300; tiers at difficulty 400 and 200. With the default excess
// of 10, only the 200 tier is reliable.
var lines = Lines(
Spell(1, 47, 7, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 400, school: LifeMagicSkill),
Spell(2, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 200, school: LifeMagicSkill));
Assert.Empty(BuffPlan.Build(known, active, Refresh));
}
[Fact]
public void RecastsWhenAStrongerTierIsKnownThanTheOneInForce()
{
// The whole point of tracking tier rather than mere presence: a
// level-1 buff in force must not block casting the level-7 one.
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(1, family: 10, tier: 1, seconds: 900) };
var plan = BuffPlan.Build(known, active, Refresh);
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained, 300) },
Array.Empty<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Default);
Assert.Single(plan);
Assert.Equal(2u, plan[0].SpellId);
}
[Fact]
public void RefreshesABuffThatIsAboutToExpire()
public void SkipsAFamilyAlreadyInForceAtAnEqualTierWithTimeLeft()
{
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(2, family: 10, tier: 7, seconds: 5) };
var lines = Lines(
Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var plan = BuffPlan.Build(known, active, Refresh);
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) },
Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 4, 900) },
Default);
Assert.Single(plan);
Assert.Equal(2u, plan[0].SpellId);
Assert.Empty(plan);
}
[Fact]
public void TreatsFamilyZeroSpellsIndividually()
public void RecastsWhenTheBuffInForceIsWeaker()
{
// Family 0 is retail's "does not stack" bucket. Collapsing it by family
// would silently drop every such buff but one, and they are unrelated
// spells that all need casting.
var known = new[]
{
Spell(101, family: 0, tier: 1),
Spell(102, family: 0, tier: 1),
Spell(103, family: 0, tier: 1),
};
var lines = Lines(
Spell(2, 47, 7, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) },
Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 2, 900) },
Default);
Assert.Equal(3, plan.Count);
Assert.Single(plan);
}
[Fact]
public void SkipsAnIndividuallyActiveFamilyZeroSpell()
public void RefreshesBelowVirindiTanksFiveMinuteThreshold()
{
var known = new[] { Spell(101, family: 0, tier: 1), Spell(102, family: 0, tier: 1) };
var active = new[] { Active(101, family: 0, tier: 1, seconds: 900) };
var lines = Lines(
Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var skills = new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) };
var plan = BuffPlan.Build(known, active, Refresh);
var comfortable = BuffPlan.Build(lines, skills, Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 4, 301) }, Default);
var expiring = BuffPlan.Build(lines, skills, Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 4, 299) }, Default);
Assert.Single(plan);
Assert.Equal(102u, plan[0].SpellId);
Assert.Empty(comfortable);
Assert.Single(expiring);
}
[Fact]
public void OrdersCheapestFirstSoAPartialPassLandsMoreBuffs()
{
var known = new[]
{
Spell(1, family: 10, tier: 1, mana: 500),
Spell(2, family: 20, tier: 1, mana: 5),
Spell(3, family: 30, tier: 1, mana: 50),
};
var lines = Lines(
Spell(1, 1, 1, "Increases the caster's Strength by 10 points.", mana: 500),
Spell(2, 3, 1, "Increases the caster's Endurance by 10 points.", mana: 5),
Spell(3, 5, 1, "Increases the caster's Quickness by 10 points.", mana: 50));
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
var plan = BuffPlan.Build(
lines, Array.Empty<PluginSkillInfo>(),
new[] { Attribute(0, "Strength"), Attribute(1, "Endurance"), Attribute(2, "Quickness") },
Array.Empty<PluginActiveEnchantment>(), Default);
Assert.Equal(new uint[] { 2, 3, 1 }, plan.Select(s => s.SpellId).ToArray());
}
[Fact]
public void IsStableAcrossRepeatedBuilds()
{
// The tick loop rebuilds the plan every pass; an unstable order would
// make it re-cast the same spell while starving another.
var known = new[]
{
Spell(1, family: 10, tier: 1, mana: 20),
Spell(2, family: 20, tier: 1, mana: 20),
Spell(3, family: 30, tier: 1, mana: 20),
};
var first = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
var second = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
Assert.Equal(
first.Select(s => s.SpellId).ToArray(),
second.Select(s => s.SpellId).ToArray());
}
[Fact]
public void EmptySpellbookProducesNoPlan()
{
Assert.Empty(BuffPlan.Build(
Array.Empty<PluginSpellInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Refresh));
Array.Empty<BuffLine>(), Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(),
Default));
}
}

View file

@ -0,0 +1,112 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank;
namespace AcDream.Plugins.MossTank.Tests;
/// <summary>
/// The mana-upkeep loop: convert stamina to mana when mana is low, restore
/// stamina with Revitalize when that leaves stamina too low to convert.
/// </summary>
public class VitalPlanTests
{
private static readonly VitalSettings Default = new();
private sealed class Character : ICharacterInfo
{
public bool IsInWorld => true;
public uint CurrentHealth { get; init; }
public uint MaxHealth { get; init; } = 100;
public uint CurrentStamina { get; init; }
public uint MaxStamina { get; init; } = 100;
public uint CurrentMana { get; init; }
public uint MaxMana { get; init; } = 100;
public IReadOnlyList<PluginSkillInfo> Skills { get; init; } = [];
public IReadOnlyList<PluginAttributeInfo> Attributes { get; init; } = [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; init; } = [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
skill = default;
return false;
}
}
private static PluginSpellInfo Spell(
uint id, string name, int tier, int difficulty = 50) =>
new(id, name, Family: 89, tier, difficulty, ManaCost: 0, DurationSeconds: 0f,
School: 33u, Description: string.Empty,
IsSelfTargeted: true, IsBeneficial: true);
[Fact]
public void ConvertsStaminaToManaWhenManaIsLowAndStaminaIsAvailable()
{
var character = new Character { CurrentMana = 10, CurrentStamina = 90 };
Assert.Equal(VitalAction.StaminaToMana, VitalPlan.Decide(character, Default));
}
[Fact]
public void RevitalizesWhenBothManaAndStaminaAreLow()
{
// Converting here would drain what little stamina is left, so stamina
// has to come back first.
var character = new Character { CurrentMana = 10, CurrentStamina = 10 };
Assert.Equal(VitalAction.Revitalize, VitalPlan.Decide(character, Default));
}
[Fact]
public void DoesNothingWhenManaIsHealthy()
{
var character = new Character { CurrentMana = 95, CurrentStamina = 20 };
Assert.Equal(VitalAction.None, VitalPlan.Decide(character, Default));
}
[Fact]
public void DoesNothingWhenVitalsAreUnknown()
{
// Unpublished vitals read as zero; treating that as "empty" would cast
// on a character that is perfectly fine.
var character = new Character { MaxMana = 0, MaxStamina = 0 };
Assert.Equal(VitalAction.None, VitalPlan.Decide(character, Default));
}
[Fact]
public void PicksTheStrongestCastableConversionByName()
{
// Family cannot identify these: retail puts Stamina to Health and
// Stamina to Mana in the SAME family, so only the name distinguishes
// them and a family-based pick would convert into the wrong vital.
var known = new[]
{
Spell(1, "Stamina to Mana Self I", tier: 1),
Spell(2, "Stamina to Mana Self VI", tier: 6),
Spell(3, "Stamina to Health Self VII", tier: 7),
};
var levels = new Dictionary<uint, uint> { [33u] = 300 };
Assert.True(VitalPlan.TryFind(
known, VitalPlan.StaminaToManaStem, levels, 10, out PluginSpellInfo pick));
Assert.Equal(2u, pick.SpellId);
}
[Fact]
public void SkipsConversionTiersTheSkillCannotCarry()
{
var known = new[]
{
Spell(1, "Stamina to Mana Self I", tier: 1, difficulty: 50),
Spell(2, "Stamina to Mana Self VII", tier: 7, difficulty: 400),
};
var levels = new Dictionary<uint, uint> { [33u] = 100 };
Assert.True(VitalPlan.TryFind(
known, VitalPlan.StaminaToManaStem, levels, 10, out PluginSpellInfo pick));
Assert.Equal(1u, pick.SpellId);
}
[Fact]
public void ReportsNoConversionWhenTheCharacterKnowsNone()
{
var known = new[] { Spell(1, "Strength Self I", tier: 1) };
Assert.False(VitalPlan.TryFind(
known, VitalPlan.StaminaToManaStem, new Dictionary<uint, uint>(), 10, out _));
}
}