diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs index 90e929ed..7ad88d74 100644 --- a/src/AcDream.App/Plugins/AppAutomationSurface.cs +++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs @@ -160,7 +160,11 @@ internal sealed class AppAutomationSurface { if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta)) continue; - if (!meta.IsSelfTargeted || !meta.IsBeneficial || meta.IsDebuff) + // Beneficial and not a debuff is the whole filter. Requiring the + // self-targeted flag here is what hid every bane: they are cast by + // selecting yourself, and the flag only says "needs no selection". + // Whether a given target accepts the spell is EvaluateGate's job. + if (!meta.IsBeneficial || meta.IsDebuff || meta.IsUntargeted) continue; built.Add(Project(meta)); } @@ -236,6 +240,17 @@ internal sealed class AppAutomationSurface // ── ICharacterInfo ──────────────────────────────────────────────────── public bool IsInWorld => IsAvailable; + public uint ObjectId + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Lifecycle.PlayerGuid ?? 0u; + } + } + public uint CurrentHealth => Vital(LocalPlayerState.VitalKind.Health).Current; public uint MaxHealth => Vital(LocalPlayerState.VitalKind.Health).Maximum; public uint CurrentStamina => Vital(LocalPlayerState.VitalKind.Stamina).Current; diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index ab0db948..23a0a00c 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -93,6 +93,14 @@ public interface ICharacterInfo { bool IsInWorld { get; } + /// + /// The local player's own object id, or 0 when not in world. Needed to + /// target yourself: retail's banes are Item Enchantments whose description + /// says "Target yourself to cast this spell on all of your equipped armor", + /// so a plugin has to select the player before casting them. + /// + uint ObjectId { get; } + uint CurrentHealth { get; } uint MaxHealth { get; } uint CurrentStamina { get; } @@ -119,9 +127,18 @@ public interface ICharacterInfo public interface ISpellCatalog { /// - /// Every spell in the character's spellbook that targets self and is - /// beneficial. + /// Every beneficial spell in the character's spellbook that can be cast on + /// the player. /// + /// + /// This is NOT "spells with the self-targeted flag". That flag means the + /// spell needs no selection; it does not mean a spell without it cannot be + /// cast on you. Banes carry no self flag yet are cast by targeting + /// yourself, so filtering on the flag silently loses every one of them. + /// Spells needing a selection are included here, and + /// decides whether the current + /// target actually accepts them. + /// IReadOnlyList KnownSelfBuffs { get; } bool TryGet(uint spellId, out PluginSpellInfo info); @@ -196,6 +213,7 @@ public sealed class NoOpAutomationSurface } public bool IsInWorld => false; + public uint ObjectId => 0; public uint CurrentHealth => 0; public uint MaxHealth => 0; public uint CurrentStamina => 0; diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs index fa8c7604..ee808bf8 100644 --- a/src/AcDream.Plugins.MossTank/BuffPlan.cs +++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs @@ -21,6 +21,31 @@ public sealed class BuffSettings /// Buff every attribute (VTank's default). public bool BuffAttributes { get; set; } = true; + /// + /// The elemental/physical protections and Armor Self. VTank keeps these in + /// their own profile (BuffProfile_Prots) and casts them by default. + /// + public bool BuffProtections { get; set; } = true; + + /// + /// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift + /// Killer, Defender, Spirit Drinker. + /// + public bool BuffAuras { get; set; } = true; + + /// + /// Banes — armour resistance, cast by targeting yourself. VTank keeps them + /// in their own profile (BuffProfile_Banes) and casts them by default. + /// + public bool BuffBanes { get; set; } = true; + + /// + /// Anything else self-targeted with a duration (regeneration and friends). + /// Off by default: useful to some characters, wasted mana for others, and + /// it is the bucket anything unrecognised falls into. + /// + public bool BuffOther { get; set; } + /// /// Buff trained and specialised skills only — VTank's stated default: /// "automatically buffs every Attribute and Skill you have trained". @@ -94,6 +119,10 @@ public static class BuffPlan BuffTargetKind.Attribute => settings.BuffAttributes && attributeNames.Contains(line.TargetName), BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName), + BuffTargetKind.Protection => settings.BuffProtections, + BuffTargetKind.Aura => settings.BuffAuras, + BuffTargetKind.Bane => settings.BuffBanes, + BuffTargetKind.Other => settings.BuffOther, _ => false, }; if (!wanted) diff --git a/src/AcDream.Plugins.MossTank/BuffProfile.cs b/src/AcDream.Plugins.MossTank/BuffProfile.cs index 47143e06..b13f3712 100644 --- a/src/AcDream.Plugins.MossTank/BuffProfile.cs +++ b/src/AcDream.Plugins.MossTank/BuffProfile.cs @@ -3,15 +3,35 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; -/// What a buff line raises. +/// What a buff line does, which is also how it is toggled. public enum BuffTargetKind { Unknown = 0, + /// Raises a skill: "Increases the caster's Life Magic skill by 10 points." Skill, + /// Raises an attribute: "Increases the caster's Strength by 10 points." Attribute, + /// + /// Defensive self-buff: the elemental/physical protections, and Armor Self. + /// + Protection, + /// + /// A self-cast aura that buffs the wielded weapon or caster — Blood Drinker, + /// Heart Seeker, Swift Killer, Defender, Spirit Drinker. + /// + Aura, + /// + /// A bane: an Item Enchantment raising armour resistance. Cast by selecting + /// YOURSELF — retail's own description says "Target yourself to cast this + /// spell on all of your equipped armor" — so it needs a selection even + /// though it is, in effect, a self buff. + /// + Bane, + /// Any other self-targeted duration buff (regeneration and friends). + Other, } -/// One buff line: a family, what it raises, and its known tiers. +/// One buff line: a family, what it does, and its known tiers. public sealed record BuffLine( uint Family, BuffTargetKind Kind, @@ -19,26 +39,33 @@ public sealed record BuffLine( List Tiers); /// -/// Works out which stat each known self-buff raises, straight from retail data. +/// Works out what each known self-buff does, straight from retail data. /// /// /// /// The client's spell table carries no link between a spell and the stat it /// modifies — that arrives from the server with the enchantment. But retail -/// writes it in the spell's own description: +/// writes it in the spell's own description, so the classification is derived +/// from shipped data rather than hard-coded: /// /// -/// Increases the caster's Life Magic skill by 10 points. -/// Increases the caster's Strength by 10 points. +/// Increases the caster's Life Magic skill by 10 points. -> Skill +/// Increases the caster's Strength by 10 points. -> Attribute +/// Reduces damage the caster takes from Fire by 9%. -> Protection +/// Increases the caster's natural armor by 20 points. -> Protection +/// Increases a weapon's damage value by 2 points. -> Aura /// /// -/// So the mapping is derived from shipped data rather than hard-coded, which -/// matters because the naming is genuinely irregular and no rule would cover -/// it: Invulnerability raises Melee Defense, Impregnability -/// raises Missile Defense, Fealty raises Loyalty, Sprint raises -/// Run, Arcane Enlightenment raises Arcane Lore, and — the one that -/// would silently poison any name-matching scheme — the spell line called -/// Willpower raises the attribute named Self. +/// The irregular naming is why this reads descriptions instead of names: +/// Invulnerability raises Melee Defense, Impregnability raises +/// Missile Defense, Fealty raises Loyalty, Sprint raises Run, and +/// the line called Willpower raises the attribute named Self. +/// +/// +/// Banes are included. They carry no self-targeted flag, but that flag +/// means "needs no selection", not "cannot be cast on you": retail's own text +/// says "Target yourself to cast this spell on all of your equipped armor". So +/// they are classified here and the caller selects the player before casting. /// /// public static partial class BuffProfile @@ -48,6 +75,32 @@ public static partial class BuffProfile RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex IncreasesPattern(); + [GeneratedRegex( + @"^Reduces damage (?:the caster|you) takes? from (?.+?) by ", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ProtectionPattern(); + + /// + /// Retail's own wording for the weapon/caster auras. Matched on the + /// description rather than the "Aura of" name prefix so the older + /// non-aura phrasings classify the same way. + /// + [GeneratedRegex( + @"\b(a weapon's|weapon or magic caster|magic caster|missile weapon's)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex AuraPattern(); + + /// + /// Banes, matched on retail's own instruction rather than on the word + /// "Bane": "Target yourself to cast this spell on all of your equipped + /// armor." That sentence is what says these are cast at the player, which + /// the self-targeted flag does not. + /// + [GeneratedRegex( + @"Target yourself to cast this spell on all of your equipped", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BanePattern(); + /// /// Retail's spell text says "Assess Monster" where the skill table says /// "Assess Creature". Without this the skill silently never matches and its @@ -59,9 +112,16 @@ public static partial class BuffProfile ["Assess Monster"] = "Assess Creature", }; + /// The six primary attributes, by the names retail's spells use. + private static readonly HashSet AttributeNames = + new(StringComparer.OrdinalIgnoreCase) + { + "Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self", + }; + /// /// Group the character's known self-buffs into buff lines, keeping only - /// lines that raise a stat and last long enough to be worth maintaining. + /// those that last long enough to be worth maintaining. /// public static List Build(IReadOnlyList knownSelfBuffs) { @@ -75,8 +135,16 @@ public static partial class BuffProfile if (spell.DurationSeconds <= 0f) continue; - if (!TryParseTarget(spell.Description, out BuffTargetKind kind, out string target)) - continue; + Classify(spell.Description, out BuffTargetKind kind, out string target); + if (kind == BuffTargetKind.Unknown) + { + // Nothing self-targeted is discarded for being unrecognised. + // Dropping what the patterns do not match is how protections + // and weapon auras went missing without a word; an unknown + // spell belongs in Other, which the user can switch on. + kind = BuffTargetKind.Other; + target = spell.Name; + } if (!byFamily.TryGetValue(spell.Family, out BuffLine? line)) { @@ -92,28 +160,77 @@ public static partial class BuffProfile return byFamily.Values.ToList(); } - /// Parse "Increases the caster's X [skill] by N points." - public static bool TryParseTarget( + /// Classify one spell from its retail description. + public static void Classify( string? description, out BuffTargetKind kind, out string target) { kind = BuffTargetKind.Unknown; target = string.Empty; - if (string.IsNullOrEmpty(description)) - return false; + if (string.IsNullOrWhiteSpace(description)) + return; - Match match = IncreasesPattern().Match(description); - if (!match.Success) - return false; + // Banes first: their text also mentions armour resistance, and the + // "target yourself" instruction is what actually identifies them. + if (BanePattern().IsMatch(description)) + { + kind = BuffTargetKind.Bane; + target = "equipped armor"; + return; + } - target = match.Groups["target"].Value.Trim(); + // Auras next: "Increases a weapon's damage value" would otherwise be + // read as raising something on the caster. + if (AuraPattern().IsMatch(description)) + { + kind = BuffTargetKind.Aura; + target = "weapon"; + return; + } + + Match protection = ProtectionPattern().Match(description); + if (protection.Success) + { + kind = BuffTargetKind.Protection; + target = protection.Groups["target"].Value.Trim(); + return; + } + + Match increases = IncreasesPattern().Match(description); + if (!increases.Success) + return; + + target = increases.Groups["target"].Value.Trim(); if (target.Length == 0) - return false; - if (SkillNameAliases.TryGetValue(target, out string? alias)) - target = alias; + return; - // The word "skill" is what separates a skill buff from an attribute - // buff in retail's own wording. - kind = match.Groups["skill"].Success ? BuffTargetKind.Skill : BuffTargetKind.Attribute; - return true; + if (increases.Groups["skill"].Success) + { + // The word "skill" is what separates a skill buff from an attribute + // buff in retail's own wording. + kind = BuffTargetKind.Skill; + if (SkillNameAliases.TryGetValue(target, out string? alias)) + target = alias; + return; + } + + if (AttributeNames.Contains(target)) + { + kind = BuffTargetKind.Attribute; + return; + } + + // "Increases the caster's natural armor by 20 points" — defensive, but + // neither a skill nor an attribute. + kind = target.Contains("armor", StringComparison.OrdinalIgnoreCase) + ? BuffTargetKind.Protection + : BuffTargetKind.Other; + } + + /// Back-compatible shim for the skill/attribute cases. + public static bool TryParseTarget( + string? description, out BuffTargetKind kind, out string target) + { + Classify(description, out kind, out target); + return kind != BuffTargetKind.Unknown; } } diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 95e71546..532e5321 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -35,6 +35,12 @@ internal sealed class MossTankPanel private int _castThisPass; private string _status = "Idle."; + /// + /// What the player had selected before the pass, so targeting yourself for + /// banes does not quietly steal the selection and leave it changed. + /// + private uint? _selectionBeforePass; + public MossTankPanel(IPluginHost host) => _host = host; // ── main panel bindings ─────────────────────────────────────────────── @@ -116,6 +122,14 @@ internal sealed class MossTankPanel $"Trained skills only: {OnOff(_buffSettings.BuffTrainedSkillsOnly)}"; public string AttributesText => $"Buff attributes: {OnOff(_buffSettings.BuffAttributes)}"; + public string ProtectionsText => + $"Buff protections: {OnOff(_buffSettings.BuffProtections)}"; + public string AurasText => + $"Buff weapon auras: {OnOff(_buffSettings.BuffAuras)}"; + public string BanesText => + $"Buff banes (armor): {OnOff(_buffSettings.BuffBanes)}"; + public string OtherText => + $"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}"; public Action DifficultyDown => () => _buffSettings.SkillExcessOverDifficulty = Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5); @@ -141,6 +155,11 @@ internal sealed class MossTankPanel _buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly; public Action ToggleAttributes => () => _buffSettings.BuffAttributes = !_buffSettings.BuffAttributes; + public Action ToggleProtections => () => + _buffSettings.BuffProtections = !_buffSettings.BuffProtections; + public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras; + public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes; + public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther; private static double Step(double value, int direction) => Math.Clamp(Math.Round(value + direction * 0.05, 2), 0.0, 1.0); @@ -179,6 +198,7 @@ internal sealed class MossTankPanel _queueIndex = 0; _castThisPass = 0; _sinceProgress = 0; + _selectionBeforePass = _host.Selection.SelectedObjectId; _running = _queue.Count > 0; _status = _queue.Count == 0 ? "Nothing to buff." @@ -195,6 +215,16 @@ internal sealed class MossTankPanel _queue = new List(); _queueIndex = 0; _status = status; + RestoreSelection(); + } + + private void RestoreSelection() + { + if (_selectionBeforePass is { } previous && previous != 0) + _host.Selection.Select(previous); + else + _host.Selection.Clear(); + _selectionBeforePass = null; } private List BuildPlan(IAutomationSurface automation, bool force) => @@ -286,6 +316,22 @@ internal sealed class MossTankPanel private bool TryCast( IAutomationSurface automation, PluginSpellInfo spell, string label) { + // A spell without the self-targeted flag still needs a target, and for + // a bane that target is the player: retail's text is "Target yourself + // to cast this spell on all of your equipped armor". Select first, or + // the gate refuses for want of a target. + if (!spell.IsSelfTargeted) + { + uint self = automation.Character.ObjectId; + if (self == 0) + { + _status = $"{spell.Name}: no self target"; + return false; + } + if (_host.Selection.SelectedObjectId != self) + _host.Selection.Select(self); + } + PluginCastGate gate = automation.Magic.EvaluateGate(spell.SpellId); if (gate != PluginCastGate.Ready) { diff --git a/src/AcDream.Plugins.MossTank/mosstank-settings.xml b/src/AcDream.Plugins.MossTank/mosstank-settings.xml index 3f5f8fbe..0e2a2317 100644 --- a/src/AcDream.Plugins.MossTank/mosstank-settings.xml +++ b/src/AcDream.Plugins.MossTank/mosstank-settings.xml @@ -4,7 +4,7 @@ vocabulary, and the toggle is just another Action. Adjuster buttons rather than typed fields, because editable text in a plugin panel needs keyboard routing plumbed through first. --> - +