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>
242 lines
7.9 KiB
C#
242 lines
7.9 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank;
|
|
|
|
/// <summary>
|
|
/// The panel's binding object and the buff loop's state machine.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Everything here runs on the host's update thread — the button click and the
|
|
/// tick both arrive there — so no locking is used, deliberately: a lock would
|
|
/// imply a second thread that does not exist.
|
|
/// </remarks>
|
|
internal sealed class MossTankPanel
|
|
{
|
|
/// <summary>Roughly a retail cast plus windup, so casts do not stack up.</summary>
|
|
private const double CastIntervalSeconds = 3.0;
|
|
|
|
/// <summary>Give up on a pass that stops making progress.</summary>
|
|
private const double StallTimeoutSeconds = 25.0;
|
|
|
|
private readonly IPluginHost _host;
|
|
private readonly BuffSettings _buffSettings = new();
|
|
private readonly VitalSettings _vitalSettings = new();
|
|
|
|
private List<PluginSpellInfo> _plan = new();
|
|
private int _planIndex;
|
|
private bool _running;
|
|
private double _sinceLastCast;
|
|
private double _sinceProgress;
|
|
private int _castThisPass;
|
|
private string _status = "Idle.";
|
|
|
|
public MossTankPanel(IPluginHost host) => _host = host;
|
|
|
|
/// <summary>Bound to the panel's Buff button.</summary>
|
|
public Action Buff => StartOrStop;
|
|
|
|
/// <summary>
|
|
/// Bound to the panel's <c>visible</c>. Keeps the window off the character
|
|
/// select and login screens, where there is no character to buff.
|
|
/// </summary>
|
|
public bool IsInWorld => _host.Automation.IsAvailable;
|
|
|
|
public string ButtonText => _running ? "Stop" : "Buff";
|
|
|
|
public string Status => _status;
|
|
|
|
/// <summary>Vitals line, using the same numbers the character panel shows.</summary>
|
|
public string Vitals
|
|
{
|
|
get
|
|
{
|
|
ICharacterInfo character = _host.Automation.Character;
|
|
if (!_host.Automation.IsAvailable)
|
|
return string.Empty;
|
|
return $"Health {character.CurrentHealth}/{character.MaxHealth}"
|
|
+ $" Stam {character.CurrentStamina}/{character.MaxStamina}"
|
|
+ $" Mana {character.CurrentMana}/{character.MaxMana}";
|
|
}
|
|
}
|
|
|
|
/// <summary>What a buff pass would cover, named from the retail tables.</summary>
|
|
public string Coverage
|
|
{
|
|
get
|
|
{
|
|
IAutomationSurface automation = _host.Automation;
|
|
if (!automation.IsAvailable)
|
|
return string.Empty;
|
|
|
|
int trained = 0;
|
|
foreach (PluginSkillInfo skill in automation.Character.Skills)
|
|
{
|
|
if (skill.Training is PluginSkillTraining.Trained
|
|
or PluginSkillTraining.Specialized)
|
|
{
|
|
trained++;
|
|
}
|
|
}
|
|
int attributes = automation.Character.Attributes.Count;
|
|
int lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs).Count;
|
|
return $"{attributes} attributes, {trained} trained skills, {lines} buff lines known";
|
|
}
|
|
}
|
|
|
|
private void StartOrStop()
|
|
{
|
|
// Logged unconditionally: "nothing happened when I clicked" is
|
|
// ambiguous between the click never arriving and the click arriving and
|
|
// declining to act. This line separates those two without guesswork.
|
|
_host.Log.Info(
|
|
$"MossTank: Buff clicked (running={_running}, inWorld={_host.Automation.IsAvailable})");
|
|
|
|
if (_running)
|
|
{
|
|
Stop("Stopped.");
|
|
return;
|
|
}
|
|
|
|
IAutomationSurface automation = _host.Automation;
|
|
if (!automation.IsAvailable)
|
|
{
|
|
_status = "Not in world.";
|
|
return;
|
|
}
|
|
|
|
_plan = BuildPlan(automation);
|
|
_planIndex = 0;
|
|
_castThisPass = 0;
|
|
_sinceLastCast = CastIntervalSeconds; // cast the first one immediately
|
|
_sinceProgress = 0;
|
|
_running = true;
|
|
_status = _plan.Count == 0
|
|
? "Checking…"
|
|
: $"Buffing 0/{_plan.Count}…";
|
|
_host.Log.Info($"MossTank: pass started, {_plan.Count} buff(s) queued");
|
|
}
|
|
|
|
private void Stop(string status)
|
|
{
|
|
_running = false;
|
|
_plan = new List<PluginSpellInfo>();
|
|
_planIndex = 0;
|
|
_status = status;
|
|
}
|
|
|
|
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation)
|
|
{
|
|
List<BuffLine> lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs);
|
|
return BuffPlan.Build(
|
|
lines,
|
|
automation.Character.Skills,
|
|
automation.Character.Attributes,
|
|
automation.Character.ActiveEnchantments,
|
|
_buffSettings);
|
|
}
|
|
|
|
private Dictionary<uint, uint> SkillLevels(IAutomationSurface automation)
|
|
{
|
|
var levels = new Dictionary<uint, uint>();
|
|
foreach (PluginSkillInfo skill in automation.Character.Skills)
|
|
levels[skill.SkillId] = skill.Current;
|
|
return levels;
|
|
}
|
|
|
|
/// <summary>Driven by <see cref="IEvents.Tick"/> on the host update thread.</summary>
|
|
public void OnTick(double elapsedSeconds)
|
|
{
|
|
if (!_running)
|
|
return;
|
|
|
|
IAutomationSurface automation = _host.Automation;
|
|
if (!automation.IsAvailable)
|
|
{
|
|
Stop("Lost the session.");
|
|
return;
|
|
}
|
|
|
|
_sinceLastCast += elapsedSeconds;
|
|
_sinceProgress += elapsedSeconds;
|
|
|
|
if (_sinceProgress > StallTimeoutSeconds)
|
|
{
|
|
Stop($"Stalled after {_castThisPass} cast(s).");
|
|
_host.Log.Warn("MossTank: pass stalled; stopping");
|
|
return;
|
|
}
|
|
|
|
if (_sinceLastCast < CastIntervalSeconds || automation.Magic.IsCasting)
|
|
return;
|
|
|
|
// Vitals come first: a buff pass that runs itself out of mana and keeps
|
|
// trying is worse than one that pauses to convert stamina.
|
|
if (TryVitalUpkeep(automation))
|
|
return;
|
|
|
|
// Re-derive against current enchantments so anything that landed since
|
|
// the pass began drops out rather than being cast twice.
|
|
_plan = BuildPlan(automation);
|
|
|
|
if (_plan.Count == 0)
|
|
{
|
|
Stop($"Done — {_castThisPass} cast(s).");
|
|
_host.Log.Info($"MossTank: pass complete ({_castThisPass} cast)");
|
|
return;
|
|
}
|
|
|
|
if (_planIndex >= _plan.Count)
|
|
_planIndex = 0;
|
|
|
|
PluginSpellInfo next = _plan[_planIndex];
|
|
if (!TryCast(automation, next, $"Buffing {_castThisPass + 1}/{_plan.Count}"))
|
|
_planIndex++;
|
|
}
|
|
|
|
private bool TryVitalUpkeep(IAutomationSurface automation)
|
|
{
|
|
VitalAction action = VitalPlan.Decide(automation.Character, _vitalSettings);
|
|
if (action == VitalAction.None)
|
|
return false;
|
|
|
|
string stem = action == VitalAction.StaminaToMana
|
|
? VitalPlan.StaminaToManaStem
|
|
: VitalPlan.RevitalizeStem;
|
|
|
|
if (!VitalPlan.TryFind(
|
|
automation.Spells.KnownSelfBuffs, stem, SkillLevels(automation),
|
|
_buffSettings.SkillExcessOverDifficulty, out PluginSpellInfo spell))
|
|
{
|
|
// Not knowing the conversion is not an error — plenty of characters
|
|
// do not have it. Fall through to buffing rather than stalling.
|
|
return false;
|
|
}
|
|
|
|
return TryCast(automation, spell, action.ToString());
|
|
}
|
|
|
|
private bool TryCast(
|
|
IAutomationSurface automation, PluginSpellInfo spell, string label)
|
|
{
|
|
PluginCastGate gate = automation.Magic.EvaluateGate(spell.SpellId);
|
|
if (gate != PluginCastGate.Ready)
|
|
{
|
|
_status = $"{spell.Name}: {gate}";
|
|
return false;
|
|
}
|
|
|
|
if (!automation.Magic.Cast(spell.SpellId))
|
|
{
|
|
_status = $"Refused {spell.Name}.";
|
|
return false;
|
|
}
|
|
|
|
_castThisPass++;
|
|
_sinceLastCast = 0;
|
|
_sinceProgress = 0;
|
|
_planIndex = 0;
|
|
_status = $"{label}: {spell.Name}";
|
|
_host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})");
|
|
return true;
|
|
}
|
|
}
|