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:
parent
54005a864c
commit
5ca4a63272
3 changed files with 77 additions and 33 deletions
|
|
@ -38,12 +38,18 @@ public sealed class BuffSettings
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class BuffPlan
|
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(
|
public static List<PluginSpellInfo> Build(
|
||||||
IReadOnlyList<BuffLine> lines,
|
IReadOnlyList<BuffLine> lines,
|
||||||
IReadOnlyList<PluginSkillInfo> skills,
|
IReadOnlyList<PluginSkillInfo> skills,
|
||||||
IReadOnlyList<PluginAttributeInfo> attributes,
|
IReadOnlyList<PluginAttributeInfo> attributes,
|
||||||
IReadOnlyList<PluginActiveEnchantment> active,
|
IReadOnlyList<PluginActiveEnchantment> active,
|
||||||
BuffSettings settings)
|
BuffSettings settings,
|
||||||
|
bool force = false)
|
||||||
{
|
{
|
||||||
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
|
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
@ -96,7 +102,8 @@ public static class BuffPlan
|
||||||
if (!TryPickTier(line, skillLevels, settings, out PluginSpellInfo pick))
|
if (!TryPickTier(line, skillLevels, settings, out PluginSpellInfo pick))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (inForce.TryGetValue(line.Family, out var held)
|
if (!force
|
||||||
|
&& inForce.TryGetValue(line.Family, out var held)
|
||||||
&& held.Tier >= pick.Tier
|
&& held.Tier >= pick.Tier
|
||||||
&& held.Seconds >= settings.RebuffWhenUnderSeconds)
|
&& held.Seconds >= settings.RebuffWhenUnderSeconds)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,12 @@ internal sealed class MossTankPanel
|
||||||
private readonly BuffSettings _buffSettings = new();
|
private readonly BuffSettings _buffSettings = new();
|
||||||
private readonly VitalSettings _vitalSettings = new();
|
private readonly VitalSettings _vitalSettings = new();
|
||||||
|
|
||||||
private List<PluginSpellInfo> _plan = new();
|
// A pass works through a queue captured at the start rather than a plan
|
||||||
private int _planIndex;
|
// 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 bool _running;
|
||||||
private double _sinceProgress;
|
private double _sinceProgress;
|
||||||
private int _castThisPass;
|
private int _castThisPass;
|
||||||
|
|
@ -44,7 +48,8 @@ internal sealed class MossTankPanel
|
||||||
public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen;
|
public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen;
|
||||||
public bool SettingsVisible => _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;
|
public string Status => _status;
|
||||||
|
|
||||||
/// <summary>Vitals line, using the same numbers the character panel shows.</summary>
|
/// <summary>Vitals line, using the same numbers the character panel shows.</summary>
|
||||||
|
|
@ -168,33 +173,38 @@ internal sealed class MossTankPanel
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_plan = BuildPlan(automation);
|
// Always a force pass: the button is Virindi Tank's Force Buff, which
|
||||||
_planIndex = 0;
|
// recasts everything rather than only what has lapsed.
|
||||||
|
_queue = BuildPlan(automation, force: true);
|
||||||
|
_queueIndex = 0;
|
||||||
_castThisPass = 0;
|
_castThisPass = 0;
|
||||||
_sinceProgress = 0;
|
_sinceProgress = 0;
|
||||||
_running = true;
|
_running = _queue.Count > 0;
|
||||||
_status = _plan.Count == 0 ? "Checking…" : $"Buffing 0/{_plan.Count}…";
|
_status = _queue.Count == 0
|
||||||
_host.Log.Info($"MossTank: pass started, {_plan.Count} buff(s) queued");
|
? "Nothing to buff."
|
||||||
Announce(_plan.Count == 0
|
: $"Force buffing 0/{_queue.Count}…";
|
||||||
? "Buffing — checking what needs recasting."
|
_host.Log.Info($"MossTank: force pass started, {_queue.Count} buff(s) queued");
|
||||||
: $"Buffing — {_plan.Count} spell(s) to cast.");
|
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)
|
private void Stop(string status)
|
||||||
{
|
{
|
||||||
_running = false;
|
_running = false;
|
||||||
_plan = new List<PluginSpellInfo>();
|
_queue = new List<PluginSpellInfo>();
|
||||||
_planIndex = 0;
|
_queueIndex = 0;
|
||||||
_status = status;
|
_status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation) =>
|
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation, bool force) =>
|
||||||
BuffPlan.Build(
|
BuffPlan.Build(
|
||||||
BuffProfile.Build(automation.Spells.KnownSelfBuffs),
|
BuffProfile.Build(automation.Spells.KnownSelfBuffs),
|
||||||
automation.Character.Skills,
|
automation.Character.Skills,
|
||||||
automation.Character.Attributes,
|
automation.Character.Attributes,
|
||||||
automation.Character.ActiveEnchantments,
|
automation.Character.ActiveEnchantments,
|
||||||
_buffSettings);
|
_buffSettings,
|
||||||
|
force);
|
||||||
|
|
||||||
private Dictionary<uint, uint> SkillLevels(IAutomationSurface automation)
|
private Dictionary<uint, uint> SkillLevels(IAutomationSurface automation)
|
||||||
{
|
{
|
||||||
|
|
@ -235,26 +245,20 @@ internal sealed class MossTankPanel
|
||||||
if (TryVitalUpkeep(automation))
|
if (TryVitalUpkeep(automation))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Re-derive against current enchantments so anything that landed since
|
if (_queueIndex >= _queue.Count)
|
||||||
// the pass began drops out rather than being cast twice.
|
|
||||||
_plan = BuildPlan(automation);
|
|
||||||
|
|
||||||
if (_plan.Count == 0)
|
|
||||||
{
|
{
|
||||||
Stop($"Done — {_castThisPass} cast(s).");
|
Stop($"Done — {_castThisPass} cast(s).");
|
||||||
_host.Log.Info($"MossTank: pass complete ({_castThisPass} cast)");
|
_host.Log.Info($"MossTank: force pass complete ({_castThisPass} cast)");
|
||||||
Announce(_castThisPass == 0
|
Announce($"Finished — {_castThisPass} spell(s) cast.");
|
||||||
? "Already fully buffed."
|
|
||||||
: $"Finished — {_castThisPass} spell(s) cast.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_planIndex >= _plan.Count)
|
PluginSpellInfo next = _queue[_queueIndex];
|
||||||
_planIndex = 0;
|
// 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
|
||||||
PluginSpellInfo next = _plan[_planIndex];
|
// queue behind it; the status line names why it was skipped.
|
||||||
if (!TryCast(automation, next, $"Buffing {_castThisPass + 1}/{_plan.Count}"))
|
TryCast(automation, next, $"Force buffing {_castThisPass + 1}/{_queue.Count}");
|
||||||
_planIndex++;
|
_queueIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryVitalUpkeep(IAutomationSurface automation)
|
private bool TryVitalUpkeep(IAutomationSurface automation)
|
||||||
|
|
@ -297,7 +301,6 @@ internal sealed class MossTankPanel
|
||||||
|
|
||||||
_castThisPass++;
|
_castThisPass++;
|
||||||
_sinceProgress = 0;
|
_sinceProgress = 0;
|
||||||
_planIndex = 0;
|
|
||||||
_status = $"{label}: {spell.Name}";
|
_status = $"{label}: {spell.Name}";
|
||||||
_host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})");
|
_host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})");
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,40 @@ public class BuffPlanTests
|
||||||
Assert.Equal(new uint[] { 2, 3, 1 }, plan.Select(s => s.SpellId).ToArray());
|
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]
|
[Fact]
|
||||||
public void EmptySpellbookProducesNoPlan()
|
public void EmptySpellbookProducesNoPlan()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue