feat(mosstank): the button is Force Buff — always recast everything

Virindi Tank's own term: Force Buff recasts the lot rather than only what has
lapsed. The button now does that, and says so.

BuffPlan gained a force flag that skips the already-in-force check. Forcing
means "ignore what is already up", NOT "ignore the settings" -- the trained-
skill filter, the attribute toggle and the difficulty margin all still apply,
and there is a test pinning that.

The loop had to change shape for this. It used to re-derive the plan every
tick and treat "plan is empty" as done, which works only because the ordinary
plan shrinks as buffs land. A forced plan never shrinks -- that is the point --
so the same loop would have cast forever. A pass now captures a queue at the
start and works through it by index, which is also cheaper: no rebuilding 80-odd
buff lines every frame.

A spell that will not go now advances the queue rather than blocking it. One
missing component used to mean everything behind it waited for the stall
timeout; now the status line names the refusal and the pass carries on.

Solution builds clean; 14,440 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 19:17:32 +02:00
parent 54005a864c
commit 5ca4a63272
3 changed files with 77 additions and 33 deletions

View file

@ -38,12 +38,18 @@ public sealed class BuffSettings
/// </remarks>
public static class BuffPlan
{
/// <param name="force">
/// 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.
/// </param>
public static List<PluginSpellInfo> Build(
IReadOnlyList<BuffLine> lines,
IReadOnlyList<PluginSkillInfo> skills,
IReadOnlyList<PluginAttributeInfo> attributes,
IReadOnlyList<PluginActiveEnchantment> active,
BuffSettings settings)
BuffSettings settings,
bool force = false)
{
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
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)
{

View file

@ -24,8 +24,12 @@ internal sealed class MossTankPanel
private readonly BuffSettings _buffSettings = new();
private readonly VitalSettings _vitalSettings = new();
private List<PluginSpellInfo> _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<PluginSpellInfo> _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";
/// <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>
@ -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<PluginSpellInfo>();
_planIndex = 0;
_queue = new List<PluginSpellInfo>();
_queueIndex = 0;
_status = status;
}
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation) =>
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);
_buffSettings,
force);
private Dictionary<uint, uint> 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;

View file

@ -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<PluginAttributeInfo>(),
active, Default));
Assert.Single(BuffPlan.Build(lines, skills, Array.Empty<PluginAttributeInfo>(),
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<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Default, force: true);
Assert.Empty(plan);
}
[Fact]
public void EmptySpellbookProducesNoPlan()
{