using System.Globalization;
using AcDream.Plugin.Abstractions;
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 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
{
///
/// 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();
private readonly VitalSettings _vitalSettings = new();
// 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 _queue = new();
private int _queueIndex;
private bool _running;
private double _sinceProgress;
private int _castThisPass;
private string _status = "Idle.";
///
/// What the player had selected before the pass, so targeting yourself for
/// banes does not quietly steal the selection and leave it changed.
///
private uint? _selectionBeforePass;
public MossTankPanel(IPluginHost host) => _host = host;
// ── main panel bindings ───────────────────────────────────────────────
public Action Buff => StartOrStop;
public Action OpenSettings => () => SettingsOpen = true;
public Action CloseSettings => () => SettingsOpen = false;
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;
/// The button is Force Buff; while a pass runs it cancels.
public string ButtonText => _running ? "Stop" : "Force Buff";
public string Status => _status;
/// Vitals line, using the same numbers the character panel shows.
public string Vitals
{
get
{
ICharacterInfo character = _host.Automation.Character;
if (!_host.Automation.IsAvailable)
return string.Empty;
return $"Health {character.CurrentHealth}/{character.MaxHealth}"
+ $" Stam {character.CurrentStamina}/{character.MaxStamina}"
+ $" Mana {character.CurrentMana}/{character.MaxMana}";
}
}
/// What a buff pass would cover, named from the retail tables.
public string Coverage
{
get
{
IAutomationSurface automation = _host.Automation;
if (!automation.IsAvailable)
return string.Empty;
int trained = 0;
foreach (PluginSkillInfo skill in automation.Character.Skills)
{
if (skill.Training is PluginSkillTraining.Trained
or PluginSkillTraining.Specialized)
{
trained++;
}
}
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 string ProtectionsText =>
$"Buff protections: {OnOff(_buffSettings.BuffProtections)}";
public string AurasText =>
$"Buff weapon auras: {OnOff(_buffSettings.BuffAuras)}";
public string BanesText =>
$"Buff banes (armor): {OnOff(_buffSettings.BuffBanes)}";
public string RegenerationText =>
$"Buff regen rates: {OnOff(_buffSettings.BuffRegeneration)}";
public string OtherText =>
$"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}";
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;
public Action ToggleProtections => () =>
_buffSettings.BuffProtections = !_buffSettings.BuffProtections;
public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras;
public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes;
public Action ToggleRegeneration =>
() => _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration;
public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther;
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()
{
_host.Log.Info(
$"MossTank: Buff clicked (running={_running}, inWorld={_host.Automation.IsAvailable})");
if (_running)
{
Stop("Stopped.");
Announce("Stopped.");
return;
}
IAutomationSurface automation = _host.Automation;
if (!automation.IsAvailable)
{
_status = "Not in world.";
return;
}
// 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;
_selectionBeforePass = _host.Selection.SelectedObjectId;
_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;
_queue = new List();
_queueIndex = 0;
_status = status;
RestoreSelection();
}
private void RestoreSelection()
{
if (_selectionBeforePass is { } previous && previous != 0)
_host.Selection.Select(previous);
else
_host.Selection.Clear();
_selectionBeforePass = null;
}
private List BuildPlan(IAutomationSurface automation, bool force) =>
BuffPlan.Build(
BuffProfile.Build(automation.Spells.KnownSelfBuffs),
automation.Character.Skills,
automation.Character.Attributes,
automation.Character.ActiveEnchantments,
_buffSettings,
force);
private Dictionary SkillLevels(IAutomationSurface automation)
{
var levels = new Dictionary();
foreach (PluginSkillInfo skill in automation.Character.Skills)
levels[skill.SkillId] = skill.Current;
return levels;
}
/// Driven by on the host update thread.
public void OnTick(double elapsedSeconds)
{
if (!_running)
return;
IAutomationSurface automation = _host.Automation;
if (!automation.IsAvailable)
{
Stop("Lost the session.");
return;
}
_sinceProgress += elapsedSeconds;
if (_sinceProgress > StallTimeoutSeconds)
{
Stop($"Stalled after {_castThisPass} cast(s).");
_host.Log.Warn("MossTank: pass stalled; stopping");
Announce($"Stopped — no progress after {_castThisPass} cast(s).");
return;
}
// 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;
if (TryVitalUpkeep(automation))
return;
if (_queueIndex >= _queue.Count)
{
Stop($"Done — {_castThisPass} cast(s).");
_host.Log.Info($"MossTank: force pass complete ({_castThisPass} cast)");
Announce($"Finished — {_castThisPass} spell(s) cast.");
return;
}
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)
{
VitalAction action = VitalPlan.Decide(automation.Character, _vitalSettings);
if (action == VitalAction.None)
return false;
string stem = action == VitalAction.StaminaToMana
? VitalPlan.StaminaToManaStem
: VitalPlan.RevitalizeStem;
if (!VitalPlan.TryFind(
automation.Spells.KnownSelfBuffs, stem, SkillLevels(automation),
_buffSettings.SkillExcessOverDifficulty, out PluginSpellInfo spell))
{
// Not knowing the conversion is not an error — plenty of characters
// do not have it. Fall through to buffing rather than stalling.
return false;
}
return TryCast(automation, spell, action.ToString());
}
private bool TryCast(
IAutomationSurface automation, PluginSpellInfo spell, string label)
{
// A spell without the self-targeted flag still needs a target, and for
// a bane that target is the player: retail's text is "Target yourself
// to cast this spell on all of your equipped armor". Select first, or
// the gate refuses for want of a target.
if (!spell.IsSelfTargeted)
{
uint self = automation.Character.ObjectId;
if (self == 0)
{
_status = $"{spell.Name}: no self target";
return false;
}
if (_host.Selection.SelectedObjectId != self)
_host.Selection.Select(self);
}
PluginCastGate gate = automation.Magic.EvaluateGate(spell.SpellId);
if (gate != PluginCastGate.Ready)
{
_status = $"{spell.Name}: {gate}";
return false;
}
if (!automation.Magic.Cast(spell.SpellId))
{
_status = $"Refused {spell.Name}.";
return false;
}
_castThisPass++;
_sinceProgress = 0;
_status = $"{label}: {spell.Name}";
_host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})");
return true;
}
}