420 lines
14 KiB
C#
420 lines
14 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<PluginInventoryItem> 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<PluginInventoryItem> inventory,
|
|
IEnumerable<string> 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<PluginTrackedEnchantment> tracked =
|
|
automation.Enchantments.Capture(member.ObjectId);
|
|
if (tracked.Any(static enchantment =>
|
|
enchantment.SpellId == DispelProtectionSpell
|
|
&& enchantment.SecondsRemaining > 0d))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var qualities = new Dictionary<MonsterDamageType, int>();
|
|
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;
|
|
}
|
|
}
|