415 lines
17 KiB
C#
415 lines
17 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;
|
|
|
|
/// <summary>
|
|
/// The retained markup host reads label bindings while drawing. Coverage
|
|
/// walks the complete known-buff table, so it belongs on the update side
|
|
/// and only needs to refresh at human-readable cadence.
|
|
/// </summary>
|
|
private const double CoverageRefreshIntervalSeconds = 1.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.";
|
|
private string _vitals = string.Empty;
|
|
private string _coverage = string.Empty;
|
|
private bool _vitalsInitialized;
|
|
private uint _currentHealth;
|
|
private uint _maxHealth;
|
|
private uint _currentStamina;
|
|
private uint _maxStamina;
|
|
private uint _currentMana;
|
|
private uint _maxMana;
|
|
private IReadOnlyList<PluginSpellInfo>? _coverageSpellSnapshot;
|
|
private int _coverageBuffLineCount;
|
|
private double _coverageRefreshRemaining;
|
|
|
|
/// <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 => _vitals;
|
|
|
|
/// <summary>What a buff pass would cover, named from the retail tables.</summary>
|
|
public string Coverage => _coverage;
|
|
|
|
// ── 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)
|
|
{
|
|
RefreshDisplayBindings(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 void RefreshDisplayBindings(double elapsedSeconds)
|
|
{
|
|
IAutomationSurface automation = _host.Automation;
|
|
if (!automation.IsAvailable)
|
|
{
|
|
_vitals = string.Empty;
|
|
_coverage = string.Empty;
|
|
_vitalsInitialized = false;
|
|
_coverageSpellSnapshot = null;
|
|
_coverageBuffLineCount = 0;
|
|
_coverageRefreshRemaining = 0.0;
|
|
return;
|
|
}
|
|
|
|
ICharacterInfo character = automation.Character;
|
|
uint currentHealth = character.CurrentHealth;
|
|
uint maxHealth = character.MaxHealth;
|
|
uint currentStamina = character.CurrentStamina;
|
|
uint maxStamina = character.MaxStamina;
|
|
uint currentMana = character.CurrentMana;
|
|
uint maxMana = character.MaxMana;
|
|
if (!_vitalsInitialized
|
|
|| currentHealth != _currentHealth
|
|
|| maxHealth != _maxHealth
|
|
|| currentStamina != _currentStamina
|
|
|| maxStamina != _maxStamina
|
|
|| currentMana != _currentMana
|
|
|| maxMana != _maxMana)
|
|
{
|
|
_currentHealth = currentHealth;
|
|
_maxHealth = maxHealth;
|
|
_currentStamina = currentStamina;
|
|
_maxStamina = maxStamina;
|
|
_currentMana = currentMana;
|
|
_maxMana = maxMana;
|
|
_vitals = $"Health {currentHealth}/{maxHealth}"
|
|
+ $" Stam {currentStamina}/{maxStamina}"
|
|
+ $" Mana {currentMana}/{maxMana}";
|
|
_vitalsInitialized = true;
|
|
}
|
|
|
|
IReadOnlyList<PluginSpellInfo> spells = automation.Spells.KnownSelfBuffs;
|
|
bool spellbookChanged = !ReferenceEquals(spells, _coverageSpellSnapshot);
|
|
_coverageRefreshRemaining -= Math.Max(0.0, elapsedSeconds);
|
|
if (!spellbookChanged && _coverageRefreshRemaining > 0.0)
|
|
return;
|
|
|
|
if (spellbookChanged)
|
|
{
|
|
_coverageSpellSnapshot = spells;
|
|
_coverageBuffLineCount = BuffProfile.Build(spells).Count;
|
|
}
|
|
|
|
int trained = 0;
|
|
foreach (PluginSkillInfo skill in character.Skills)
|
|
{
|
|
if (skill.Training is PluginSkillTraining.Trained
|
|
or PluginSkillTraining.Specialized)
|
|
{
|
|
trained++;
|
|
}
|
|
}
|
|
_coverage = $"{character.Attributes.Count} attributes, "
|
|
+ $"{trained} trained skills, "
|
|
+ $"{_coverageBuffLineCount} buff lines";
|
|
_coverageRefreshRemaining = CoverageRefreshIntervalSeconds;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|