Four defects from the first in-world look, three of them with a definite root cause rather than a plausible one. **The Buff button did nothing.** Not a hit-testing problem -- the pointer found the button perfectly. UiRoot's press handling asks the pressed widget whether it owns the pointer; a widget that does not claim the press falls through to "move the ancestor window", and a window drag returns early on release without ever emitting a Click. UiButton and UiClickablePanel both override HandlesClick for exactly this reason; UiSimpleButton never did. Latent since that class was written, and invisible until it was put inside a draggable window -- which is precisely what a markup plugin panel is. Found by reproducing it headlessly through the real UiRoot dispatcher rather than by reasoning about it: MarkupPanelClickTests drives press-and-release over the button and asserts the bound action ran, with a separate test asserting the pointer finds the button at all, so a future failure says which half broke. My earlier guess -- that a modal at character select was swallowing the click -- was wrong, and the screenshot of the panel live in world disproved it. **"0 trained skills".** The skill-name table was read in OnLoad *before* GameWindowCompositionPipeline.Run, which is what publishes the DAT collection, so _dats was still null, the whole block was skipped, and the surface reported an empty skill list with nothing to explain it. Bound in PublishDatCollection instead -- the moment the data exists -- so it cannot run early again whatever the phase ordering does, and a genuinely missing SkillTable now says so. **Plugin text used the development bitmap font.** UiLabel and UiSimpleButton gained a DatFont, and MarkupDocument now takes the retail interface font from the host, so plugin panels render through the same glyph path (including retail's two-plane outline) as authored panels. **MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's ClientLocal log type (0x1A) -- the channel the client uses for its own notices, local to this client, so a plugin cannot speak in the player's name. MossTank announces the start, the finish with a cast count, and a stall. Not addressed here: the cursor showing blue rather than amber. Traced but not fixed -- CursorFeedbackController picks the cursor family from combat mode, and CombatMode.Magic selects the blue Magic cursor where Default is amber. That is a combat-mode question, unrelated to this change, and worth its own look rather than a speculative fix folded in here. Solution builds clean; 14,437 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
8.6 KiB
C#
259 lines
8.6 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank;
|
|
|
|
/// <summary>
|
|
/// The panel's binding object and the buff loop's state machine.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class MossTankPanel
|
|
{
|
|
/// <summary>Roughly a retail cast plus windup, so casts do not stack up.</summary>
|
|
private const double CastIntervalSeconds = 3.0;
|
|
|
|
/// <summary>Give up on a pass that stops making progress.</summary>
|
|
private const double StallTimeoutSeconds = 25.0;
|
|
|
|
private readonly IPluginHost _host;
|
|
private readonly BuffSettings _buffSettings = new();
|
|
private readonly VitalSettings _vitalSettings = new();
|
|
|
|
private List<PluginSpellInfo> _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;
|
|
|
|
/// <summary>Bound to the panel's Buff button.</summary>
|
|
public Action Buff => StartOrStop;
|
|
|
|
/// <summary>
|
|
/// Bound to the panel's <c>visible</c>. Keeps the window off the character
|
|
/// select and login screens, where there is no character to buff.
|
|
/// </summary>
|
|
public bool IsInWorld => _host.Automation.IsAvailable;
|
|
|
|
public string ButtonText => _running ? "Stop" : "Buff";
|
|
|
|
public string Status => _status;
|
|
|
|
/// <summary>Vitals line, using the same numbers the character panel shows.</summary>
|
|
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}";
|
|
}
|
|
}
|
|
|
|
/// <summary>What a buff pass would cover, named from the retail tables.</summary>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// One chat line, tagged so it reads like a client notice rather than
|
|
/// something the character said.
|
|
/// </summary>
|
|
private void Announce(string text)
|
|
{
|
|
_host.Automation.Chat.PostSystemMessage($"[MossTank] {text}");
|
|
}
|
|
|
|
private void Stop(string status)
|
|
{
|
|
_running = false;
|
|
_plan = new List<PluginSpellInfo>();
|
|
_planIndex = 0;
|
|
_status = status;
|
|
}
|
|
|
|
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation)
|
|
{
|
|
List<BuffLine> lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs);
|
|
return BuffPlan.Build(
|
|
lines,
|
|
automation.Character.Skills,
|
|
automation.Character.Attributes,
|
|
automation.Character.ActiveEnchantments,
|
|
_buffSettings);
|
|
}
|
|
|
|
private Dictionary<uint, uint> SkillLevels(IAutomationSurface automation)
|
|
{
|
|
var levels = new Dictionary<uint, uint>();
|
|
foreach (PluginSkillInfo skill in automation.Character.Skills)
|
|
levels[skill.SkillId] = skill.Current;
|
|
return levels;
|
|
}
|
|
|
|
/// <summary>Driven by <see cref="IEvents.Tick"/> on the host update thread.</summary>
|
|
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;
|
|
}
|
|
}
|