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 click and the
/// tick both 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;
private readonly IPluginHost _host;
private readonly BuffSettings _buffSettings = new();
private readonly VitalSettings _vitalSettings = new();
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.
public Action Buff => StartOrStop;
///
/// 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 string ButtonText => _running ? "Stop" : "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++;
}
}
int attributes = automation.Character.Attributes.Count;
int lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs).Count;
return $"{attributes} attributes, {trained} trained skills, {lines} buff lines known";
}
}
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})");
if (_running)
{
Stop("Stopped.");
Announce("Stopped.");
return;
}
IAutomationSurface automation = _host.Automation;
if (!automation.IsAvailable)
{
_status = "Not in world.";
return;
}
_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}…";
_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;
_plan = new List();
_planIndex = 0;
_status = status;
}
private List BuildPlan(IAutomationSurface automation)
{
List lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs);
return BuffPlan.Build(
lines,
automation.Character.Skills,
automation.Character.Attributes,
automation.Character.ActiveEnchantments,
_buffSettings);
}
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;
}
_sinceLastCast += elapsedSeconds;
_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;
}
if (_sinceLastCast < CastIntervalSeconds || 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;
// Re-derive against current enchantments so anything that landed since
// the pass began drops out rather than being cast twice.
_plan = BuildPlan(automation);
if (_plan.Count == 0)
{
Stop($"Done — {_castThisPass} cast(s).");
_host.Log.Info($"MossTank: pass complete ({_castThisPass} cast)");
Announce(_castThisPass == 0
? "Already fully buffed."
: $"Finished — {_castThisPass} spell(s) cast.");
return;
}
if (_planIndex >= _plan.Count)
_planIndex = 0;
PluginSpellInfo next = _plan[_planIndex];
if (!TryCast(automation, next, $"Buffing {_castThisPass + 1}/{_plan.Count}"))
_planIndex++;
}
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)
{
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++;
_sinceLastCast = 0;
_sinceProgress = 0;
_planIndex = 0;
_status = $"{label}: {spell.Name}";
_host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})");
return true;
}
}