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()
{