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 click and the /// tick both arrive there — so no locking is used, deliberately: a lock would /// imply a second thread that does not exist. /// internal sealed class MossTankPanel { /// Roughly a retail cast plus windup, so casts do not stack up. private const double CastIntervalSeconds = 3.0; /// Give up on a pass that stops making progress. private const double StallTimeoutSeconds = 25.0; private readonly IPluginHost _host; private readonly BuffSettings _buffSettings = new(); private readonly VitalSettings _vitalSettings = new(); private List _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; /// Bound to the panel's Buff button. public Action Buff => StartOrStop; /// /// Bound to the panel's visible. Keeps the window off the character /// select and login screens, where there is no character to buff. /// public bool IsInWorld => _host.Automation.IsAvailable; public string ButtonText => _running ? "Stop" : "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++; } } 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(); _planIndex = 0; _status = status; } private List BuildPlan(IAutomationSurface automation) { List lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs); return BuffPlan.Build( lines, automation.Character.Skills, automation.Character.Attributes, automation.Character.ActiveEnchantments, _buffSettings); } 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; } _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; } }