using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
///
/// VTank's post-buff dispel rules from c8.cs, cx.cs and af.cs. Policy lives in
/// the plugin; the host contributes only canonical spell, item, mode and
/// completion operations.
///
internal sealed class DispelController
{
private const uint EradicateLifeMagicSelf =
(uint)SpellId.EradicateLifeMagicSelf;
private const double ActionTimeoutSeconds = 15d;
private const float AllyDispelRangeMeters = 5f;
private const uint CreatureEnchantmentSkill = 31u;
private const uint ArcaneLoreSkill = 14u;
private const uint DispelProtectionSpell = 3179u;
private static readonly string[] HighDifficultyItems =
[
"Rune of Dispel",
"Society Gem of Dispelling",
"Black Market Gem of Dispelling",
];
private static readonly string[] NormalDifficultyItems =
[
"Rune of Dispel",
"Chocolate Gromnie",
"Condensed Dispel Potion",
"Gem of Stillness",
];
private readonly IPluginHost _host;
private readonly VitalSettings _settings;
private Pending? _pending;
private double _pendingSeconds;
private double _retryDelay;
public DispelController(IPluginHost host, VitalSettings settings)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string Status { get; private set; } = "Dispel idle";
public bool Tick(double elapsedSeconds, bool canAct)
{
double elapsed = Math.Max(0d, elapsedSeconds);
_retryDelay = Math.Max(0d, _retryDelay - elapsed);
if (ObservePending(elapsed))
return true;
IAutomationSurface automation = _host.Automation;
if (!canAct
|| _retryDelay > 0d
|| !_host.Automation.IsAvailable
|| (!_settings.CastDispelSelf
&& !_settings.UseDispelItems
&& !_settings.UseDispelDrum)
|| automation.Magic.IsCasting
|| automation.Items.IsBusy)
{
return false;
}
if (_settings.CastDispelSelf
&& TryStartSelfDispel(automation))
{
return true;
}
if (_settings.UseDispelItems
&& TrySelectDispelItem(automation, out PluginInventoryItem item))
{
long revision = automation.Items.LastCompletion.Revision;
PluginItemCommandResult result = automation.Items.Use(item.ObjectId);
if (result.Accepted)
{
_pending = new Pending(
DispelSource.Item,
item.ObjectId,
item.Name,
revision);
_pendingSeconds = 0d;
Status = $"Using {item.Name}";
return true;
}
Status = $"Waiting to use {item.Name}";
return result.Status == PluginItemCommandStatus.Busy;
}
if (_settings.UseDispelDrum && TryStartAllyDispel(automation))
return true;
Status = "Dispel idle";
return false;
}
public void Reset()
{
_pending = null;
_pendingSeconds = 0d;
_retryDelay = 0d;
Status = "Dispel idle";
}
private bool TryStartSelfDispel(IAutomationSurface automation)
{
if (!automation.Spells.TryGet(
EradicateLifeMagicSelf,
out PluginSpellInfo spell)
|| !automation.Spells.IsKnown(EradicateLifeMagicSelf)
|| !HasVulnerabilityAtOrBelow(automation, spell.Difficulty)
|| !automation.Items.CaptureOwnedItems().Any(static item =>
item.StackSize > 0
&& item.Name.Equals("Chorizite", StringComparison.Ordinal)))
{
return false;
}
if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic)
{
PluginCombatCommandResult mode = automation.Combat.EnterMode(
PluginCombatMode.Magic);
Status = mode.Accepted
? "Switching to Magic for self dispel"
: "Waiting for Magic mode to self dispel";
return true;
}
uint target = automation.Character.ObjectId;
PluginCastGate gate = automation.Magic.EvaluateGate(
EradicateLifeMagicSelf,
target);
if (gate != PluginCastGate.Ready)
{
Status = "Waiting to cast Eradicate Life Magic Self";
return true;
}
long revision = automation.Magic.LastCompletion.Revision;
if (!automation.Magic.Cast(EradicateLifeMagicSelf, target))
{
Status = "Self dispel was refused";
_retryDelay = 0.25d;
return true;
}
_pending = new Pending(
DispelSource.Spell,
EradicateLifeMagicSelf,
spell.Name,
revision);
_pendingSeconds = 0d;
Status = $"Casting {spell.Name}";
return true;
}
private bool TrySelectDispelItem(
IAutomationSurface automation,
out PluginInventoryItem selected)
{
selected = default;
IReadOnlyList inventory =
automation.Items.CaptureOwnedItems();
if (HasVulnerabilityAtOrBelow(automation, 400)
&& TryFind(inventory, HighDifficultyItems, out selected))
{
return true;
}
return HasVulnerabilityAtOrBelow(automation, 350)
&& TryFind(inventory, NormalDifficultyItems, out selected);
}
private static bool TryFind(
IReadOnlyList inventory,
IEnumerable names,
out PluginInventoryItem selected)
{
foreach (string name in names)
{
foreach (PluginInventoryItem item in inventory)
{
if (item.StackSize > 0
&& item.Name.Equals(name, StringComparison.Ordinal))
{
selected = item;
return true;
}
}
}
selected = default;
return false;
}
private bool TryStartAllyDispel(IAutomationSurface automation)
{
if (!automation.Fellowship.IsInFellowship
|| !TrySelectAwakener(automation, out PluginInventoryItem drum)
|| !TrySelectAlly(automation, out PluginFellowMember target))
{
return false;
}
if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic)
{
PluginCombatCommandResult mode = automation.Combat.EnterMode(
PluginCombatMode.Magic);
Status = mode.Accepted
? "Switching to Magic for ally dispel"
: "Waiting for Magic mode to dispel ally";
return true;
}
long revision = automation.Items.LastCompletion.Revision;
PluginItemCommandResult result = automation.Items.Apply(
drum.ObjectId,
target.ObjectId);
if (!result.Accepted)
{
Status = $"Waiting to use {drum.Name} on {target.Name}";
return result.Status == PluginItemCommandStatus.Busy;
}
_pending = new Pending(
DispelSource.AllyItem,
drum.ObjectId,
$"{drum.Name} on {target.Name}",
revision);
_pendingSeconds = 0d;
Status = $"Using {drum.Name} on {target.Name}";
return true;
}
private static bool TrySelectAwakener(
IAutomationSurface automation,
out PluginInventoryItem selected)
{
selected = default;
if (!automation.Character.TryGetSkill(
CreatureEnchantmentSkill,
out PluginSkillInfo creature)
|| !automation.Character.TryGetSkill(
ArcaneLoreSkill,
out PluginSkillInfo arcane)
|| arcane.Current < 110u)
{
return false;
}
foreach (PluginInventoryItem item in automation.Items.CaptureOwnedItems())
{
if (!item.IsEquipped)
continue;
bool valid = item.Name switch
{
"Awakener" => creature.Training == PluginSkillTraining.Specialized,
"Attenuated Awakener" => creature.Training
is PluginSkillTraining.Trained
or PluginSkillTraining.Specialized,
_ => false,
};
if (!valid)
continue;
selected = item;
return true;
}
return false;
}
private static bool TrySelectAlly(
IAutomationSurface automation,
out PluginFellowMember selected)
{
selected = default;
int highestScore = 0;
foreach (PluginFellowMember member in automation.Fellowship.CaptureMembers())
{
if (member.ObjectId == automation.Character.ObjectId
|| member.Distance > AllyDispelRangeMeters)
{
continue;
}
IReadOnlyList tracked =
automation.Enchantments.Capture(member.ObjectId);
if (tracked.Any(static enchantment =>
enchantment.SpellId == DispelProtectionSpell
&& enchantment.SecondsRemaining > 0d))
{
continue;
}
var qualities = new Dictionary();
foreach (PluginTrackedEnchantment enchantment in tracked)
{
if (enchantment.SecondsRemaining <= 0d
|| enchantment.IsUntargeted
|| !automation.Spells.TryGet(
enchantment.SpellId,
out PluginSpellInfo spell)
|| spell.Difficulty > 350
|| !DebuffSpellCatalog.TryClassify(
spell,
out DebuffIdentity identity,
out _)
|| identity.Flag != MonsterActionFlags.Vulnerability
|| identity.DamageType == MonsterDamageType.Auto)
{
continue;
}
int quality = enchantment.Quality;
if (!qualities.TryGetValue(identity.DamageType, out int old)
|| quality > old)
{
qualities[identity.DamageType] = quality;
}
}
int score = qualities.Values.Where(static quality => quality > 250).Sum();
if (score <= highestScore)
continue;
highestScore = score;
selected = member;
}
return selected.ObjectId != 0u;
}
private static bool HasVulnerabilityAtOrBelow(
IAutomationSurface automation,
int maximumDifficulty)
{
foreach (PluginActiveEnchantment active
in automation.Character.ActiveEnchantments)
{
if (active.SecondsRemaining < 0d
|| !automation.Spells.TryGet(active.SpellId, out PluginSpellInfo spell)
|| spell.Difficulty > maximumDifficulty
|| spell.IsUntargeted
|| !DebuffSpellCatalog.TryClassify(
spell,
out DebuffIdentity identity,
out _)
|| identity.Flag != MonsterActionFlags.Vulnerability)
{
continue;
}
return true;
}
return false;
}
private bool ObservePending(double elapsedSeconds)
{
if (_pending is not { } pending)
return false;
_pendingSeconds += elapsedSeconds;
if (pending.Source == DispelSource.Spell)
{
PluginCastCompletion completion = _host.Automation.Magic.LastCompletion;
if (completion.Revision > pending.Revision)
{
pending.Revision = completion.Revision;
if (completion.SpellId == pending.ObjectId)
return Finish(completion.IsSuccess, completion.WeenieError);
}
}
else
{
PluginItemUseCompletion completion = _host.Automation.Items.LastCompletion;
if (completion.Revision > pending.Revision)
{
pending.Revision = completion.Revision;
if (completion.SourceObjectId == pending.ObjectId)
return Finish(completion.IsSuccess, completion.WeenieError);
}
}
if (_pendingSeconds < ActionTimeoutSeconds)
return true;
Status = $"Dispel timed out: {pending.Name}";
ClearPending();
return true;
}
private bool Finish(bool succeeded, uint weenieError)
{
string name = _pending?.Name ?? "dispel";
Status = succeeded
? $"Dispel completed: {name}"
: $"Dispel failed (0x{weenieError:X}): {name}";
ClearPending();
return true;
}
private void ClearPending()
{
_pending = null;
_pendingSeconds = 0d;
_retryDelay = 0.25d;
}
private enum DispelSource
{
Spell,
Item,
AllyItem,
}
private sealed class Pending(
DispelSource source,
uint objectId,
string name,
long revision)
{
public DispelSource Source { get; } = source;
public uint ObjectId { get; } = objectId;
public string Name { get; } = name;
public long Revision { get; set; } = revision;
}
}