From 54005a864c00f2c0d29dc3583bdc3c51694aae4a Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 20 Aug 2026 18:51:15 +0200 Subject: [PATCH] feat(mosstank): cast back-to-back, and a settings view for the thresholds Three things from the first working buff pass. **Pacing.** Casts were three seconds apart because of a fixed interval I added before there was a real busy signal. There is one now -- the shared busy count, incremented by the cast and decremented by the server's UseDone -- so the interval is gone and the server is the only throttle. MossTank casts the moment the previous action is acknowledged, which is the spam behaviour VTank has. The stall timeout moved 25s -> 30s so a slow server does not read as a stall. **Stamina to Mana was surprising.** VTank does convert vitals by default (Recharge-*-Mana), so the behaviour is right, but a buff pass quietly spending your stamina is not something to discover by watching. It is now a setting, with its own thresholds, and can be turned off outright. **Settings view.** Spell difficulty margin, rebuff time, the three vital thresholds, and three toggles. Two details worth recording: * The difficulty margin is SIGNED, per the VTank wiki: "a positive number raises the skill necessary to cast spells, a negative number lowers it. To attempt higher spells at a low level use a negative number." So the range spans -100..+100 rather than starting at zero. * It is a second registered panel with a complementary visible binding rather than a tab control. Two panels and an Action need no new markup vocabulary, and only one is ever on screen. Adjuster buttons rather than typed fields: buttons are proven in plugin markup, while an editable UiField would need keyboard routing plumbed through to plugin panels first. Worth doing, but not as a side quest inside this change. Also fixed: the App copy target names plugin files explicitly, so the new markup would have been left out of the plugin directory and the settings panel would have failed to load at runtime with the build perfectly green. Caught by listing the output rather than trusting the build. Solution builds clean; 14,438 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 --- src/AcDream.App/AcDream.App.csproj | 4 +- .../AcDream.Plugins.MossTank.csproj | 3 + src/AcDream.Plugins.MossTank/BuffPlan.cs | 10 +- src/AcDream.Plugins.MossTank/MossTankPanel.cs | 138 ++++++++++++------ .../MossTankPlugin.cs | 10 +- src/AcDream.Plugins.MossTank/VitalPlan.cs | 18 ++- .../mosstank-settings.xml | 38 +++++ src/AcDream.Plugins.MossTank/mosstank.xml | 17 ++- .../VitalPlanTests.cs | 10 ++ 9 files changed, 179 insertions(+), 69 deletions(-) create mode 100644 src/AcDream.Plugins.MossTank/mosstank-settings.xml diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index d2137f6a..ec9fe281 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -167,7 +167,7 @@ PreserveNewest + + PreserveNewest + diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs index a1b74ca6..dc3067be 100644 --- a/src/AcDream.Plugins.MossTank/BuffPlan.cs +++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs @@ -3,29 +3,29 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// Settings that shape a buff pass. Defaults follow Virindi Tank's. -public sealed record BuffSettings +public sealed class BuffSettings { /// /// VTank recasts buffs once they drop below five minutes remaining /// ("all buff spells are recast when they go below 5 minutes"). /// - public double RebuffWhenUnderSeconds { get; init; } = 300.0; + public double RebuffWhenUnderSeconds { get; set; } = 300.0; /// /// How far the casting skill must exceed a spell's difficulty before the /// tier is considered reliable — VTank's /// SpellDiffExcessThreshold-Buff. /// - public int SkillExcessOverDifficulty { get; init; } = 10; + public int SkillExcessOverDifficulty { get; set; } = 10; /// Buff every attribute (VTank's default). - public bool BuffAttributes { get; init; } = true; + public bool BuffAttributes { get; set; } = true; /// /// Buff trained and specialised skills only — VTank's stated default: /// "automatically buffs every Attribute and Skill you have trained". /// - public bool BuffTrainedSkillsOnly { get; init; } = true; + public bool BuffTrainedSkillsOnly { get; set; } = true; } /// diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 82e5984a..605549e5 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; @@ -6,17 +7,18 @@ 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 +/// Everything here runs on the host's update thread — the button clicks and the +/// tick all 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; + /// + /// Give up on a pass that stops making progress. Generous, because the + /// pacing is now the server acknowledging each cast rather than a fixed + /// delay, and a slow server should not read as a stall. + /// + private const double StallTimeoutSeconds = 30.0; private readonly IPluginHost _host; private readonly BuffSettings _buffSettings = new(); @@ -25,24 +27,24 @@ internal sealed class MossTankPanel 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. + // ── main panel bindings ─────────────────────────────────────────────── public Action Buff => StartOrStop; + public Action OpenSettings => () => SettingsOpen = true; + public Action CloseSettings => () => SettingsOpen = false; - /// - /// 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 bool SettingsOpen { get; private set; } + + /// Keeps the windows off the character-select and login screens. + public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen; + public bool SettingsVisible => _host.Automation.IsAvailable && SettingsOpen; public string ButtonText => _running ? "Stop" : "Buff"; - public string Status => _status; /// Vitals line, using the same numbers the character panel shows. @@ -77,17 +79,78 @@ internal sealed class MossTankPanel 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"; + return $"{automation.Character.Attributes.Count} attributes, " + + $"{trained} trained skills, " + + $"{BuffProfile.Build(automation.Spells.KnownSelfBuffs).Count} buff lines"; } } + // ── settings bindings ───────────────────────────────────────────────── + // Adjuster buttons rather than typed entry: buttons are a proven primitive + // in plugin markup, whereas an editable field would need keyboard routing + // plumbed through to plugin panels first. + + /// + /// VTank's SpellDiffExcessThreshold-Buff. Signed on purpose — the + /// wiki is explicit that "a positive number raises the skill necessary to + /// cast spells, a negative number lowers it", so a lower-level character + /// can reach for higher tiers by going negative. + /// + public string DifficultyText => + $"Spell difficulty margin: {_buffSettings.SkillExcessOverDifficulty:+0;-0;0}"; + + public string RebuffText => + $"Rebuff when under: {_buffSettings.RebuffWhenUnderSeconds / 60.0:0.#} min"; + + public string ManaFloorText => $"Convert below mana: {Percent(_vitalSettings.ManaFloor)}"; + public string ManaTargetText => $"Stop converting at: {Percent(_vitalSettings.ManaTarget)}"; + public string StaminaFloorText => $"Keep stamina above: {Percent(_vitalSettings.StaminaFloor)}"; + public string VitalUpkeepText => + $"Stamina to Mana / Revitalize: {OnOff(_vitalSettings.Enabled)}"; + public string TrainedOnlyText => + $"Trained skills only: {OnOff(_buffSettings.BuffTrainedSkillsOnly)}"; + public string AttributesText => + $"Buff attributes: {OnOff(_buffSettings.BuffAttributes)}"; + + public Action DifficultyDown => () => _buffSettings.SkillExcessOverDifficulty = + Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5); + public Action DifficultyUp => () => _buffSettings.SkillExcessOverDifficulty = + Math.Min(100, _buffSettings.SkillExcessOverDifficulty + 5); + + public Action RebuffDown => () => _buffSettings.RebuffWhenUnderSeconds = + Math.Max(30, _buffSettings.RebuffWhenUnderSeconds - 30); + public Action RebuffUp => () => _buffSettings.RebuffWhenUnderSeconds = + Math.Min(1800, _buffSettings.RebuffWhenUnderSeconds + 30); + + public Action ManaFloorDown => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, -1); + public Action ManaFloorUp => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, +1); + + public Action ManaTargetDown => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, -1); + public Action ManaTargetUp => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, +1); + + public Action StaminaFloorDown => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, -1); + public Action StaminaFloorUp => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, +1); + + public Action ToggleVitalUpkeep => () => _vitalSettings.Enabled = !_vitalSettings.Enabled; + public Action ToggleTrainedOnly => () => + _buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly; + public Action ToggleAttributes => () => + _buffSettings.BuffAttributes = !_buffSettings.BuffAttributes; + + private static double Step(double value, int direction) => + Math.Clamp(Math.Round(value + direction * 0.05, 2), 0.0, 1.0); + + private static string Percent(double fraction) => + (fraction * 100).ToString("0", CultureInfo.InvariantCulture) + "%"; + + private static string OnOff(bool value) => value ? "on" : "off"; + + // ── the loop ────────────────────────────────────────────────────────── + private void Announce(string text) => + _host.Automation.Chat.PostSystemMessage($"[MossTank] {text}"); + 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})"); @@ -108,27 +171,15 @@ internal sealed class MossTankPanel _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}…"; + _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."); } - /// - /// One chat line, tagged so it reads like a client notice rather than - /// something the character said. - /// - private void Announce(string text) - { - _host.Automation.Chat.PostSystemMessage($"[MossTank] {text}"); - } - private void Stop(string status) { _running = false; @@ -137,16 +188,13 @@ internal sealed class MossTankPanel _status = status; } - private List BuildPlan(IAutomationSurface automation) - { - List lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs); - return BuffPlan.Build( - lines, + private List BuildPlan(IAutomationSurface automation) => + BuffPlan.Build( + BuffProfile.Build(automation.Spells.KnownSelfBuffs), automation.Character.Skills, automation.Character.Attributes, automation.Character.ActiveEnchantments, _buffSettings); - } private Dictionary SkillLevels(IAutomationSurface automation) { @@ -169,9 +217,7 @@ internal sealed class MossTankPanel return; } - _sinceLastCast += elapsedSeconds; _sinceProgress += elapsedSeconds; - if (_sinceProgress > StallTimeoutSeconds) { Stop($"Stalled after {_castThisPass} cast(s)."); @@ -180,11 +226,12 @@ internal sealed class MossTankPanel return; } - if (_sinceLastCast < CastIntervalSeconds || automation.Magic.IsCasting) + // The server is the only throttle: cast the moment the previous action + // is acknowledged. The fixed delay this replaces was standing in for a + // busy signal we now have, and it only made the pass slow. + if (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; @@ -249,7 +296,6 @@ internal sealed class MossTankPanel } _castThisPass++; - _sinceLastCast = 0; _sinceProgress = 0; _planIndex = 0; _status = $"{label}: {spell.Name}"; diff --git a/src/AcDream.Plugins.MossTank/MossTankPlugin.cs b/src/AcDream.Plugins.MossTank/MossTankPlugin.cs index 92c018f8..7b158272 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPlugin.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPlugin.cs @@ -33,11 +33,13 @@ public sealed class MossTankPlugin : IAcDreamPlugin // Markup ships beside the plugin assembly, so it is found relative to // this DLL rather than the host's working directory -- plugins are // loaded from their own directory and the two are not the same. - string markup = Path.Combine( - Path.GetDirectoryName(typeof(MossTankPlugin).Assembly.Location) ?? ".", - "mosstank.xml"); + string directory = + Path.GetDirectoryName(typeof(MossTankPlugin).Assembly.Location) ?? "."; - _host.Ui.AddMarkupPanel(markup, _panel); + // Two panels with complementary visible bindings stand in for a tab + // control: only one is ever on screen, and switching is just an Action. + _host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank.xml"), _panel); + _host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank-settings.xml"), _panel); _tick = _panel.OnTick; _host.Events.Tick += _tick; diff --git a/src/AcDream.Plugins.MossTank/VitalPlan.cs b/src/AcDream.Plugins.MossTank/VitalPlan.cs index a373e19f..1062baef 100644 --- a/src/AcDream.Plugins.MossTank/VitalPlan.cs +++ b/src/AcDream.Plugins.MossTank/VitalPlan.cs @@ -13,18 +13,25 @@ public enum VitalAction } /// Thresholds for vital upkeep, following VTank's Recharge-* settings. -public sealed record VitalSettings +public sealed class VitalSettings { + /// + /// Whether to convert vitals at all. VTank does this by default through its + /// Recharge-* thresholds, but it is surprising the first time a buff pass + /// spends your stamina, so it is worth being able to turn off. + /// + public bool Enabled { get; set; } = true; + /// Convert stamina to mana below this fraction of max mana. - public double ManaFloor { get; init; } = 0.50; + public double ManaFloor { get; set; } = 0.50; /// Stop converting once mana is back above this fraction. - public double ManaTarget { get; init; } = 0.85; + public double ManaTarget { get; set; } = 0.85; /// Refuse to drain stamina below this fraction — the conversion /// takes half your stamina, and stranding the character at zero is worse /// than being short of mana. - public double StaminaFloor { get; init; } = 0.35; + public double StaminaFloor { get; set; } = 0.35; } /// @@ -53,6 +60,9 @@ public static class VitalPlan public static VitalAction Decide(ICharacterInfo character, VitalSettings settings) { + if (!settings.Enabled) + return VitalAction.None; + double mana = Fraction(character.CurrentMana, character.MaxMana); double stamina = Fraction(character.CurrentStamina, character.MaxStamina); diff --git a/src/AcDream.Plugins.MossTank/mosstank-settings.xml b/src/AcDream.Plugins.MossTank/mosstank-settings.xml new file mode 100644 index 00000000..3f5f8fbe --- /dev/null +++ b/src/AcDream.Plugins.MossTank/mosstank-settings.xml @@ -0,0 +1,38 @@ + + + +