using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; internal enum AttackSpellShape { Direct, Arc, Streak, Ring, Harm, Drain, Martyr, } internal readonly record struct AttackSpellChoice( PluginSpellInfo Spell, AttackSpellShape Shape, MonsterDamageType DamageType, bool CastWithoutTarget); /// /// VTank's attack vocabulary projected from the learned retail spell table. /// Shape and element are derived from stable retail spell names/descriptions; /// the host remains a policy-free provider of canonical DAT metadata. /// internal sealed class AttackSpellCatalog { private const uint TuskerFistsSpellId = 0x0B76u; private readonly AttackSpellChoice[] _choices; private AttackSpellCatalog(AttackSpellChoice[] choices) => _choices = choices; public static AttackSpellCatalog Build( IReadOnlyList spells) { ArgumentNullException.ThrowIfNull(spells); var choices = new List(); foreach (PluginSpellInfo spell in spells) { if (TryClassify(spell, out AttackSpellChoice choice)) choices.Add(choice); } return new AttackSpellCatalog([.. choices]); } /// /// Returns VTank's preferred spell forms in retry order. Cast feasibility /// stays with the host's exact gate, so a lower known tier can be selected /// when the character cannot currently cast the strongest one. /// public IReadOnlyList Candidates( MonsterRuleActions actions, CombatSettings settings, PluginCombatTarget target, int nearbyRingTargets, ICharacterInfo character) { ArgumentNullException.ThrowIfNull(actions); ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(character); MonsterDamageType damageMode = ResolveDamageMode( actions.DamageType, character); bool ringDue = actions.UsesRing && nearbyRingTargets >= (actions.UsesPrimaryAttack ? Math.Max(1, settings.MinimumRingTargets) : 1); var candidates = new List(); foreach (AttackSpellChoice choice in _choices) { if (!MatchesDamageMode(choice, damageMode)) continue; if (choice.Shape == AttackSpellShape.Ring && !ringDue) continue; if (choice.Shape != AttackSpellShape.Ring && !MatchesPrimaryShape(choice.Shape, actions, settings, target)) { continue; } candidates.Add(choice); } candidates.Sort((left, right) => Compare( left, right, actions with { DamageType = damageMode }, settings, target, ringDue, character)); return candidates; } private static int Compare( AttackSpellChoice left, AttackSpellChoice right, MonsterRuleActions actions, CombatSettings settings, PluginCombatTarget target, bool ringDue, ICharacterInfo character) { // VTank resolves Auto through GameInfoDB before it chooses the // bolt/arc/streak form. Element preference therefore outranks spell // shape and tier; an unavailable preferred element naturally falls // through to the next candidate in the ordered list. if (actions.DamageType == MonsterDamageType.Auto) { int leftDamage = VtankDamageDatabase.PreferenceIndex( target, left.DamageType); int rightDamage = VtankDamageDatabase.PreferenceIndex( target, right.DamageType); int damage = leftDamage.CompareTo(rightDamage); if (damage != 0) return damage; } int leftPreference = Preference( left.Shape, actions, settings, target, ringDue, character); int rightPreference = Preference( right.Shape, actions, settings, target, ringDue, character); int preferred = leftPreference.CompareTo(rightPreference); if (preferred != 0) return preferred; int tier = right.Spell.Tier.CompareTo(left.Spell.Tier); if (tier != 0) return tier; int difficulty = right.Spell.Difficulty.CompareTo(left.Spell.Difficulty); return difficulty != 0 ? difficulty : left.Spell.SpellId.CompareTo(right.Spell.SpellId); } private static int Preference( AttackSpellShape shape, MonsterRuleActions actions, CombatSettings settings, PluginCombatTarget target, bool ringDue, ICharacterInfo character) { if (ringDue && shape == AttackSpellShape.Ring) return 0; if (actions.DamageType == MonsterDamageType.DrainAuto) { bool needsHealth = character.MaxHealth != 0u && character.CurrentHealth / (double)character.MaxHealth < 0.75d; if (needsHealth && shape == AttackSpellShape.Drain) return 1; if (!needsHealth && character.MaxHealth != 0u && character.CurrentHealth / (double)character.MaxHealth >= 0.5d && shape == AttackSpellShape.Martyr) { return 1; } return shape switch { AttackSpellShape.Drain => 2, AttackSpellShape.Martyr => 3, AttackSpellShape.Harm => 4, _ => 20, }; } if (actions.UsesStreak) { if (shape == AttackSpellShape.Streak) return 1; if (ShouldUseArc(settings, target)) return shape == AttackSpellShape.Arc ? 2 : 3; return shape == AttackSpellShape.Direct ? 2 : 3; } if (ShouldUseArc(settings, target)) { if (shape == AttackSpellShape.Arc) return 1; if (shape == AttackSpellShape.Direct) return 2; } else { if (shape == AttackSpellShape.Direct) return 1; if (shape == AttackSpellShape.Arc) return 2; } return shape switch { AttackSpellShape.Harm => 1, AttackSpellShape.Streak => 3, AttackSpellShape.Arc => 4, AttackSpellShape.Direct => 5, _ => 10, }; } /// /// VTank's real 3-way UseArcs switch (refs/vtank/decompiled/hi.cs:515-538): /// No never arcs, Yes always arcs, AtRange arcs only /// once the target is at or beyond . /// private static bool ShouldUseArc(CombatSettings settings, PluginCombatTarget target) => settings.UseArcs switch { UseArcsMode.Yes => true, UseArcsMode.AtRange => target.Distance >= settings.ArcRange, _ => false, }; private static bool MatchesPrimaryShape( AttackSpellShape shape, MonsterRuleActions actions, CombatSettings settings, PluginCombatTarget target) { if (actions.DamageType == MonsterDamageType.DrainAuto) { return shape is AttackSpellShape.Drain or AttackSpellShape.Martyr or AttackSpellShape.Harm; } if (actions.DamageType == MonsterDamageType.Harm) return shape == AttackSpellShape.Harm; if (actions.UsesStreak) { // Streak is preferred, not a hard requirement: VTank falls back // when the matching streak/tier is unknown or presently gated. return shape is AttackSpellShape.Streak or AttackSpellShape.Direct or AttackSpellShape.Arc; } return shape is AttackSpellShape.Direct or AttackSpellShape.Arc; } private static bool MatchesDamageMode( AttackSpellChoice choice, MonsterDamageType requested) { return requested switch { MonsterDamageType.Harm => choice.Shape == AttackSpellShape.Harm, MonsterDamageType.DrainAuto => choice.Shape is AttackSpellShape.Drain or AttackSpellShape.Martyr or AttackSpellShape.Harm, MonsterDamageType.VoidBasic or MonsterDamageType.Nether => choice.DamageType == MonsterDamageType.Nether, MonsterDamageType.Auto => choice.DamageType is not MonsterDamageType.Auto && choice.Shape is not (AttackSpellShape.Harm or AttackSpellShape.Drain or AttackSpellShape.Martyr), _ => choice.DamageType == requested, }; } private static MonsterDamageType ResolveDamageMode( MonsterDamageType requested, ICharacterInfo character) { // VTank's ga/hi pair treats Prismatic as an ammunition policy while // retaining normal GameInfoDB element selection for magic. Fists is // special only while the Tusker Fists enchantment is active; // otherwise ga resolves the attack element to Bludgeon. if (requested == MonsterDamageType.Prismatic) return MonsterDamageType.Auto; if (requested == MonsterDamageType.Fists) { return character.ActiveEnchantments.Any( static enchantment => enchantment.SpellId == TuskerFistsSpellId) ? MonsterDamageType.Fists : MonsterDamageType.Bludgeon; } if (requested != MonsterDamageType.Auto) return requested; bool hasWar = IsTrained(character, 34u); if (hasWar) return MonsterDamageType.Auto; if (IsTrained(character, 43u)) return MonsterDamageType.VoidBasic; return IsTrained(character, 33u) ? MonsterDamageType.DrainAuto : MonsterDamageType.Auto; } private static bool IsTrained(ICharacterInfo character, uint skillId) => character.TryGetSkill(skillId, out PluginSkillInfo skill) && skill.Training is PluginSkillTraining.Trained or PluginSkillTraining.Specialized; internal static bool TryClassify( PluginSpellInfo spell, out AttackSpellChoice choice) { string name = Normalize(spell.Name); AttackSpellShape shape; MonsterDamageType damage; if (spell.SpellId == TuskerFistsSpellId || name.Equals("Tusker Fists", StringComparison.OrdinalIgnoreCase)) { shape = AttackSpellShape.Direct; damage = MonsterDamageType.Fists; } else if (name.StartsWith("Harm Other", StringComparison.OrdinalIgnoreCase)) { shape = AttackSpellShape.Harm; damage = MonsterDamageType.Harm; } else if (name.StartsWith( "Drain Health Other", StringComparison.OrdinalIgnoreCase)) { shape = AttackSpellShape.Drain; damage = MonsterDamageType.DrainAuto; } else if (name.StartsWith( "Martyr's Hecatomb", StringComparison.OrdinalIgnoreCase)) { shape = AttackSpellShape.Martyr; damage = MonsterDamageType.DrainAuto; } else { if (!spell.IsOffensive || spell.IsBeneficial || spell.IsDebuff || spell.IsDamageOverTime || DebuffSpellCatalog.TryClassify(spell, out _, out _)) { choice = default; return false; } damage = DamageFromText(spell.Description, name); if (damage == MonsterDamageType.Auto) { choice = default; return false; } if (name.Contains(" Streak", StringComparison.OrdinalIgnoreCase)) shape = AttackSpellShape.Streak; else if (name.Contains(" Arc", StringComparison.OrdinalIgnoreCase)) shape = AttackSpellShape.Arc; else if ((spell.TargetMask == 0u || spell.IsUntargeted) && (name.Contains(" Ring", StringComparison.OrdinalIgnoreCase) || spell.Description.Contains( "outward from the caster", StringComparison.OrdinalIgnoreCase))) { shape = AttackSpellShape.Ring; } else if (spell.TargetMask != 0u || spell.IsProjectile) shape = AttackSpellShape.Direct; else { choice = default; return false; } } choice = new AttackSpellChoice( spell, shape, damage, shape == AttackSpellShape.Ring || spell.IsUntargeted || spell.TargetMask == 0u); return true; } private static MonsterDamageType DamageFromText( string description, string name) { string text = string.Concat(description, " ", name); if (text.Contains("slashing damage", StringComparison.OrdinalIgnoreCase) || text.Contains("Blade", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Slash; if (text.Contains("piercing damage", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Pierce; if (text.Contains("bludgeoning damage", StringComparison.OrdinalIgnoreCase) || text.Contains("Shock Wave", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Bludgeon; if (text.Contains("cold damage", StringComparison.OrdinalIgnoreCase) || text.Contains("Frost", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Cold; if (text.Contains("fire damage", StringComparison.OrdinalIgnoreCase) || text.Contains("Flame", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Fire; if (text.Contains("acid damage", StringComparison.OrdinalIgnoreCase) || text.Contains("Acid", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Acid; if (text.Contains("electric", StringComparison.OrdinalIgnoreCase) || text.Contains("Lightning", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Electric; if (text.Contains("nether", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Nether; // The first six tiers call the piercing line Force Bolt; description // is authoritative, while this name fallback covers sparse fixtures. if (text.Contains("Force", StringComparison.OrdinalIgnoreCase)) return MonsterDamageType.Pierce; return MonsterDamageType.Auto; } private static string Normalize(string name) { const string incantation = "Incantation of "; return name.StartsWith(incantation, StringComparison.OrdinalIgnoreCase) ? name[incantation.Length..] : name; } }