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 @@ + + + +