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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AcDream.Plugins.MossTank/mosstank.xml b/src/AcDream.Plugins.MossTank/mosstank.xml
index e22e9387..104001dd 100644
--- a/src/AcDream.Plugins.MossTank/mosstank.xml
+++ b/src/AcDream.Plugins.MossTank/mosstank.xml
@@ -1,10 +1,11 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs
index b7ddedae..3debc8b5 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/VitalPlanTests.cs
@@ -102,6 +102,16 @@ public class VitalPlanTests
Assert.Equal(1u, pick.SpellId);
}
+ [Fact]
+ public void DoesNothingWhenVitalUpkeepIsTurnedOff()
+ {
+ // Converting stamina mid-buff surprises people, so it has to be a
+ // setting rather than a behaviour.
+ var character = new Character { CurrentMana = 1, CurrentStamina = 100 };
+ var off = new VitalSettings { Enabled = false };
+ Assert.Equal(VitalAction.None, VitalPlan.Decide(character, off));
+ }
+
[Fact]
public void ReportsNoConversionWhenTheCharacterKnowsNone()
{