diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs index dc3067be..fa8c7604 100644 --- a/src/AcDream.Plugins.MossTank/BuffPlan.cs +++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs @@ -38,12 +38,18 @@ public sealed class BuffSettings /// public static class BuffPlan { + /// + /// Virindi Tank's Force Buff: queue every wanted line at its best castable + /// tier, ignoring what is already in force and how long it has left. The + /// ordinary path skips buffs that are already covered; forcing does not. + /// public static List Build( IReadOnlyList lines, IReadOnlyList skills, IReadOnlyList attributes, IReadOnlyList active, - BuffSettings settings) + BuffSettings settings, + bool force = false) { var trainedSkills = new Dictionary( StringComparer.OrdinalIgnoreCase); @@ -96,7 +102,8 @@ public static class BuffPlan if (!TryPickTier(line, skillLevels, settings, out PluginSpellInfo pick)) continue; - if (inForce.TryGetValue(line.Family, out var held) + if (!force + && inForce.TryGetValue(line.Family, out var held) && held.Tier >= pick.Tier && held.Seconds >= settings.RebuffWhenUnderSeconds) { diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 605549e5..95e71546 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -24,8 +24,12 @@ internal sealed class MossTankPanel private readonly BuffSettings _buffSettings = new(); private readonly VitalSettings _vitalSettings = new(); - private List _plan = new(); - private int _planIndex; + // 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; @@ -44,7 +48,8 @@ internal sealed class MossTankPanel public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen; public bool SettingsVisible => _host.Automation.IsAvailable && SettingsOpen; - public string ButtonText => _running ? "Stop" : "Buff"; + /// 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. @@ -168,33 +173,38 @@ internal sealed class MossTankPanel return; } - _plan = BuildPlan(automation); - _planIndex = 0; + // 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; - _running = true; - _status = _plan.Count == 0 ? "Checking…" : $"Buffing 0/{_plan.Count}…"; - _host.Log.Info($"MossTank: pass started, {_plan.Count} buff(s) queued"); - Announce(_plan.Count == 0 - ? "Buffing — checking what needs recasting." - : $"Buffing — {_plan.Count} spell(s) to cast."); + _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; - _plan = new List(); - _planIndex = 0; + _queue = new List(); + _queueIndex = 0; _status = status; } - private List BuildPlan(IAutomationSurface automation) => + private List BuildPlan(IAutomationSurface automation, bool force) => BuffPlan.Build( BuffProfile.Build(automation.Spells.KnownSelfBuffs), automation.Character.Skills, automation.Character.Attributes, automation.Character.ActiveEnchantments, - _buffSettings); + _buffSettings, + force); private Dictionary SkillLevels(IAutomationSurface automation) { @@ -235,26 +245,20 @@ internal sealed class MossTankPanel 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) + if (_queueIndex >= _queue.Count) { Stop($"Done — {_castThisPass} cast(s)."); - _host.Log.Info($"MossTank: pass complete ({_castThisPass} cast)"); - Announce(_castThisPass == 0 - ? "Already fully buffed." - : $"Finished — {_castThisPass} spell(s) cast."); + _host.Log.Info($"MossTank: force pass complete ({_castThisPass} cast)"); + Announce($"Finished — {_castThisPass} spell(s) cast."); return; } - if (_planIndex >= _plan.Count) - _planIndex = 0; - - PluginSpellInfo next = _plan[_planIndex]; - if (!TryCast(automation, next, $"Buffing {_castThisPass + 1}/{_plan.Count}")) - _planIndex++; + 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) @@ -297,7 +301,6 @@ internal sealed class MossTankPanel _castThisPass++; _sinceProgress = 0; - _planIndex = 0; _status = $"{label}: {spell.Name}"; _host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})"); return true; diff --git a/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs b/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs index 5329095b..ef1889ac 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/BuffPlanTests.cs @@ -202,6 +202,40 @@ public class BuffPlanTests Assert.Equal(new uint[] { 2, 3, 1 }, plan.Select(s => s.SpellId).ToArray()); } + [Fact] + public void ForceQueuesBuffsThatAreAlreadyInForce() + { + // Virindi Tank's Force Buff recasts everything rather than only what + // has lapsed, which is what the Buff button does. + var lines = Lines( + Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.", + difficulty: 100, school: LifeMagicSkill)); + var skills = new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) }; + var active = new[] { new PluginActiveEnchantment(1, 47, 4, 1800) }; + + Assert.Empty(BuffPlan.Build(lines, skills, Array.Empty(), + active, Default)); + Assert.Single(BuffPlan.Build(lines, skills, Array.Empty(), + active, Default, force: true)); + } + + [Fact] + public void ForceStillRespectsSkillAndTrainingFilters() + { + // Forcing means "ignore what is already up", not "ignore the settings". + var lines = Lines( + Spell(1, 71, 1, "Increases the caster's Leadership skill by 10 points.")); + + var plan = BuffPlan.Build( + lines, + new[] { Skill(35, "Leadership", PluginSkillTraining.Untrained) }, + Array.Empty(), + Array.Empty(), + Default, force: true); + + Assert.Empty(plan); + } + [Fact] public void EmptySpellbookProducesNoPlan() {