feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
420
src/AcDream.Plugins.MossTank/DispelController.cs
Normal file
420
src/AcDream.Plugins.MossTank/DispelController.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue