using System.Globalization; using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// /// The panel's binding object and the buff loop's state machine. /// /// /// 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. /// internal sealed class MossTankPanel { /// /// 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. /// 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 _queue = new(); private int _queueIndex; private bool _running; private double _sinceProgress; private int _castThisPass; private string _status = "Idle."; /// /// What the player had selected before the pass, so targeting yourself for /// banes does not quietly steal the selection and leave it changed. /// 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; } /// Keeps the windows off the character-select and login screens. public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen; public bool SettingsVisible => _host.Automation.IsAvailable && SettingsOpen; /// The button is Force Buff; while a pass runs it cancels. public string ButtonText => _running ? "Stop" : "Force Buff"; public string Status => _status; /// Vitals line, using the same numbers the character panel shows. 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}"; } } /// What a buff pass would cover, named from the retail tables. 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. /// /// VTank's SpellDiffExcessThreshold-Buff. 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. /// 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(); _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 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 SkillLevels(IAutomationSurface automation) { var levels = new Dictionary(); foreach (PluginSkillInfo skill in automation.Character.Skills) levels[skill.SkillId] = skill.Current; return levels; } /// Driven by on the host update thread. 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; } }