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>
358 lines
15 KiB
C#
358 lines
15 KiB
C#
using System.Globalization;
|
|
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 clicks and the
|
|
/// tick all 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>
|
|
/// Give up on a pass that stops making progress. Generous, because the
|
|
/// pacing is now the server acknowledging each cast rather than a fixed
|
|
/// delay, and a slow server should not read as a stall.
|
|
/// </summary>
|
|
private const double StallTimeoutSeconds = 30.0;
|
|
|
|
private readonly IPluginHost _host;
|
|
private readonly BuffSettings _buffSettings = new();
|
|
private readonly VitalSettings _vitalSettings = new();
|
|
|
|
// A pass works through a queue captured at the start rather than a plan
|
|
// re-derived each tick. Force Buff deliberately ignores what is already in
|
|
// force, so a re-derived plan would never shrink and the pass could never
|
|
// reach "done" -- it would cast forever.
|
|
private List<PluginSpellInfo> _queue = new();
|
|
private int _queueIndex;
|
|
private bool _running;
|
|
private double _sinceProgress;
|
|
private int _castThisPass;
|
|
private string _status = "Idle.";
|
|
|
|
/// <summary>
|
|
/// What the player had selected before the pass, so targeting yourself for
|
|
/// banes does not quietly steal the selection and leave it changed.
|
|
/// </summary>
|
|
private uint? _selectionBeforePass;
|
|
|
|
public MossTankPanel(IPluginHost host) => _host = host;
|
|
|
|
// ── main panel bindings ───────────────────────────────────────────────
|
|
public Action Buff => StartOrStop;
|
|
public Action OpenSettings => () => SettingsOpen = true;
|
|
public Action CloseSettings => () => SettingsOpen = false;
|
|
|
|
public bool SettingsOpen { get; private set; }
|
|
|
|
/// <summary>Keeps the windows off the character-select and login screens.</summary>
|
|
public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen;
|
|
public bool SettingsVisible => _host.Automation.IsAvailable && SettingsOpen;
|
|
|
|
/// <summary>The button is Force Buff; while a pass runs it cancels.</summary>
|
|
public string ButtonText => _running ? "Stop" : "Force 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++;
|
|
}
|
|
}
|
|
return $"{automation.Character.Attributes.Count} attributes, "
|
|
+ $"{trained} trained skills, "
|
|
+ $"{BuffProfile.Build(automation.Spells.KnownSelfBuffs).Count} buff lines";
|
|
}
|
|
}
|
|
|
|
// ── settings bindings ─────────────────────────────────────────────────
|
|
// Adjuster buttons rather than typed entry: buttons are a proven primitive
|
|
// in plugin markup, whereas an editable field would need keyboard routing
|
|
// plumbed through to plugin panels first.
|
|
|
|
/// <summary>
|
|
/// VTank's <c>SpellDiffExcessThreshold-Buff</c>. Signed on purpose — the
|
|
/// wiki is explicit that "a positive number raises the skill necessary to
|
|
/// cast spells, a negative number lowers it", so a lower-level character
|
|
/// can reach for higher tiers by going negative.
|
|
/// </summary>
|
|
public string DifficultyText =>
|
|
$"Spell difficulty margin: {_buffSettings.SkillExcessOverDifficulty:+0;-0;0}";
|
|
|
|
public string RebuffText =>
|
|
$"Rebuff when under: {_buffSettings.RebuffWhenUnderSeconds / 60.0:0.#} min";
|
|
|
|
public string ManaFloorText => $"Convert below mana: {Percent(_vitalSettings.ManaFloor)}";
|
|
public string ManaTargetText => $"Stop converting at: {Percent(_vitalSettings.ManaTarget)}";
|
|
public string StaminaFloorText => $"Keep stamina above: {Percent(_vitalSettings.StaminaFloor)}";
|
|
public string VitalUpkeepText =>
|
|
$"Stamina to Mana / Revitalize: {OnOff(_vitalSettings.Enabled)}";
|
|
public string TrainedOnlyText =>
|
|
$"Trained skills only: {OnOff(_buffSettings.BuffTrainedSkillsOnly)}";
|
|
public string AttributesText =>
|
|
$"Buff attributes: {OnOff(_buffSettings.BuffAttributes)}";
|
|
public string ProtectionsText =>
|
|
$"Buff protections: {OnOff(_buffSettings.BuffProtections)}";
|
|
public string AurasText =>
|
|
$"Buff weapon auras: {OnOff(_buffSettings.BuffAuras)}";
|
|
public string BanesText =>
|
|
$"Buff banes (armor): {OnOff(_buffSettings.BuffBanes)}";
|
|
public string RegenerationText =>
|
|
$"Buff regen rates: {OnOff(_buffSettings.BuffRegeneration)}";
|
|
public string OtherText =>
|
|
$"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}";
|
|
|
|
public Action DifficultyDown => () => _buffSettings.SkillExcessOverDifficulty =
|
|
Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5);
|
|
public Action DifficultyUp => () => _buffSettings.SkillExcessOverDifficulty =
|
|
Math.Min(100, _buffSettings.SkillExcessOverDifficulty + 5);
|
|
|
|
public Action RebuffDown => () => _buffSettings.RebuffWhenUnderSeconds =
|
|
Math.Max(30, _buffSettings.RebuffWhenUnderSeconds - 30);
|
|
public Action RebuffUp => () => _buffSettings.RebuffWhenUnderSeconds =
|
|
Math.Min(1800, _buffSettings.RebuffWhenUnderSeconds + 30);
|
|
|
|
public Action ManaFloorDown => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, -1);
|
|
public Action ManaFloorUp => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, +1);
|
|
|
|
public Action ManaTargetDown => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, -1);
|
|
public Action ManaTargetUp => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, +1);
|
|
|
|
public Action StaminaFloorDown => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, -1);
|
|
public Action StaminaFloorUp => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, +1);
|
|
|
|
public Action ToggleVitalUpkeep => () => _vitalSettings.Enabled = !_vitalSettings.Enabled;
|
|
public Action ToggleTrainedOnly => () =>
|
|
_buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly;
|
|
public Action ToggleAttributes => () =>
|
|
_buffSettings.BuffAttributes = !_buffSettings.BuffAttributes;
|
|
public Action ToggleProtections => () =>
|
|
_buffSettings.BuffProtections = !_buffSettings.BuffProtections;
|
|
public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras;
|
|
public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes;
|
|
public Action ToggleRegeneration =>
|
|
() => _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration;
|
|
public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther;
|
|
|
|
private static double Step(double value, int direction) =>
|
|
Math.Clamp(Math.Round(value + direction * 0.05, 2), 0.0, 1.0);
|
|
|
|
private static string Percent(double fraction) =>
|
|
(fraction * 100).ToString("0", CultureInfo.InvariantCulture) + "%";
|
|
|
|
private static string OnOff(bool value) => value ? "on" : "off";
|
|
|
|
// ── the loop ──────────────────────────────────────────────────────────
|
|
private void Announce(string text) =>
|
|
_host.Automation.Chat.PostSystemMessage($"[MossTank] {text}");
|
|
|
|
private void StartOrStop()
|
|
{
|
|
_host.Log.Info(
|
|
$"MossTank: Buff clicked (running={_running}, inWorld={_host.Automation.IsAvailable})");
|
|
|
|
if (_running)
|
|
{
|
|
Stop("Stopped.");
|
|
Announce("Stopped.");
|
|
return;
|
|
}
|
|
|
|
IAutomationSurface automation = _host.Automation;
|
|
if (!automation.IsAvailable)
|
|
{
|
|
_status = "Not in world.";
|
|
return;
|
|
}
|
|
|
|
// Always a force pass: the button is Virindi Tank's Force Buff, which
|
|
// recasts everything rather than only what has lapsed.
|
|
_queue = BuildPlan(automation, force: true);
|
|
_queueIndex = 0;
|
|
_castThisPass = 0;
|
|
_sinceProgress = 0;
|
|
_selectionBeforePass = _host.Selection.SelectedObjectId;
|
|
_running = _queue.Count > 0;
|
|
_status = _queue.Count == 0
|
|
? "Nothing to buff."
|
|
: $"Force buffing 0/{_queue.Count}…";
|
|
_host.Log.Info($"MossTank: force pass started, {_queue.Count} buff(s) queued");
|
|
Announce(_queue.Count == 0
|
|
? "Nothing to buff — no known self-buffs match your skills."
|
|
: $"Force buffing — {_queue.Count} spell(s).");
|
|
}
|
|
|
|
private void Stop(string status)
|
|
{
|
|
_running = false;
|
|
_queue = new List<PluginSpellInfo>();
|
|
_queueIndex = 0;
|
|
_status = status;
|
|
RestoreSelection();
|
|
}
|
|
|
|
private void RestoreSelection()
|
|
{
|
|
if (_selectionBeforePass is { } previous && previous != 0)
|
|
_host.Selection.Select(previous);
|
|
else
|
|
_host.Selection.Clear();
|
|
_selectionBeforePass = null;
|
|
}
|
|
|
|
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation, bool force) =>
|
|
BuffPlan.Build(
|
|
BuffProfile.Build(automation.Spells.KnownSelfBuffs),
|
|
automation.Character.Skills,
|
|
automation.Character.Attributes,
|
|
automation.Character.ActiveEnchantments,
|
|
_buffSettings,
|
|
force);
|
|
|
|
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;
|
|
}
|
|
|
|
_sinceProgress += elapsedSeconds;
|
|
if (_sinceProgress > StallTimeoutSeconds)
|
|
{
|
|
Stop($"Stalled after {_castThisPass} cast(s).");
|
|
_host.Log.Warn("MossTank: pass stalled; stopping");
|
|
Announce($"Stopped — no progress after {_castThisPass} cast(s).");
|
|
return;
|
|
}
|
|
|
|
// The server is the only throttle: cast the moment the previous action
|
|
// is acknowledged. The fixed delay this replaces was standing in for a
|
|
// busy signal we now have, and it only made the pass slow.
|
|
if (automation.Magic.IsCasting)
|
|
return;
|
|
|
|
if (TryVitalUpkeep(automation))
|
|
return;
|
|
|
|
if (_queueIndex >= _queue.Count)
|
|
{
|
|
Stop($"Done — {_castThisPass} cast(s).");
|
|
_host.Log.Info($"MossTank: force pass complete ({_castThisPass} cast)");
|
|
Announce($"Finished — {_castThisPass} spell(s) cast.");
|
|
return;
|
|
}
|
|
|
|
PluginSpellInfo next = _queue[_queueIndex];
|
|
// Advance on both outcomes. A spell that will not go now (missing
|
|
// components, a gate that stays shut) must not block the rest of the
|
|
// queue behind it; the status line names why it was skipped.
|
|
TryCast(automation, next, $"Force buffing {_castThisPass + 1}/{_queue.Count}");
|
|
_queueIndex++;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// A spell without the self-targeted flag still needs a target, and for
|
|
// a bane that target is the player: retail's text is "Target yourself
|
|
// to cast this spell on all of your equipped armor". Select first, or
|
|
// the gate refuses for want of a target.
|
|
if (!spell.IsSelfTargeted)
|
|
{
|
|
uint self = automation.Character.ObjectId;
|
|
if (self == 0)
|
|
{
|
|
_status = $"{spell.Name}: no self target";
|
|
return false;
|
|
}
|
|
if (_host.Selection.SelectedObjectId != self)
|
|
_host.Selection.Select(self);
|
|
}
|
|
|
|
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++;
|
|
_sinceProgress = 0;
|
|
_status = $"{label}: {spell.Name}";
|
|
_host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})");
|
|
return true;
|
|
}
|
|
}
|