using System.Globalization; using System.Text; using AcDream.Plugin.Abstractions; using AcDream.Plugins.MossTank.Expressions; namespace AcDream.Plugins.MossTank; /// /// The panel's binding object and the buff loop's state machine. /// /// /// Everything here runs on the host's update thread — the button clicks and the /// tick all arrive there — so no locking is used, deliberately: a lock would /// imply a second thread that does not exist. /// internal sealed partial class MossTankPanel { private enum TankTab { Options, Profiles, Vitals, Monsters, Items, Consumables, Buffs, Route, Meta, } /// /// Give up on a pass that stops making progress. Generous, because the /// pacing is now the server acknowledging each cast rather than a fixed /// delay, and a slow server should not read as a stall. /// private const double StallTimeoutSeconds = 30.0; /// /// The retained markup host reads label bindings while drawing. Coverage /// walks the complete known-buff table, so it belongs on the update side /// and only needs to refresh at human-readable cadence. /// private const double CoverageRefreshIntervalSeconds = 1.0; private readonly IPluginHost _host; private readonly BuffSettings _buffSettings = new(); private readonly VitalSettings _vitalSettings = new(); private readonly CombatSettings _combatSettings = new(); private readonly InventorySettings _inventorySettings = new(); private readonly NavigationSettings _navigationSettings = new(); private readonly MossTankProfileStore _profiles; private readonly MossTankLootProfileStore _lootProfiles; private readonly MossTankRouteProfileStore _routeProfiles; private readonly MossTankMetaProfileStore _metaProfiles; private readonly MetaViewManager _metaViews; private readonly CombatController _combat; private readonly VitalRechargeController _vitalRecharge; private readonly DispelController _dispel; private readonly InventoryMaintenanceController _inventoryMaintenance; private readonly CraftingController _crafting; private readonly ItemManaRechargeController _itemManaRecharge; private readonly LootController _loot; private readonly ProfileGiveController _profileGive; private readonly NavigationController _navigation; private readonly FellowshipManager _fellowshipManager; private readonly MossTankExpressionRuntime _expressions; private MetaProfile _metaProfile; private readonly MetaEngine _meta; // A pass works through a queue captured at the start rather than a plan // re-derived each tick. Force Buff deliberately ignores what is already in // force, so a re-derived plan would never shrink and the pass could never // reach "done" -- it would cast forever. private List _queue = new(); private int _queueIndex; private bool _running; private bool _forcePass; private bool _announceBuffPass; private double _automaticBuffScanRemaining; private double _buffCastRecastRemaining; private bool _fastCastMovementActive; private long _fastCastStartCompletionRevision; private double _fastCastMovementElapsed; private double _randomHelperRemaining; private int _randomHelperCursor; private double _sinceProgress; private int _castThisPass; private string _status = "Idle."; private string _vitals = string.Empty; private string _coverage = string.Empty; private bool _vitalsInitialized; private uint _currentHealth; private uint _maxHealth; private uint _currentStamina; private uint _maxStamina; private uint _currentMana; private uint _maxMana; private IReadOnlyList? _coverageSpellSnapshot; private int _coverageBuffLineCount; private double _coverageRefreshRemaining; private TankTab _activeTab = TankTab.Options; private readonly HashSet _noBuffItemNames = new(StringComparer.Ordinal); private string _profileNotice = "Select an inventory item, then add it to this profile."; private string _profileNameDraft = string.Empty; private string _profileLifecycleNotice = "Macro settings are stored by character."; private IReadOnlyList _monsterRows = Array.Empty(); private int _selectedMonsterRule; private string _monsterExpressionDraft = "DEFAULT"; private string _monsterEditorNotice = "Select a rule to edit."; // PluginCore's three columns deliberately expose different cycles. The // damage column is eDamageElement 0..13; Ex. Vuln omits Harm/Void/etc.; // PetDmg adds VTank's PAuto sentinel. A shared Enum.GetNames list made // several choices visible in columns where retail could never select // them, and also exposed our internal "Electric"/"PlayerAuto" names. private static readonly string[] MonsterDamageNames = [ "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", "Fire", "Harm", "Auto", "Void Basic", "Drain Auto", "Prismatic", "Random", "Fists", ]; private static readonly string[] MonsterExtraVulnerabilityNames = [ "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", "Fire", "Auto", "None", ]; private static readonly string[] MonsterPetDamageNames = [ "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", "Fire", "PAuto", "Auto", "None", ]; private IReadOnlyList _itemRows = Array.Empty(); private IReadOnlyList _consumableRows = Array.Empty(); private int _selectedItemRow; private int _selectedConsumableRow; private bool _lootEditorVisible; private bool _advancedOptionsVisible; private int _selectedAdvancedOption; private string _advancedOptionValueDraft = string.Empty; private string _advancedOptionNotice = "All VTank settings are available here."; private IReadOnlyList _lootRuleRows = Array.Empty(); private int _selectedLootRule; private string _lootExpressionDraft = "*"; private string _lootEditorNotice = "Add a rule or select one to edit."; private string _lootProfileNameDraft = string.Empty; private IReadOnlyList _routeRows = Array.Empty(); private int _selectedRouteWaypoint; private string _routeProfileNameDraft = string.Empty; private string _routeNotice = "Add the current position or a selected object."; private string _routeChatDraft = "/ls"; private int _routePauseSeconds = 5; private RouteRecallKind _routeRecallKind = RouteRecallKind.PrimaryPortal; private bool _routeAddToEnd = true; private IReadOnlyList _metaRows = Array.Empty(); private int _selectedMetaRule; private string _metaProfileNameDraft = string.Empty; private string _metaStateDraft = MetaEngine.DefaultState; private string _metaConditionTextDraft = string.Empty; private string _metaActionTextDraft = string.Empty; private string _metaSecondaryTextDraft = string.Empty; private MetaConditionKind _metaConditionKind = MetaConditionKind.Always; private MetaActionKind _metaActionKind = MetaActionKind.None; private int _metaNumber; private int _metaSecondaryNumber; private string _metaNotice = "Add a rule or select one to edit."; private bool _applyingProfileOptions; private bool _initialized; private bool _firstRunGuidancePending; private bool _automationWasAvailable; /// /// 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; _firstRunGuidancePending = NeedsFirstRunGuidance(host); _profiles = new MossTankProfileStore(host); _profiles.BindCharacter(host.Automation.Character.Name); _profiles.LoadCurrent( _combatSettings, _buffSettings, _vitalSettings, _inventorySettings, _noBuffItemNames); _lootProfiles = new MossTankLootProfileStore(host); _lootProfiles.BindCharacter(host.Automation.Character.Name); if (!_lootProfiles.LoadCurrent( _inventorySettings.Loot.Rules, _inventorySettings.Loot)) { _lootProfiles.SaveCurrent( _inventorySettings.Loot.Rules, _inventorySettings.Loot); } _routeProfiles = new MossTankRouteProfileStore(host); _routeProfiles.BindCharacter(host.Automation.Character.Name); if (!_routeProfiles.LoadCurrent(_navigationSettings)) _routeProfiles.SaveCurrent(_navigationSettings); _combat = new CombatController(host, _combatSettings, _vitalSettings); _vitalRecharge = new VitalRechargeController( host, _vitalSettings, _combatSettings); _dispel = new DispelController(host, _vitalSettings); _inventoryMaintenance = new InventoryMaintenanceController( host, _inventorySettings); _crafting = new CraftingController( host, _inventorySettings, _combatSettings); _combat.BindAmmunitionCraftRequest( _crafting.CanRequest, _crafting.Request); _itemManaRecharge = new ItemManaRechargeController( host, _inventorySettings, _combatSettings); _loot = new LootController( host, _inventorySettings.Loot); _profileGive = new ProfileGiveController(host, _lootProfiles); _navigation = new NavigationController(host, _navigationSettings); _fellowshipManager = new FellowshipManager(host); _metaProfiles = new MossTankMetaProfileStore(host); _metaViews = new MetaViewManager(host); _metaProfiles.BindCharacter(host.Automation.Character.Name); _metaProfile = _metaProfiles.LoadCurrent(); _expressions = new MossTankExpressionRuntime(host); _meta = new MetaEngine( host, _expressions, _metaProfile, new MetaServices { IsNavigationRouteEmpty = () => _navigationSettings.Waypoints.Count == 0, NeedsBuff = () => BuildPlan(_host.Automation, force: false).Count != 0, DistanceFromAnyRoutePoint = DistanceFromAnyRoutePoint, CountMonstersByPriority = CountMonstersByPriority, LoadEmbeddedNavigationRoute = LoadEmbeddedNavigationRoute, GetOption = GetMetaOption, SetOption = SetMetaOption, CreateView = _metaViews.Create, DestroyView = _metaViews.Destroy, DestroyAllViews = _metaViews.DestroyAll, }); RegisterVtankExpressionFunctions(); _initialized = true; ApplyPersistedOptionOverrides(); RefreshMonsterEditor(); RefreshItemEditors(); RefreshLootEditor(); RefreshRouteEditor(); RefreshMetaEditor(); _automationWasAvailable = host.Automation.IsAvailable; } // ── main panel bindings ─────────────────────────────────────────────── public Action Buff => StartOrStop; public Action ForceBuff => StartForceBuff; public Action CancelForceBuff => CancelForceBuffCore; public Action ToggleCombat => ToggleMacro; public Action ToggleCombatEnabled => () => { _combatSettings.Enabled = !_combatSettings.Enabled; SaveProfile(); }; public Action ToggleAutoFellowManagement => () => SetMetaOption( "AutoFellowManagement", ExpressionValue.Boolean(!AutoFellowManagementEnabled)); public Action ShowOptions => () => SelectTab(TankTab.Options); public Action ShowProfiles => () => SelectTab(TankTab.Profiles); public Action ShowVitals => () => SelectTab(TankTab.Vitals); public Action ShowMonsters => () => SelectTab(TankTab.Monsters); public Action ShowItems => () => SelectTab(TankTab.Items); public Action ShowConsumables => () => SelectTab(TankTab.Consumables); public Action ShowBuffs => () => SelectTab(TankTab.Buffs); public Action ShowRoute => () => SelectTab(TankTab.Route); public Action ShowMeta => () => SelectTab(TankTab.Meta); /// Keeps the gameplay window off login/character-select screens. public bool WindowAvailable => _host.Automation.IsAvailable; internal ExpressionValue EvaluateExpression(string source) => _expressions.Evaluate(source); internal IReadOnlyCollection ExpressionFunctionNames => _expressions.Functions.Select(static function => function.Name).ToArray(); public bool OptionsSelected => _activeTab == TankTab.Options; public bool ProfilesSelected => _activeTab == TankTab.Profiles; public bool VitalsSelected => _activeTab == TankTab.Vitals; public bool MonstersSelected => _activeTab == TankTab.Monsters; public bool ItemsSelected => _activeTab == TankTab.Items; public bool ConsumablesSelected => _activeTab == TankTab.Consumables; public bool BuffsSelected => _activeTab == TankTab.Buffs; public bool RouteSelected => _activeTab == TankTab.Route; public bool MetaSelected => _activeTab == TankTab.Meta; public bool OptionsTabEnabled => true; public bool ProfilesTabEnabled => true; public bool VitalsTabEnabled => true; public bool MonstersTabEnabled => true; public bool ItemsTabEnabled => true; public bool ConsumablesTabEnabled => true; public bool BuffsTabEnabled => true; public bool RouteTabEnabled => true; public bool MetaTabEnabled => true; public bool OptionsVisible => OptionsSelected && !_lootEditorVisible && !_advancedOptionsVisible; public bool ProfilesVisible => ProfilesSelected && !_lootEditorVisible; public bool VitalsVisible => VitalsSelected && !_lootEditorVisible; public bool MonstersVisible => MonstersSelected && !_lootEditorVisible; public bool ItemsVisible => ItemsSelected && !_lootEditorVisible; public bool ConsumablesVisible => ConsumablesSelected && !_lootEditorVisible; public bool BuffsVisible => BuffsSelected && !_lootEditorVisible; public bool RouteVisible => RouteSelected && !_lootEditorVisible; public bool MetaVisible => MetaSelected && !_lootEditorVisible; public bool LootEditorVisible => _lootEditorVisible; public bool AdvancedOptionsVisible => _advancedOptionsVisible; public IReadOnlyList AdvancedOptionNames => VtankOptionCatalog.Names; public int SelectedAdvancedOptionIndex => _selectedAdvancedOption; public string AdvancedOptionName => VtankOptionCatalog.Names[ Math.Clamp( _selectedAdvancedOption, 0, VtankOptionCatalog.Names.Length - 1)]; public string AdvancedOptionValueDraft => _advancedOptionValueDraft; public string AdvancedOptionNotice => _advancedOptionNotice; public Action ShowAdvancedOptions => () => { _advancedOptionsVisible = true; LoadAdvancedOptionDraft(); }; public Action HideAdvancedOptions => () => _advancedOptionsVisible = false; public Action SelectAdvancedOption => index => { _selectedAdvancedOption = Math.Clamp( index, 0, VtankOptionCatalog.Names.Length - 1); LoadAdvancedOptionDraft(); }; public Action SetAdvancedOptionValueDraft => value => _advancedOptionValueDraft = value; public Action SubmitAdvancedOption => value => { _advancedOptionValueDraft = value; ApplyAdvancedOptionCore(); }; public Action ApplyAdvancedOption => ApplyAdvancedOptionCore; /// The button is Force Buff; while a pass runs it cancels. public string BuffButtonText => _running ? "Stop buffing" : "Force buff"; public string BuffStatus => _status; public string CombatButtonText => _combat.ButtonText; public string CombatStatus => _combat.Status; public string CombatTarget => _combat.TargetText; public string CombatMode => _combat.ModeText; public string VitalStatus => _vitalRecharge.Status; public string DispelStatus => _dispel.Status; public string InventoryMaintenanceStatus => _inventoryMaintenance.Status; public string CraftingStatus => _crafting.Status; public string ItemManaRechargeStatus => _itemManaRecharge.Status; public string LootStatus => _loot.Status; public bool CombatEnabled => _combatSettings.Enabled; public bool BuffingEnabled => _buffSettings.Enabled; public bool IdlePeaceModeEnabled => _combatSettings.IdlePeaceMode; public bool IdleBuffTopoffEnabled => _buffSettings.IdleBuffTopoff; public bool ManaChargesWhenOffEnabled => _inventorySettings.ManaChargesWhenOff; public bool AutoFellowManagementEnabled => _combatSettings.AutoFellowManagement; public string FellowshipManagerStatus => _fellowshipManager.Status; public bool AutoStackEnabled => _inventorySettings.AutoStack; public bool AutoCramEnabled => _inventorySettings.AutoCram; public bool AutoCraftItemsEnabled => _inventorySettings.AutoCraftItems; public bool CastDispelSelfEnabled => _vitalSettings.CastDispelSelf; public bool UseDispelItemsEnabled => _vitalSettings.UseDispelItems; public bool RefillWornManaEnabled => _inventorySettings.RefillWornMana; public float RefillWornManaValue => _inventorySettings.RefillWornManaPercent / 100f; public string RefillWornManaText => $"Refill worn mana below {_inventorySettings.RefillWornManaPercent}%"; public string ItemProfileText => ProfileText( "Weapons / Wands / Shields / Pets", _combatSettings.CombatItemNames); public string ConsumableProfileText => ProfileText( "Gems / Food / Kits / Potions / Charges / Grenades / Lockpicks", _combatSettings.ConsumableNames); public string ProfileNotice => _profileNotice; public Action AddSelectedItem => () => AddSelectedProfileItem(noBuffs: false); public Action AddSelectedItemNoBuffs => () => AddSelectedProfileItem(noBuffs: true); public Action AddSelectedConsumable => AddSelectedConsumableCore; public Action AddAllPeas => AddAllPeasCore; public IReadOnlyList ItemRows => _itemRows; public IReadOnlyList ConsumableRows => _consumableRows; public int SelectedItemRowIndex => _selectedItemRow; public int SelectedConsumableRowIndex => _selectedConsumableRow; public Action SelectItemRow => index => _selectedItemRow = ClampRow(index, _itemRows.Count); public Action SelectConsumableRow => index => _selectedConsumableRow = ClampRow(index, _consumableRows.Count); public Action RemoveSelectedItem => RemoveSelectedItemCore; public Action RemoveSelectedConsumable => RemoveSelectedConsumableCore; public Action ToggleAutoStack => () => { _inventorySettings.AutoStack = !_inventorySettings.AutoStack; _inventoryMaintenance.Reset(); SaveProfile(); }; public Action ToggleAutoCram => () => { _inventorySettings.AutoCram = !_inventorySettings.AutoCram; _inventoryMaintenance.Reset(); SaveProfile(); }; public Action ToggleAutoCraftItems => () => { _inventorySettings.AutoCraftItems = !_inventorySettings.AutoCraftItems; _crafting.Reset(); SaveProfile(); }; public Action ToggleCastDispelSelf => () => SetMetaOption( "CastDispelSelf", ExpressionValue.Boolean(!_vitalSettings.CastDispelSelf)); public Action ToggleUseDispelItems => () => SetMetaOption( "UseDispelItems", ExpressionValue.Boolean(!_vitalSettings.UseDispelItems)); public Action ToggleRefillWornMana => () => { _inventorySettings.RefillWornMana = !_inventorySettings.RefillWornMana; _itemManaRecharge.Reset(); SaveProfile(); }; public Action SetRefillWornMana => value => { _inventorySettings.RefillWornManaPercent = Math.Clamp( (int)MathF.Round(value * 100f), 0, 99); SaveProfile(); }; // ── Loot profile / editor ──────────────────────────────────────────── public bool LootEnabled => _inventorySettings.Loot.Enabled; public bool LootPriorityBoostEnabled => _inventorySettings.Loot.PriorityBoost; public bool LootAllCorpsesEnabled => _inventorySettings.Loot.LootAllCorpses; public bool LootFellowCorpsesEnabled => _inventorySettings.Loot.LootFellowCorpses; public bool LootOnlyRareCorpsesEnabled => _inventorySettings.Loot.LootOnlyRareCorpses; public bool ReadUnknownScrollsEnabled => _inventorySettings.Loot.ReadUnknownScrolls; public IReadOnlyList LootProfileNames => _lootProfiles.AvailableNames; public string LootProfileName => _lootProfiles.Selected; public string LootProfileNameDraft => _lootProfileNameDraft; public IReadOnlyList LootClassifierNames { get { var names = new List { "VTClassic" }; names.AddRange(_host.LootClassifiers.Available.Select(FormatClassifier)); string selected = SelectedLootClassifier; if (!names.Contains(selected, StringComparer.Ordinal)) names.Add(selected); return names; } } public string SelectedLootClassifier { get { string id = _inventorySettings.Loot.ExternalClassifierId; if (string.IsNullOrWhiteSpace(id)) return "VTClassic"; foreach (PluginLootClassifierInfo info in _host.LootClassifiers.Available) { if (string.Equals(info.Id, id, StringComparison.OrdinalIgnoreCase)) return FormatClassifier(info); } return $"Unavailable [{id}]"; } } public string LootRangeText => $"Corpse range {_inventorySettings.Loot.CorpseApproachRange:0}m"; public IReadOnlyList LootRuleRows => _lootRuleRows; public int SelectedLootRuleIndex => _selectedLootRule; public string LootExpressionDraft => _lootExpressionDraft; public string LootEditorNotice => _lootEditorNotice; public IReadOnlyList LootActionNames => Enum.GetNames(); public string SelectedLootAction => SelectedLootRule?.Action.ToString() ?? LootAction.Keep.ToString(); public string LootPriorityText => $"Priority {SelectedLootRule?.Priority ?? 0}"; public string LootKeepCountText => $"Keep up to {SelectedLootRule?.KeepCount ?? 1}"; public Action ToggleLooting => () => { _inventorySettings.Loot.Enabled = !_inventorySettings.Loot.Enabled; _loot.Reset(); SaveProfile(); }; public Action ToggleLootPriorityBoost => () => { _inventorySettings.Loot.PriorityBoost = !_inventorySettings.Loot.PriorityBoost; SaveProfile(); }; public Action ToggleLootAllCorpses => () => { _inventorySettings.Loot.LootAllCorpses = !_inventorySettings.Loot.LootAllCorpses; SaveProfile(); }; public Action ToggleLootFellowCorpses => () => { _inventorySettings.Loot.LootFellowCorpses = !_inventorySettings.Loot.LootFellowCorpses; SaveProfile(); }; public Action ToggleLootOnlyRareCorpses => () => { _inventorySettings.Loot.LootOnlyRareCorpses = !_inventorySettings.Loot.LootOnlyRareCorpses; SaveProfile(); }; public Action ToggleReadUnknownScrolls => () => { _inventorySettings.Loot.ReadUnknownScrolls = !_inventorySettings.Loot.ReadUnknownScrolls; SaveProfile(); }; public Action ShowLootEditor => () => { _activeTab = TankTab.Profiles; _lootEditorVisible = true; RefreshLootEditor(); }; public Action SelectLootProfile => SelectLootProfileCore; public Action SelectLootClassifier => value => { string? id = ResolveClassifierId(value); if (id is null) { _profileLifecycleNotice = $"Loot engine '{value}' is unavailable."; return; } _inventorySettings.Loot.ExternalClassifierId = id; _loot.Reset(); SaveProfile(); _profileLifecycleNotice = id.Length == 0 ? "Loot engine set to VTClassic." : $"Loot engine set to {SelectedLootClassifier}."; }; public Action SetLootProfileNameDraft => value => _lootProfileNameDraft = value; public Action CreateNamedLootProfile => value => { _lootProfileNameDraft = value; CreateLootProfileCore(copyCurrent: false); }; public Action CreateLootProfile => () => CreateLootProfileCore(copyCurrent: false); public Action CopyLootProfile => () => CreateLootProfileCore(copyCurrent: true); public Action ClearLootProfile => ClearLootProfileCore; public Action CloseLootEditor => () => _lootEditorVisible = false; public Action SelectLootRule => SelectLootRuleCore; public Action SetLootExpressionDraft => value => _lootExpressionDraft = value; public Action ApplyLootExpression => value => { _lootExpressionDraft = value; ApplyLootExpressionCore(); }; public Action ApplyLootRule => ApplyLootExpressionCore; public Action AddLootRule => AddLootRuleCore; public Action RemoveLootRule => RemoveLootRuleCore; public Action MoveLootRuleUp => () => MoveLootRule(-1); public Action MoveLootRuleDown => () => MoveLootRule(1); public Action SelectLootAction => SelectLootActionCore; public Action LootPriorityDown => () => UpdateSelectedLootRule(rule => rule.Priority = Math.Max(-1000, rule.Priority - 1)); public Action LootPriorityUp => () => UpdateSelectedLootRule(rule => rule.Priority = Math.Min(1000, rule.Priority + 1)); public Action LootKeepCountDown => () => UpdateSelectedLootRule(rule => rule.KeepCount = Math.Max(0, rule.KeepCount - 1)); public Action LootKeepCountUp => () => UpdateSelectedLootRule(rule => rule.KeepCount = Math.Min(100000, rule.KeepCount + 1)); public Action LootRangeDown => () => { _inventorySettings.Loot.CorpseApproachRange = Math.Max( 2f, _inventorySettings.Loot.CorpseApproachRange - 2f); SaveProfile(); }; public Action LootRangeUp => () => { _inventorySettings.Loot.CorpseApproachRange = Math.Min( 100f, _inventorySettings.Loot.CorpseApproachRange + 2f); SaveProfile(); }; // ── Navigation / route profiles ────────────────────────────────────── public bool NavigationEnabled => _navigationSettings.Enabled; public bool NavigationPriorityEnabled => _navigationSettings.Priority; public bool FollowAroundCornersEnabled => _navigationSettings.FollowAroundCorners; public bool OpenDoorsEnabled => _navigationSettings.OpenDoors; public string NavigationStatus => _navigation.Status; public IReadOnlyList RouteRows => _routeRows; public int SelectedRouteWaypointIndex => _selectedRouteWaypoint; public IReadOnlyList RouteModeNames => Enum.GetNames(); public string SelectedRouteMode => _navigationSettings.Mode.ToString(); public IReadOnlyList RouteRecallNames => Enum.GetNames(); public string SelectedRouteRecall => _routeRecallKind.ToString(); public IReadOnlyList RouteProfileNames => _routeProfiles.AvailableNames; public string SelectedRouteProfile => _routeProfiles.Selected; public string RouteProfileNameDraft => _routeProfileNameDraft; public string RouteNotice => _routeNotice; public string RouteChatDraft => _routeChatDraft; public string RoutePauseText => $"{_routePauseSeconds} seconds"; public string RouteMinimumDistanceText => string.Create( CultureInfo.InvariantCulture, $"Follow/Nav Min Distance: {_navigationSettings.MinimumDistanceMeters:0.0}m"); public string RouteFollowTargetText => _navigationSettings.FollowTargetObjectId == 0u ? "Follow target: [None]" : $"Follow target: {_navigationSettings.FollowTargetName}"; public string RouteAddPositionText => _routeAddToEnd ? "Add to End" : "Insert After Selection"; public Action ToggleNavigation => () => { _navigationSettings.Enabled = !_navigationSettings.Enabled; _navigation.Reset(); SaveRouteProfile(); }; public Action ToggleNavigationPriority => () => { _navigationSettings.Priority = !_navigationSettings.Priority; SaveRouteProfile(); }; public Action ToggleFollowAroundCorners => () => { _navigationSettings.FollowAroundCorners = !_navigationSettings.FollowAroundCorners; _navigation.Reset(); SaveRouteProfile(); }; public Action ToggleOpenDoors => () => { _navigationSettings.OpenDoors = !_navigationSettings.OpenDoors; _navigation.Reset(); SaveRouteProfile(); }; public Action SelectRouteMode => value => { if (!Enum.TryParse(value, ignoreCase: true, out RouteMode mode)) return; _navigationSettings.Mode = mode; if (mode == RouteMode.Target) CaptureFollowTarget(); _navigation.Reset(); RefreshRouteEditor(); SaveRouteProfile(); }; public Action SelectRouteRecall => value => { if (Enum.TryParse(value, ignoreCase: true, out RouteRecallKind recall)) _routeRecallKind = recall; }; public Action SelectRouteWaypoint => index => _selectedRouteWaypoint = ClampRow(index, _navigationSettings.Waypoints.Count); public Action AddRoutePoint => AddRoutePointCore; public Action AddRouteUseSelected => () => AddSelectedObjectWaypoint(RouteWaypointType.UseNpc); public Action AddRouteOpenVendor => () => AddSelectedObjectWaypoint(RouteWaypointType.OpenVendor); public Action AddRoutePortal => () => AddSelectedObjectWaypoint(RouteWaypointType.PortalByName); public Action AddRouteRecall => AddRouteRecallCore; public Action AddRoutePause => AddRoutePauseCore; public Action AddRouteChat => AddRouteChatCore; public Action AddRouteCheckpoint => AddRouteCheckpointCore; public Action AddRouteJump => AddRouteJumpCore; public Action RemoveRouteWaypoint => RemoveRouteWaypointCore; public Action MoveRouteWaypointUp => () => MoveRouteWaypoint(-1); public Action MoveRouteWaypointDown => () => MoveRouteWaypoint(1); public Action ToggleRouteAddPosition => () => _routeAddToEnd = !_routeAddToEnd; public Action RoutePauseDown => () => _routePauseSeconds = Math.Max(0, _routePauseSeconds - 1); public Action RoutePauseUp => () => _routePauseSeconds = Math.Min(3600, _routePauseSeconds + 1); public Action RouteMinimumDistanceDown => () => { _navigationSettings.MinimumDistanceMeters = Math.Max( 0.5d, _navigationSettings.MinimumDistanceMeters - 0.5d); SaveRouteProfile(); }; public Action RouteMinimumDistanceUp => () => { _navigationSettings.MinimumDistanceMeters = Math.Min( 50d, _navigationSettings.MinimumDistanceMeters + 0.5d); SaveRouteProfile(); }; public Action SetRouteChatDraft => value => _routeChatDraft = value; public Action SetRouteProfileNameDraft => value => _routeProfileNameDraft = value; public Action SelectRouteProfile => SelectRouteProfileCore; public Action CreateNamedRouteProfile => value => { _routeProfileNameDraft = value; CreateRouteProfileCore(copyCurrent: false); }; public Action CreateRouteProfile => () => CreateRouteProfileCore(copyCurrent: false); public Action CopyRouteProfile => () => CreateRouteProfileCore(copyCurrent: true); public Action ClearRouteProfile => ClearRouteProfileCore; public Action SetFollowTarget => CaptureFollowTarget; // ── Meta profile / editor ──────────────────────────────────────────── public bool MetaEnabled => _meta.Enabled; public string MetaState => _meta.CurrentState; public string MetaStateText => $"State: {MetaState}"; public string MetaStatus => _meta.Status; public IReadOnlyList MetaRows => _metaRows; public int SelectedMetaRuleIndex => _selectedMetaRule; public IReadOnlyList MetaConditionNames => Enum.GetNames(); public IReadOnlyList MetaActionNames => Enum.GetNames(); public string SelectedMetaCondition => _metaConditionKind.ToString(); public string SelectedMetaAction => _metaActionKind.ToString(); public string MetaStateDraft => _metaStateDraft; public string MetaConditionTextDraft => _metaConditionTextDraft; public string MetaActionTextDraft => _metaActionTextDraft; public string MetaSecondaryTextDraft => _metaSecondaryTextDraft; public string MetaNumberText => _metaNumber.ToString(CultureInfo.InvariantCulture); public string MetaNumberLabel => $"N: {MetaNumberText}"; public string MetaSecondaryNumberText => _metaSecondaryNumber.ToString(CultureInfo.InvariantCulture); public string MetaSecondaryNumberLabel => $"N2: {MetaSecondaryNumberText}"; public string MetaNotice => _metaNotice; public IReadOnlyList MetaProfileNames => _metaProfiles.AvailableNames; public string SelectedMetaProfile => _metaProfiles.Selected; public string MetaProfileNameDraft => _metaProfileNameDraft; public Action ToggleMeta => () => { _meta.SetEnabled(!_meta.Enabled); _combatSettings.MetaState = _meta.CurrentState; }; public Action SelectMetaRule => SelectMetaRuleCore; public Action SelectMetaCondition => value => { if (Enum.TryParse(value, ignoreCase: true, out MetaConditionKind parsed)) _metaConditionKind = parsed; }; public Action SelectMetaAction => value => { if (Enum.TryParse(value, ignoreCase: true, out MetaActionKind parsed)) _metaActionKind = parsed; }; public Action SetMetaStateDraft => value => _metaStateDraft = value; public Action SetMetaConditionTextDraft => value => _metaConditionTextDraft = value; public Action SetMetaActionTextDraft => value => _metaActionTextDraft = value; public Action SetMetaSecondaryTextDraft => value => _metaSecondaryTextDraft = value; public Action MetaNumberDown => () => _metaNumber--; public Action MetaNumberUp => () => _metaNumber++; public Action MetaSecondaryNumberDown => () => _metaSecondaryNumber--; public Action MetaSecondaryNumberUp => () => _metaSecondaryNumber++; public Action AddMetaRule => AddMetaRuleCore; public Action ApplyMetaRule => ApplyMetaRuleCore; public Action RemoveMetaRule => RemoveMetaRuleCore; public Action MoveMetaRuleUp => () => MoveMetaRule(-1); public Action MoveMetaRuleDown => () => MoveMetaRule(1); public Action SetMetaProfileNameDraft => value => _metaProfileNameDraft = value; public Action SelectMetaProfile => SelectMetaProfileCore; public Action CreateNamedMetaProfile => value => { _metaProfileNameDraft = value; CreateMetaProfileCore(copyCurrent: false); }; public Action CreateMetaProfile => () => CreateMetaProfileCore(copyCurrent: false); public Action CopyMetaProfile => () => CreateMetaProfileCore(copyCurrent: true); public Action ClearMetaProfile => ClearMetaProfileCore; // ── Profiles tab ───────────────────────────────────────────────────── public IReadOnlyList MacroProfileNames => _profiles.AvailableNames; public string SelectedMacroProfile => _profiles.Selected; public string ProfileNameDraft => _profileNameDraft; public string ProfileLifecycleNotice => ProfileRecoveryNotice ?? _profileLifecycleNotice; private string? ProfileRecoveryNotice => _profiles.RecoveryNotice ?? _lootProfiles.RecoveryNotice ?? _routeProfiles.RecoveryNotice ?? _metaProfiles.RecoveryNotice; public bool MineOnlyEnabled => _profiles.MineOnly; public Action SetProfileNameDraft => value => _profileNameDraft = value; public Action SelectMacroProfile => SelectProfile; public Action CreateNamedProfile => value => { _profileNameDraft = value; CreateProfileCore(copyCurrent: false); }; public Action CreateProfile => () => CreateProfileCore(copyCurrent: false); public Action CopyProfile => () => CreateProfileCore(copyCurrent: true); public Action ClearProfile => ClearProfileCore; public Action ToggleMineOnly => () => { string before = _profiles.Selected; _profiles.SetMineOnly(!_profiles.MineOnly); if (!string.Equals(before, _profiles.Selected, StringComparison.OrdinalIgnoreCase)) LoadSelectedProfile(); _profileLifecycleNotice = _profiles.MineOnly ? "Showing profiles owned by this character." : "Showing profiles from all characters."; }; // ── Monsters editor ────────────────────────────────────────────────── public IReadOnlyList MonsterRows => _monsterRows; public int SelectedMonsterRuleIndex => _selectedMonsterRule; public string MonsterExpressionDraft => _monsterExpressionDraft; public string MonsterEditorNotice => _monsterEditorNotice; public IReadOnlyList DamageTypeNames => MonsterDamageNames; public IReadOnlyList ExtraVulnerabilityNames => MonsterExtraVulnerabilityNames; public IReadOnlyList PetDamageTypeNames => MonsterPetDamageNames; public string SelectedDamageType => DamageTypeDisplay( SelectedMonsterActions.DamageType); public string SelectedExtraVulnerability => DamageTypeDisplay(SelectedMonsterActions.ExtraVulnerability); public string SelectedPetDamage => DamageTypeDisplay( SelectedMonsterActions.PetDamageType); public string MonsterPriorityText => $"Priority {SelectedMonsterActions.BoundedPriority}"; public string MonsterEquipmentText => $"Weapon {ItemDisplayName( SelectedMonsterActions.WeaponObjectId, SelectedMonsterActions.WeaponName)} " + $"Offhand {ItemDisplayName( SelectedMonsterActions.OffhandObjectId, SelectedMonsterActions.OffhandName)}"; public Action SelectMonsterRule => SelectMonsterRuleCore; public Action SetMonsterExpressionDraft => value => _monsterExpressionDraft = value; public Action ApplyMonsterExpression => value => { _monsterExpressionDraft = value; ApplyMonsterExpressionCore(); }; public Action ApplyMonsterRule => ApplyMonsterExpressionCore; public Action AddMonsterRule => AddMonsterRuleCore; public Action AddSelectedMonster => AddSelectedMonsterCore; public Action RemoveMonsterRule => RemoveMonsterRuleCore; public Action MoveMonsterRuleUp => () => MoveMonsterRule(-1); public Action MoveMonsterRuleDown => () => MoveMonsterRule(1); public Action MonsterPriorityDown => () => UpdateSelectedMonsterActions( actions => actions with { Priority = Math.Max(-1, actions.Priority - 1) }); public Action MonsterPriorityUp => () => UpdateSelectedMonsterActions( actions => actions with { Priority = Math.Min(4, actions.Priority + 1) }); public Action SelectMonsterDamage => value => SetMonsterDamage(value, extra: false); public Action SelectMonsterExtraVulnerability => value => SetMonsterDamage(value, extra: true); public Action SelectMonsterPetDamage => value => { if (TryParseDamageType(value, out MonsterDamageType parsed)) { UpdateSelectedMonsterActions(actions => actions with { PetDamageType = parsed, }); } }; public Action SetMonsterWeapon => () => SetSelectedMonsterEquipment(offhand: false); public Action SetMonsterOffhand => () => SetSelectedMonsterEquipment(offhand: true); public Action ClearMonsterEquipment => () => UpdateSelectedMonsterActions( actions => actions with { WeaponObjectId = 0u, OffhandObjectId = 0u, WeaponName = string.Empty, OffhandName = string.Empty, }); public bool MonsterFester => HasMonsterFlag(MonsterActionFlags.Fester); public bool MonsterBroadside => HasMonsterFlag(MonsterActionFlags.Broadside); public bool MonsterGravityWell => HasMonsterFlag(MonsterActionFlags.GravityWell); public bool MonsterImperil => HasMonsterFlag(MonsterActionFlags.Imperil); public bool MonsterYield => HasMonsterFlag(MonsterActionFlags.Yield); public bool MonsterVulnerability => HasMonsterFlag(MonsterActionFlags.Vulnerability); public bool MonsterAttack => HasMonsterFlag(MonsterActionFlags.Attack); public bool MonsterRing => HasMonsterFlag(MonsterActionFlags.Ring); public bool MonsterStreak => HasMonsterFlag(MonsterActionFlags.Streak); public bool MonsterWeakening => HasMonsterFlag(MonsterActionFlags.WeakeningCurse); public bool MonsterFestering => HasMonsterFlag(MonsterActionFlags.FesteringCurse); public bool MonsterCorruption => HasMonsterFlag(MonsterActionFlags.Corruption); public bool MonsterDestructive => HasMonsterFlag(MonsterActionFlags.DestructiveCurse); public bool MonsterCorrosion => HasMonsterFlag(MonsterActionFlags.Corrosion); public Action ToggleMonsterFester => () => ToggleMonsterFlag(MonsterActionFlags.Fester); public Action ToggleMonsterBroadside => () => ToggleMonsterFlag(MonsterActionFlags.Broadside); public Action ToggleMonsterGravityWell => () => ToggleMonsterFlag(MonsterActionFlags.GravityWell); public Action ToggleMonsterImperil => () => ToggleMonsterFlag(MonsterActionFlags.Imperil); public Action ToggleMonsterYield => () => ToggleMonsterFlag(MonsterActionFlags.Yield); public Action ToggleMonsterVulnerability => () => ToggleMonsterFlag(MonsterActionFlags.Vulnerability); public Action ToggleMonsterAttack => () => ToggleMonsterFlag(MonsterActionFlags.Attack); public Action ToggleMonsterRing => () => ToggleMonsterFlag(MonsterActionFlags.Ring); public Action ToggleMonsterStreak => () => ToggleMonsterFlag(MonsterActionFlags.Streak); public Action ToggleMonsterWeakening => () => ToggleMonsterFlag(MonsterActionFlags.WeakeningCurse); public Action ToggleMonsterFestering => () => ToggleMonsterFlag(MonsterActionFlags.FesteringCurse); public Action ToggleMonsterCorruption => () => ToggleMonsterFlag(MonsterActionFlags.Corruption); public Action ToggleMonsterDestructive => () => ToggleMonsterFlag(MonsterActionFlags.DestructiveCurse); public Action ToggleMonsterCorrosion => () => ToggleMonsterFlag(MonsterActionFlags.Corrosion); /// Vitals line, using the same numbers the character panel shows. public string Vitals => _vitals; /// What a buff pass would cover, named from the retail tables. public string Coverage => _coverage; // ── settings bindings ───────────────────────────────────────────────── // Adjuster buttons rather than typed entry: buttons are a proven primitive // in plugin markup, whereas an editable field would need keyboard routing // plumbed through to plugin panels first. /// /// VTank's SpellDiffExcessThreshold-Buff. Signed on purpose — the /// wiki is explicit that "a positive number raises the skill necessary to /// cast spells, a negative number lowers it", so a lower-level character /// can reach for higher tiers by going negative. /// public string DifficultyText => $"Spell difficulty margin: {_buffSettings.SkillExcessOverDifficulty:+0;-0;0}"; public string RebuffText => $"Rebuff when under: {_buffSettings.RebuffWhenUnderSeconds / 60.0:0.#} min"; public string NormalHealthText => Percent(_vitalSettings.NormalHealth); public string NormalStaminaText => Percent(_vitalSettings.NormalStamina); public string NormalManaText => Percent(_vitalSettings.NormalMana); public string NoTargetHealthText => Percent(_vitalSettings.NoTargetHealth); public string NoTargetStaminaText => Percent(_vitalSettings.NoTargetStamina); public string NoTargetManaText => Percent(_vitalSettings.NoTargetMana); public string HelperHealthText => Percent(_vitalSettings.HelperHealth); public string HelperStaminaText => Percent(_vitalSettings.HelperStamina); public string HelperManaText => Percent(_vitalSettings.HelperMana); public string VitalUpkeepText => $"Stamina to Mana / Revitalize: {OnOff(_vitalSettings.Enabled)}"; public string TrainedOnlyText => $"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 RegenerationText => $"Buff regen rates: {OnOff(_buffSettings.BuffRegeneration)}"; public string OtherText => $"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}"; public bool VitalUpkeepEnabled => _vitalSettings.Enabled; public bool HelpOthersEnabled => _vitalSettings.HelpOthers; public bool TrainedOnlyEnabled => _buffSettings.BuffTrainedSkillsOnly; public bool AttributesEnabled => _buffSettings.BuffAttributes; public bool ProtectionsEnabled => _buffSettings.BuffProtections; public bool AurasEnabled => _buffSettings.BuffAuras; public bool BanesEnabled => _buffSettings.BuffBanes; public bool RegenerationEnabled => _buffSettings.BuffRegeneration; public bool OtherEnabled => _buffSettings.BuffOther; public string TargetMethodText => $"Target selection: {_combatSettings.SelectionMethod}"; public string TargetLockText => $"Target lock: {OnOff(_combatSettings.TargetLock)}"; public string AttackRangeText => $"Maximum target range: {_combatSettings.MaximumRange:0}m"; public string MonsterRangeValueText => _combatSettings.MaximumRange.ToString("0.#", CultureInfo.InvariantCulture); public string RingRangeValueText => _combatSettings.RingDistance.ToString("0.#", CultureInfo.InvariantCulture); public string ApproachRangeValueText => _combatSettings.ApproachDistance.ToString("0.#", CultureInfo.InvariantCulture); public string FollowNavMinimumValueText => _navigationSettings.MinimumDistanceMeters.ToString( "0.#", CultureInfo.InvariantCulture); public string AngleRangeText => $"Angle-selection range: {_combatSettings.TargetSelectAngleRange:0}m"; public string AttackHeightText => $"Physical attack height: {_combatSettings.AttackHeight}"; public string AttackPowerText => $"Power / accuracy: {_combatSettings.AttackPower * 100f:0}%"; public bool TargetLockEnabled => _combatSettings.TargetLock; public bool SummonPetsEnabled => _combatSettings.SummonPets; public bool CustomPetRangeEnabled => _combatSettings.PetRangeMode == PetRangeMode.Custom; public string PetRangeText => _combatSettings.PetRangeMode == PetRangeMode.Custom ? $"Pet range: {_combatSettings.PetCustomRange:0}m" : $"Pet range: attack ({_combatSettings.MaximumRange:0}m)"; public string PetCustomRangeValueText => _combatSettings.PetCustomRange.ToString("0.#", CultureInfo.InvariantCulture); public string PetDensityText => $"Pet min. monsters: {_combatSettings.PetMonsterDensity}"; public string PetDensityValueText => _combatSettings.PetMonsterDensity.ToString(CultureInfo.InvariantCulture); public float NormalHealthValue => (float)_vitalSettings.NormalHealth; public float NormalStaminaValue => (float)_vitalSettings.NormalStamina; public float NormalManaValue => (float)_vitalSettings.NormalMana; public float NoTargetHealthValue => (float)_vitalSettings.NoTargetHealth; public float NoTargetStaminaValue => (float)_vitalSettings.NoTargetStamina; public float NoTargetManaValue => (float)_vitalSettings.NoTargetMana; public float HelperHealthValue => (float)_vitalSettings.HelperHealth; public float HelperStaminaValue => (float)_vitalSettings.HelperStamina; public float HelperManaValue => (float)_vitalSettings.HelperMana; public float AttackPowerValue => _combatSettings.AttackPower; public Action SetNormalHealth => value => UpdateVital(() => _vitalSettings.NormalHealth = Clamp(value)); public Action SetNormalStamina => value => UpdateVital(() => _vitalSettings.NormalStamina = Clamp(value)); public Action SetNormalMana => value => UpdateVital(() => _vitalSettings.NormalMana = Clamp(value)); public Action SetNoTargetHealth => value => UpdateVital(() => _vitalSettings.NoTargetHealth = Clamp(value)); public Action SetNoTargetStamina => value => UpdateVital(() => _vitalSettings.NoTargetStamina = Clamp(value)); public Action SetNoTargetMana => value => UpdateVital(() => _vitalSettings.NoTargetMana = Clamp(value)); public Action SetHelperHealth => value => UpdateVital(() => _vitalSettings.HelperHealth = Clamp(value)); public Action SetHelperStamina => value => UpdateVital(() => _vitalSettings.HelperStamina = Clamp(value)); public Action SetHelperMana => value => UpdateVital(() => _vitalSettings.HelperMana = Clamp(value)); public Action SetAttackPower => value => UpdateProfile(() => _combatSettings.AttackPower = Math.Clamp(value, 0f, 1f)); public Action DifficultyDown => () => UpdateProfile(() => _buffSettings.SkillExcessOverDifficulty = Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5)); public Action DifficultyUp => () => UpdateProfile(() => _buffSettings.SkillExcessOverDifficulty = Math.Min(100, _buffSettings.SkillExcessOverDifficulty + 5)); public Action RebuffDown => () => UpdateProfile(() => _buffSettings.RebuffWhenUnderSeconds = Math.Max(30, _buffSettings.RebuffWhenUnderSeconds - 30)); public Action RebuffUp => () => UpdateProfile(() => _buffSettings.RebuffWhenUnderSeconds = Math.Min(1800, _buffSettings.RebuffWhenUnderSeconds + 30)); public Action ToggleVitalUpkeep => () => { _vitalSettings.Enabled = !_vitalSettings.Enabled; if (!_vitalSettings.Enabled) _vitalRecharge.Reset(); SaveProfile(); }; public Action ToggleBuffing => () => SetMetaOption( "EnableBuffing", ExpressionValue.Boolean(!_buffSettings.Enabled)); public Action ToggleIdlePeaceMode => () => SetMetaOption( "IdlePeaceMode", ExpressionValue.Boolean(!_combatSettings.IdlePeaceMode)); public Action ToggleIdleBuffTopoff => () => SetMetaOption( "IdleBuffTopoff", ExpressionValue.Boolean(!_buffSettings.IdleBuffTopoff)); public Action ToggleManaChargesWhenOff => () => SetMetaOption( "ManaChargesWhenOff", ExpressionValue.Boolean(!_inventorySettings.ManaChargesWhenOff)); public Action ToggleHelpOthers => () => UpdateVital(() => _vitalSettings.HelpOthers = !_vitalSettings.HelpOthers); public Action ToggleTrainedOnly => () => UpdateProfile(() => _buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly); public Action ToggleAttributes => () => UpdateProfile(() => _buffSettings.BuffAttributes = !_buffSettings.BuffAttributes); public Action ToggleProtections => () => UpdateProfile(() => _buffSettings.BuffProtections = !_buffSettings.BuffProtections); public Action ToggleAuras => () => UpdateProfile(() => _buffSettings.BuffAuras = !_buffSettings.BuffAuras); public Action ToggleBanes => () => UpdateProfile(() => _buffSettings.BuffBanes = !_buffSettings.BuffBanes); public Action ToggleRegeneration => () => UpdateProfile(() => _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration); public Action ToggleOther => () => UpdateProfile(() => _buffSettings.BuffOther = !_buffSettings.BuffOther); public Action CycleTargetMethod => () => UpdateProfile(() => _combatSettings.SelectionMethod = _combatSettings.SelectionMethod switch { TargetSelectionMethod.Range => TargetSelectionMethod.Angle, TargetSelectionMethod.Angle => TargetSelectionMethod.Both, _ => TargetSelectionMethod.Range, }); public Action ToggleTargetLock => () => UpdateProfile(() => _combatSettings.TargetLock = !_combatSettings.TargetLock); public Action ToggleSummonPets => () => UpdateProfile(() => _combatSettings.SummonPets = !_combatSettings.SummonPets); public Action TogglePetRangeMode => () => UpdateProfile(() => _combatSettings.PetRangeMode = _combatSettings.PetRangeMode == PetRangeMode.Custom ? PetRangeMode.AttackDistance : PetRangeMode.Custom); public Action PetRangeDown => () => UpdateProfile(() => _combatSettings.PetCustomRange = Math.Max(1f, _combatSettings.PetCustomRange - 1f)); public Action PetRangeUp => () => UpdateProfile(() => _combatSettings.PetCustomRange = Math.Min(100f, _combatSettings.PetCustomRange + 1f)); public Action SetPetCustomRangeText => value => SetDistanceText(value, 1f, 100f, distance => _combatSettings.PetCustomRange = distance); public Action PetDensityDown => () => UpdateProfile(() => _combatSettings.PetMonsterDensity = Math.Max(1, _combatSettings.PetMonsterDensity - 1)); public Action PetDensityUp => () => UpdateProfile(() => _combatSettings.PetMonsterDensity = Math.Min(25, _combatSettings.PetMonsterDensity + 1)); public Action SetPetDensityText => value => { if (!int.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int density)) { return; } _combatSettings.PetMonsterDensity = Math.Clamp(density, 1, 25); SaveProfile(); }; public Action AttackRangeDown => () => UpdateProfile(() => _combatSettings.MaximumRange = Math.Max(2f, _combatSettings.MaximumRange - 2f)); public Action AttackRangeUp => () => UpdateProfile(() => _combatSettings.MaximumRange = Math.Min(100f, _combatSettings.MaximumRange + 2f)); public Action SetMonsterRangeText => value => SetDistanceText(value, 1f, 100f, distance => _combatSettings.MaximumRange = distance); public Action SetRingRangeText => value => SetDistanceText(value, 1f, 100f, distance => _combatSettings.RingDistance = distance); public Action SetApproachRangeText => value => SetDistanceText(value, 0f, 100f, distance => _combatSettings.ApproachDistance = distance); public Action SetFollowNavMinimumText => value => SetDistanceText(value, 0.5f, 50f, distance => { _navigationSettings.MinimumDistanceMeters = distance; SaveRouteProfile(); }, saveProfile: false); public Action AngleRangeDown => () => UpdateProfile(() => _combatSettings.TargetSelectAngleRange = Math.Max(2f, _combatSettings.TargetSelectAngleRange - 2f)); public Action AngleRangeUp => () => UpdateProfile(() => _combatSettings.TargetSelectAngleRange = Math.Min( _combatSettings.MaximumRange, _combatSettings.TargetSelectAngleRange + 2f)); public Action CycleAttackHeight => () => UpdateProfile(() => _combatSettings.AttackHeight = _combatSettings.AttackHeight switch { PluginAttackHeight.High => PluginAttackHeight.Medium, PluginAttackHeight.Medium => PluginAttackHeight.Low, _ => PluginAttackHeight.High, }); public Action AttackPowerDown => () => UpdateProfile(() => _combatSettings.AttackPower = Math.Max(0f, MathF.Round(_combatSettings.AttackPower - 0.1f, 2))); public Action AttackPowerUp => () => UpdateProfile(() => _combatSettings.AttackPower = Math.Min(1f, MathF.Round(_combatSettings.AttackPower + 0.1f, 2))); private static double Clamp(float value) => Math.Clamp((double)value, 0d, 1d); private void UpdateVital(Action update) { UpdateProfile(update); } private void UpdateProfile(Action update) { update(); SaveProfile(); } private void SetDistanceText( string text, float minimum, float maximum, Action apply, bool saveProfile = true) { if (!float.TryParse( text, NumberStyles.Float, CultureInfo.InvariantCulture, out float distance) && !float.TryParse( text, NumberStyles.Float, CultureInfo.CurrentCulture, out distance)) { return; } apply(Math.Clamp(distance, minimum, maximum)); if (saveProfile) SaveProfile(); } private static string Percent(double fraction) => (fraction * 100).ToString("0", CultureInfo.InvariantCulture) + "%"; private static string OnOff(bool value) => value ? "on" : "off"; private static string ProfileText(string heading, IEnumerable names) { string[] entries = names .OrderBy(static name => name, StringComparer.Ordinal) .Take(8) .ToArray(); return entries.Length == 0 ? $"{heading}: [None]" : $"{heading}: {string.Join(" | ", entries)}"; } private void RefreshItemEditors() { RefreshConsumableCategories(); _itemRows = _combatSettings.CombatItemNames .OrderBy(static name => name, StringComparer.Ordinal) .Select(name => _noBuffItemNames.Contains(name) ? name + " [no buffs]" : name) .ToArray(); _consumableRows = _combatSettings.ConsumableNames .OrderBy(static name => name, StringComparer.Ordinal) .ToArray(); _selectedItemRow = ClampRow(_selectedItemRow, _itemRows.Count); _selectedConsumableRow = ClampRow( _selectedConsumableRow, _consumableRows.Count); } private static int ClampRow(int index, int count) => count == 0 ? 0 : Math.Clamp(index, 0, count - 1); private void RemoveSelectedItemCore() { string[] names = _combatSettings.CombatItemNames .OrderBy(static name => name, StringComparer.Ordinal) .ToArray(); if (names.Length == 0) { _profileNotice = "The Items profile is empty."; return; } string removed = names[ClampRow(_selectedItemRow, names.Length)]; _combatSettings.CombatItemNames.Remove(removed); _noBuffItemNames.Remove(removed); _profileNotice = $"Removed {removed}."; RefreshItemEditors(); SaveProfile(); } private void RemoveSelectedConsumableCore() { string[] names = _combatSettings.ConsumableNames .OrderBy(static name => name, StringComparer.Ordinal) .ToArray(); if (names.Length == 0) { _profileNotice = "The Consumables profile is empty."; return; } string removed = names[ClampRow(_selectedConsumableRow, names.Length)]; _combatSettings.ConsumableNames.Remove(removed); _combatSettings.ConsumableCategories.Remove(removed); _profileNotice = $"Removed {removed}."; RefreshItemEditors(); SaveProfile(); } private LootRule? SelectedLootRule => _inventorySettings.Loot.Rules.Count == 0 ? null : _inventorySettings.Loot.Rules[Math.Clamp( _selectedLootRule, 0, _inventorySettings.Loot.Rules.Count - 1)]; private static string FormatClassifier(PluginLootClassifierInfo info) => $"{info.DisplayName} [{info.Id}]"; private string? ResolveClassifierId(string? value) { if (string.Equals(value?.Trim(), "VTClassic", StringComparison.OrdinalIgnoreCase)) return string.Empty; foreach (PluginLootClassifierInfo info in _host.LootClassifiers.Available) { if (string.Equals(value?.Trim(), FormatClassifier(info), StringComparison.Ordinal) || string.Equals(value?.Trim(), info.Id, StringComparison.OrdinalIgnoreCase)) { return info.Id; } } return null; } private void RefreshLootEditor(bool retainDraft = false) { int count = _inventorySettings.Loot.Rules.Count; _selectedLootRule = count == 0 ? 0 : Math.Clamp(_selectedLootRule, 0, count - 1); if (!retainDraft) _lootExpressionDraft = SelectedLootRule?.Expression ?? "*"; _lootRuleRows = _inventorySettings.Loot.Rules .Select((rule, index) => $"{index + 1}. {rule.Action} P{rule.Priority} " + (rule.VtankRequirements.Count == 0 ? rule.Expression : $"[VTClassic: {rule.VtankRequirements.Count} requirements]")) .ToArray(); } private void SelectLootRuleCore(int index) { if (index < 0 || index >= _inventorySettings.Loot.Rules.Count) return; _selectedLootRule = index; _lootExpressionDraft = _inventorySettings.Loot.Rules[index].Expression; _lootEditorNotice = $"Editing rule {index + 1}."; RefreshLootEditor(retainDraft: true); } private void SelectLootProfileCore(string name) { SaveProfile(); if (!_lootProfiles.Select(name)) { _lootEditorNotice = $"Loot profile '{name}' is unavailable."; return; } LoadLootProfile(); _lootEditorNotice = $"Loaded loot profile {_lootProfiles.Selected}."; } private void CreateLootProfileCore(bool copyCurrent) { if (!_lootProfiles.Create( _lootProfileNameDraft, copyCurrent, _inventorySettings.Loot.Rules, out string notice, _inventorySettings.Loot)) { _lootEditorNotice = notice; return; } _lootProfileNameDraft = string.Empty; LoadLootProfile(); _lootEditorNotice = notice; } private void ClearLootProfileCore() { _lootProfiles.ClearCurrent( _inventorySettings.Loot.Rules, _inventorySettings.Loot); _loot.Reset(); RefreshLootEditor(); _lootEditorNotice = $"Cleared {_lootProfiles.Selected}."; } private void LoadLootProfile() { if (!_lootProfiles.LoadCurrent( _inventorySettings.Loot.Rules, _inventorySettings.Loot)) { _lootProfiles.SaveCurrent( _inventorySettings.Loot.Rules, _inventorySettings.Loot); } _loot.Reset(); RefreshLootEditor(); } private void ApplyLootExpressionCore() { if (SelectedLootRule is not { } rule) { _lootEditorNotice = "Add a loot rule first."; return; } try { _ = LootRuleExpression.Compile(_lootExpressionDraft); rule.Expression = _lootExpressionDraft; rule.CustomExpression = string.Empty; rule.VtankRequirements.Clear(); _lootEditorNotice = $"Updated {rule.Name}."; _loot.Reset(); RefreshLootEditor(); SaveProfile(); } catch (FormatException error) { _lootEditorNotice = error.Message; } } private void AddLootRuleCore() { var rule = new LootRule { Name = $"Rule {_inventorySettings.Loot.Rules.Count + 1}", Expression = "*", Action = LootAction.Keep, }; _inventorySettings.Loot.Rules.Add(rule); _selectedLootRule = _inventorySettings.Loot.Rules.Count - 1; _lootExpressionDraft = rule.Expression; _lootEditorNotice = $"Added {rule.Name}."; _loot.Reset(); RefreshLootEditor(); SaveProfile(); } private void RemoveLootRuleCore() { if (SelectedLootRule is not { } rule) { _lootEditorNotice = "The loot profile is empty."; return; } _inventorySettings.Loot.Rules.RemoveAt(_selectedLootRule); _selectedLootRule = Math.Min( _selectedLootRule, Math.Max(0, _inventorySettings.Loot.Rules.Count - 1)); _lootEditorNotice = $"Removed {rule.Name}."; _loot.Reset(); RefreshLootEditor(); SaveProfile(); } private void MoveLootRule(int direction) { if (SelectedLootRule is not { } rule) return; int destination = _selectedLootRule + Math.Sign(direction); if (destination < 0 || destination >= _inventorySettings.Loot.Rules.Count) return; _inventorySettings.Loot.Rules.RemoveAt(_selectedLootRule); _inventorySettings.Loot.Rules.Insert(destination, rule); _selectedLootRule = destination; _lootEditorNotice = $"Moved {rule.Name}."; _loot.Reset(); RefreshLootEditor(); SaveProfile(); } private void SelectLootActionCore(string value) { if (SelectedLootRule is not { } rule || !Enum.TryParse(value, ignoreCase: true, out LootAction action)) { return; } rule.Action = action; _lootEditorNotice = $"{rule.Name}: {action}."; _loot.Reset(); RefreshLootEditor(); SaveProfile(); } private void UpdateSelectedLootRule(Action update) { if (SelectedLootRule is not { } rule) return; update(rule); _lootEditorNotice = $"Updated {rule.Name}."; _loot.Reset(); RefreshLootEditor(); SaveProfile(); } private void RefreshRouteEditor() { _selectedRouteWaypoint = ClampRow( _selectedRouteWaypoint, _navigationSettings.Waypoints.Count); int active = _navigation.CurrentWaypointIndex; _routeRows = _navigationSettings.Waypoints .Select((waypoint, index) => $"{(index == active && _navigationSettings.Enabled ? "<<" : " ")} " + waypoint.DisplayText) .ToArray(); } private void AddRoutePointCore() { PluginNavigationSnapshot snapshot = _host.Automation.Navigation.Snapshot; if (!snapshot.IsAvailable) { _routeNotice = "Current position is unavailable."; return; } AddRouteWaypoint(new RouteWaypoint { Type = RouteWaypointType.Point, Position = snapshot.Position, }); _routeNotice = $"Added point {RouteWaypoint.FormatPosition(snapshot.Position)}."; } private void AddRouteCheckpointCore() { PluginNavigationSnapshot snapshot = _host.Automation.Navigation.Snapshot; if (!snapshot.IsAvailable) { _routeNotice = "Current position is unavailable."; return; } AddRouteWaypoint(new RouteWaypoint { Type = RouteWaypointType.Checkpoint, Position = snapshot.Position, }); _routeNotice = "Added checkpoint."; } private void AddSelectedObjectWaypoint(RouteWaypointType type) { uint selected = _host.Selection.SelectedObjectId ?? 0u; if (selected == 0u || !_host.Automation.Navigation.TryGetObject( selected, out PluginNavigationObject target)) { _routeNotice = "Select a live portal, NPC, or vendor first."; return; } AddRouteWaypoint(new RouteWaypoint { Type = type, Position = target.Position, ObjectId = target.ObjectId, ObjectName = target.Name, }); _routeNotice = $"Added {type}: {target.Name}."; } private void AddRouteRecallCore() { AddRouteWaypoint(new RouteWaypoint { Type = RouteWaypointType.Recall, Recall = _routeRecallKind, Position = _host.Automation.Navigation.Snapshot.Position, }); _routeNotice = $"Added {RouteWaypoint.RecallDisplayName(_routeRecallKind)}."; } private void AddRoutePauseCore() { AddRouteWaypoint(new RouteWaypoint { Type = RouteWaypointType.Pause, DurationMilliseconds = _routePauseSeconds * 1000, Position = _host.Automation.Navigation.Snapshot.Position, }); _routeNotice = $"Added {_routePauseSeconds}-second pause."; } private void AddRouteChatCore() { string text = _routeChatDraft.Trim(); if (text.Length == 0) { _routeNotice = "Enter a chat command first."; return; } AddRouteWaypoint(new RouteWaypoint { Type = RouteWaypointType.ChatCommand, Text = text, Position = _host.Automation.Navigation.Snapshot.Position, }); _routeNotice = $"Added chat command {text}."; } private void AddRouteJumpCore() { PluginNavigationSnapshot snapshot = _host.Automation.Navigation.Snapshot; if (!snapshot.IsAvailable) { _routeNotice = "Current heading is unavailable."; return; } AddRouteWaypoint(new RouteWaypoint { Type = RouteWaypointType.Jump, Position = snapshot.Position, JumpHeadingDegrees = snapshot.Position.HeadingDegrees, JumpRun = true, JumpChargeMilliseconds = 1000, JumpDirection = RouteJumpDirection.Forward, }); _routeNotice = "Added forward jump."; } private void AddRouteWaypoint(RouteWaypoint waypoint) { int insertion = _routeAddToEnd ? _navigationSettings.Waypoints.Count : Math.Min( _navigationSettings.Waypoints.Count, _selectedRouteWaypoint + 1); _navigationSettings.Waypoints.Insert(insertion, waypoint); _selectedRouteWaypoint = insertion; _navigation.Reset(); RefreshRouteEditor(); SaveRouteProfile(); } private void RemoveRouteWaypointCore() { if (_navigationSettings.Waypoints.Count == 0) { _routeNotice = "The route is empty."; return; } int index = ClampRow( _selectedRouteWaypoint, _navigationSettings.Waypoints.Count); string removed = _navigationSettings.Waypoints[index].DisplayText; _navigationSettings.Waypoints.RemoveAt(index); _selectedRouteWaypoint = ClampRow( index, _navigationSettings.Waypoints.Count); _navigation.Reset(); RefreshRouteEditor(); SaveRouteProfile(); _routeNotice = $"Removed {removed}."; } private void MoveRouteWaypoint(int direction) { if (_navigationSettings.Waypoints.Count < 2) return; int source = ClampRow( _selectedRouteWaypoint, _navigationSettings.Waypoints.Count); int destination = source + Math.Sign(direction); if (destination < 0 || destination >= _navigationSettings.Waypoints.Count) return; RouteWaypoint waypoint = _navigationSettings.Waypoints[source]; _navigationSettings.Waypoints.RemoveAt(source); _navigationSettings.Waypoints.Insert(destination, waypoint); _selectedRouteWaypoint = destination; _navigation.Reset(); RefreshRouteEditor(); SaveRouteProfile(); } private void CaptureFollowTarget() { uint selected = _host.Selection.SelectedObjectId ?? 0u; if (selected == 0u || !_host.Automation.Navigation.TryGetObject( selected, out PluginNavigationObject target)) { _routeNotice = "Select a live object to follow first."; return; } _navigationSettings.FollowTargetObjectId = target.ObjectId; _navigationSettings.FollowTargetName = target.Name; _navigationSettings.Mode = RouteMode.Target; _navigation.Reset(); RefreshRouteEditor(); SaveRouteProfile(); _routeNotice = $"Following {target.Name}."; } private void SelectRouteProfileCore(string name) { SaveRouteProfile(); if (!_routeProfiles.Select(name)) { _routeNotice = $"Route profile '{name}' is unavailable."; return; } LoadRouteProfile(); _routeNotice = $"Loaded route {_routeProfiles.Selected}."; } private void CreateRouteProfileCore(bool copyCurrent) { if (!_routeProfiles.Create( _routeProfileNameDraft, copyCurrent, _navigationSettings, out string notice)) { _routeNotice = notice; return; } _routeProfileNameDraft = string.Empty; LoadRouteProfile(); _routeNotice = notice; } private void ClearRouteProfileCore() { _routeProfiles.ClearCurrent(_navigationSettings); _navigation.Reset(); RefreshRouteEditor(); _routeNotice = $"Cleared {_routeProfiles.Selected}."; } private void LoadRouteProfile() { if (!_routeProfiles.LoadCurrent(_navigationSettings)) _routeProfiles.SaveCurrent(_navigationSettings); if (_initialized) ApplyPersistedOptionOverrides(); _navigation.Reset(); RefreshRouteEditor(); } private void SaveRouteProfile() => _routeProfiles.SaveCurrent(_navigationSettings); private void SelectTab(TankTab tab) { _activeTab = tab; _lootEditorVisible = false; _advancedOptionsVisible = false; } private void LoadAdvancedOptionDraft() { _advancedOptionValueDraft = GetMetaOption(AdvancedOptionName) .ToDisplayString(); _advancedOptionNotice = $"Editing {AdvancedOptionName}."; } private void ApplyAdvancedOptionCore() { if (!TryParseOptionValue( _advancedOptionValueDraft.Trim(), out ExpressionValue value)) { _advancedOptionNotice = "Enter a value first."; return; } try { if (!SetMetaOption(AdvancedOptionName, value)) { _advancedOptionNotice = $"{AdvancedOptionName} is unavailable."; return; } LoadAdvancedOptionDraft(); _advancedOptionNotice = $"Applied {AdvancedOptionName}."; } catch (Exception exception) when (exception is FormatException or OverflowException) { _advancedOptionNotice = exception.Message; } } private MonsterRule SelectedMonsterRule => _combatSettings.Rules.Count == 0 ? new MonsterRule("DEFAULT", 0) : _combatSettings.Rules[Math.Clamp( _selectedMonsterRule, 0, _combatSettings.Rules.Count - 1)]; private MonsterRuleActions SelectedMonsterActions => SelectedMonsterRule.Actions; private void RefreshMonsterEditor(bool retainDraft = false) { if (_combatSettings.Rules.Count == 0) _combatSettings.Rules.Add(new MonsterRule("DEFAULT", 0)); _selectedMonsterRule = Math.Clamp( _selectedMonsterRule, 0, _combatSettings.Rules.Count - 1); if (!retainDraft) _monsterExpressionDraft = SelectedMonsterRule.Expression; _monsterRows = _combatSettings.Rules.Select(FormatMonsterRow).ToArray(); } private static string FormatMonsterRow(MonsterRule rule) { MonsterRuleActions actions = rule.Actions; static char Mark(MonsterActionFlags flags, MonsterActionFlags flag) => (flags & flag) != 0 ? '●' : '○'; return string.Concat( Mark(actions.Flags, MonsterActionFlags.Fester), " ", Mark(actions.Flags, MonsterActionFlags.Broadside), " ", Mark(actions.Flags, MonsterActionFlags.GravityWell), " ", Mark(actions.Flags, MonsterActionFlags.Imperil), " ", Mark(actions.Flags, MonsterActionFlags.Yield), " ", Mark(actions.Flags, MonsterActionFlags.Vulnerability), " ", Mark(actions.Flags, MonsterActionFlags.Attack), " ", Mark(actions.Flags, MonsterActionFlags.Ring), " ", Mark(actions.Flags, MonsterActionFlags.Streak), " ", Mark(actions.Flags, MonsterActionFlags.WeakeningCurse), " ", Mark(actions.Flags, MonsterActionFlags.FesteringCurse), " ", Mark(actions.Flags, MonsterActionFlags.Corruption), " ", Mark(actions.Flags, MonsterActionFlags.DestructiveCurse), " ", Mark(actions.Flags, MonsterActionFlags.Corrosion), " ", rule.Expression, " P", actions.BoundedPriority.ToString( CultureInfo.InvariantCulture), " ", actions.DamageType.ToString()); } private void SelectMonsterRuleCore(int index) { if (index < 0 || index >= _combatSettings.Rules.Count) return; _selectedMonsterRule = index; _monsterExpressionDraft = SelectedMonsterRule.Expression; _monsterEditorNotice = $"Editing row {index + 1}."; RefreshMonsterEditor(); } private void ApplyMonsterExpressionCore() { string expression = _monsterExpressionDraft.Trim(); if (expression.Length == 0) { _monsterEditorNotice = "Monster expression cannot be empty."; return; } try { _combatSettings.Rules[_selectedMonsterRule] = new MonsterRule( expression, SelectedMonsterActions); _monsterEditorNotice = $"Updated {expression}."; RefreshMonsterEditor(); SaveProfile(); } catch (FormatException error) { _monsterEditorNotice = error.Message; } } private void AddMonsterRuleCore() => AddMonsterRuleCore("New monster"); private void AddMonsterRuleCore(string expression) { try { var rule = new MonsterRule(expression, new MonsterRuleActions()); _combatSettings.Rules.Add(rule); _selectedMonsterRule = _combatSettings.Rules.Count - 1; _monsterEditorNotice = $"Added {expression}."; RefreshMonsterEditor(); SaveProfile(); } catch (FormatException error) { _monsterEditorNotice = error.Message; } } private void AddSelectedMonsterCore() { uint selectedId = _host.Selection.SelectedObjectId ?? 0u; PluginCombatTarget selected = default; bool found = false; foreach (PluginCombatTarget candidate in _host.Automation.Combat.CaptureHostileTargets(float.MaxValue)) { if (candidate.ObjectId != selectedId) continue; selected = candidate; found = true; break; } if (!found || string.IsNullOrWhiteSpace(selected.Name)) { _monsterEditorNotice = "Select a monster in the world first."; return; } AddMonsterRuleCore(EscapeMonsterLiteral(selected.Name)); } private static string EscapeMonsterLiteral(string value) { const string operators = "%/*+-#><=&|()"; var result = new System.Text.StringBuilder(value.Length + 8); foreach (char character in value) { if (char.IsDigit(character) || character == '\\' || operators.Contains(character)) { result.Append('\\'); } result.Append(character); } return result.ToString(); } private void RemoveMonsterRuleCore() { if (SelectedMonsterRule.IsDefault) { _monsterEditorNotice = "DEFAULT cannot be removed."; return; } string removed = SelectedMonsterRule.Expression; _combatSettings.Rules.RemoveAt(_selectedMonsterRule); _selectedMonsterRule = Math.Min( _selectedMonsterRule, _combatSettings.Rules.Count - 1); _monsterEditorNotice = $"Removed {removed}."; RefreshMonsterEditor(); SaveProfile(); } private void MoveMonsterRule(int direction) { int destination = _selectedMonsterRule + Math.Sign(direction); if (destination < 0 || destination >= _combatSettings.Rules.Count) return; MonsterRule current = _combatSettings.Rules[_selectedMonsterRule]; _combatSettings.Rules.RemoveAt(_selectedMonsterRule); _combatSettings.Rules.Insert(destination, current); _selectedMonsterRule = destination; _monsterEditorNotice = $"Moved {current.Expression}."; RefreshMonsterEditor(); SaveProfile(); } private bool HasMonsterFlag(MonsterActionFlags flag) => (SelectedMonsterActions.Flags & flag) != 0; private void ToggleMonsterFlag(MonsterActionFlags flag) => UpdateSelectedMonsterActions(actions => actions with { Flags = actions.Flags ^ flag, }); private void SetMonsterDamage(string value, bool extra) { if (!TryParseDamageType(value, out MonsterDamageType parsed)) return; UpdateSelectedMonsterActions(actions => extra ? actions with { ExtraVulnerability = parsed } : actions with { DamageType = parsed }); } private static string DamageTypeDisplay(MonsterDamageType value) => value switch { MonsterDamageType.Electric => "Lightning", MonsterDamageType.VoidBasic or MonsterDamageType.Nether => "Void Basic", MonsterDamageType.DrainAuto => "Drain Auto", MonsterDamageType.PlayerAuto => "PAuto", _ => value.ToString(), }; private static bool TryParseDamageType( string value, out MonsterDamageType parsed) { parsed = value.Trim() switch { string name when name.Equals("Lightning", StringComparison.OrdinalIgnoreCase) => MonsterDamageType.Electric, string name when name.Equals("Void Basic", StringComparison.OrdinalIgnoreCase) => MonsterDamageType.VoidBasic, string name when name.Equals("Drain Auto", StringComparison.OrdinalIgnoreCase) => MonsterDamageType.DrainAuto, string name when name.Equals("PAuto", StringComparison.OrdinalIgnoreCase) => MonsterDamageType.PlayerAuto, _ => (MonsterDamageType)(-1), }; return (int)parsed >= 0 || Enum.TryParse(value, ignoreCase: true, out parsed); } private void SetSelectedMonsterEquipment(bool offhand) { if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) { _monsterEditorNotice = "Select an owned weapon or offhand item first."; return; } _combatSettings.CombatItemNames.Add(item.Name); _combatSettings.CombatItemObjectIds.Add(item.ObjectId); RefreshItemEditors(); UpdateSelectedMonsterActions(actions => offhand ? actions with { OffhandObjectId = item.ObjectId, OffhandName = item.Name, } : actions with { WeaponObjectId = item.ObjectId, WeaponName = item.Name, }); } private string ItemDisplayName(uint objectId, string durableName) { if (!string.IsNullOrWhiteSpace(durableName)) return durableName; if (objectId == 0u) return ""; foreach (PluginInventoryItem item in _host.Automation.Items.CaptureOwnedItems()) { if (item.ObjectId == objectId) return item.Name; } return $"0x{objectId:X8}"; } private void UpdateSelectedMonsterActions( Func update) { MonsterRule selected = SelectedMonsterRule; _combatSettings.Rules[_selectedMonsterRule] = new MonsterRule( selected.Expression, update(selected.Actions)); _monsterEditorNotice = $"Updated {selected.Expression}."; RefreshMonsterEditor(); SaveProfile(); } private void AddSelectedProfileItem(bool noBuffs) { if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) { _profileNotice = "Select an owned inventory item first."; return; } _combatSettings.CombatItemObjectIds.Add(item.ObjectId); _combatSettings.CombatItemNames.Add(item.Name); if (noBuffs) _noBuffItemNames.Add(item.Name); else _noBuffItemNames.Remove(item.Name); _profileNotice = noBuffs ? $"Added {item.Name} (no buffs)." : $"Added {item.Name}."; RefreshItemEditors(); SaveProfile(); } private void AddSelectedConsumableCore() { if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) { _profileNotice = "Select an owned consumable first."; return; } _combatSettings.ConsumableNames.Add(item.Name); _combatSettings.ConsumableCategories[item.Name] = ConsumableClassifier.Classify(item); _profileNotice = $"Added {item.Name}."; RefreshItemEditors(); SaveProfile(); } private void AddAllPeasCore() { bool added = _combatSettings.ConsumableNames.Add( CraftingPlanner.AllPeas); _combatSettings.ConsumableCategories[CraftingPlanner.AllPeas] = ConsumableCategory.AllPeas; _profileNotice = added ? "Added [All Peas]." : "[All Peas] is already in this profile."; if (added) { RefreshItemEditors(); SaveProfile(); } } private void RefreshConsumableCategories() { foreach (string stale in _combatSettings.ConsumableCategories.Keys .Where(name => !_combatSettings.ConsumableNames.Contains(name)) .ToArray()) { _combatSettings.ConsumableCategories.Remove(stale); } foreach (string name in _combatSettings.ConsumableNames) { if (!_combatSettings.ConsumableCategories.ContainsKey(name)) { _combatSettings.ConsumableCategories[name] = ConsumableClassifier.ClassifyName(name); } } foreach (PluginInventoryItem item in _host.Automation.Items.CaptureOwnedItems()) { if (_combatSettings.ConsumableNames.Contains(item.Name)) { _combatSettings.ConsumableCategories[item.Name] = ConsumableClassifier.Classify(item); } } } private bool TryGetSelectedInventoryItem(out PluginInventoryItem selected) { uint selectedId = _host.Selection.SelectedObjectId ?? 0u; if (selectedId != 0u) { foreach (PluginInventoryItem item in _host.Automation.Items.CaptureOwnedItems()) { if (item.ObjectId == selectedId) { selected = item; return true; } } } selected = default; return false; } private MetaRule? SelectedMetaRule => (uint)_selectedMetaRule < (uint)_metaProfile.Rules.Count ? _metaProfile.Rules[_selectedMetaRule] : null; private void RefreshMetaEditor() { _metaRows = _metaProfile.Rules.Select(static rule => $"{rule.State,-16} {DescribeMetaCondition(rule.Condition),-34} " + DescribeMetaAction(rule.Action)).ToArray(); _selectedMetaRule = ClampRow(_selectedMetaRule, _metaProfile.Rules.Count); if (SelectedMetaRule is not MetaRule selected) return; _metaStateDraft = selected.State; _metaConditionKind = selected.Condition.Kind; _metaConditionTextDraft = selected.Condition.Text; _metaActionKind = selected.Action.Kind; _metaActionTextDraft = selected.Action.Text; _metaSecondaryTextDraft = selected.Action.SecondaryText; _metaNumber = checked((int)Math.Clamp( selected.Condition.Number, int.MinValue, int.MaxValue)); _metaSecondaryNumber = checked((int)Math.Clamp( selected.Condition.SecondaryNumber, int.MinValue, int.MaxValue)); } private void SelectMetaRuleCore(int index) { _selectedMetaRule = ClampRow(index, _metaProfile.Rules.Count); RefreshMetaEditor(); _metaNotice = SelectedMetaRule is null ? "Add a rule or select one to edit." : "Editing the selected ordered Meta rule."; } private void AddMetaRuleCore() { if (!TryBuildMetaRule(out MetaRule rule, out string error)) { _metaNotice = error; return; } _metaProfile.Rules.Add(rule); _selectedMetaRule = _metaProfile.Rules.Count - 1; SaveMetaProfile(); RefreshMetaEditor(); _metaNotice = $"Added rule in {rule.State}."; } private void ApplyMetaRuleCore() { if (SelectedMetaRule is not MetaRule current) { AddMetaRuleCore(); return; } if (!TryBuildMetaRule(out MetaRule replacement, out string error)) { _metaNotice = error; return; } replacement.Id = current.Id; _metaProfile.Rules[_selectedMetaRule] = replacement; SaveMetaProfile(); RefreshMetaEditor(); _metaNotice = $"Updated rule in {replacement.State}."; } private bool TryBuildMetaRule(out MetaRule rule, out string error) { string state = string.IsNullOrWhiteSpace(_metaStateDraft) ? MetaEngine.DefaultState : _metaStateDraft.Trim(); try { if (_metaConditionKind == MetaConditionKind.Expression) _ = ExpressionProgram.Compile(_metaConditionTextDraft); if (_metaActionKind is MetaActionKind.ExpressionAction or MetaActionKind.ChatExpression) { _ = ExpressionProgram.Compile(_metaActionTextDraft); } rule = new MetaRule { State = state, Condition = new MetaCondition { Kind = _metaConditionKind, Text = _metaConditionTextDraft, Number = _metaNumber, SecondaryNumber = _metaSecondaryNumber, }, Action = new MetaAction { Kind = _metaActionKind, Text = _metaActionTextDraft, SecondaryText = _metaSecondaryTextDraft, Number = _metaNumber, SecondaryNumber = _metaSecondaryNumber, }, }; error = string.Empty; return true; } catch (Exception exception) { rule = new MetaRule(); error = exception.Message; return false; } } private void RemoveMetaRuleCore() { if (SelectedMetaRule is not MetaRule selected) return; _metaProfile.Rules.RemoveAt(_selectedMetaRule); _selectedMetaRule = Math.Min( _selectedMetaRule, Math.Max(0, _metaProfile.Rules.Count - 1)); SaveMetaProfile(); RefreshMetaEditor(); _metaNotice = $"Removed rule from {selected.State}."; } private void MoveMetaRule(int direction) { int destination = _selectedMetaRule + Math.Sign(direction); if ((uint)_selectedMetaRule >= (uint)_metaProfile.Rules.Count || (uint)destination >= (uint)_metaProfile.Rules.Count) { return; } MetaRule rule = _metaProfile.Rules[_selectedMetaRule]; _metaProfile.Rules.RemoveAt(_selectedMetaRule); _metaProfile.Rules.Insert(destination, rule); _selectedMetaRule = destination; SaveMetaProfile(); RefreshMetaEditor(); _metaNotice = $"Moved {rule.State} rule."; } private void SelectMetaProfileCore(string name) { SaveMetaProfile(); if (!_metaProfiles.Select(name)) { _metaNotice = $"Meta profile '{name}' is unavailable."; return; } LoadMetaProfile(); _metaNotice = $"Loaded Meta profile {_metaProfiles.Selected}."; } private void CreateMetaProfileCore(bool copyCurrent) { if (!_metaProfiles.Create( _metaProfileNameDraft, copyCurrent, _metaProfile, out string notice)) { _metaNotice = notice; return; } _metaProfileNameDraft = string.Empty; LoadMetaProfile(); _metaNotice = notice; } private void ClearMetaProfileCore() { _metaProfile = _metaProfiles.ClearCurrent(); _meta.ReplaceProfile(_metaProfile); _selectedMetaRule = 0; RefreshMetaEditor(); _metaNotice = $"Cleared Meta profile {_metaProfiles.Selected}."; } private void LoadMetaProfile() { _metaProfile = _metaProfiles.LoadCurrent(); _meta.ReplaceProfile(_metaProfile); if (_initialized) ApplyPersistedOptionOverrides(); _selectedMetaRule = 0; RefreshMetaEditor(); } private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile); private static string DescribeMetaCondition(MetaCondition condition) => condition.Kind switch { MetaConditionKind.Expression => $"Expression: {condition.Text}", MetaConditionKind.ChatMessage or MetaConditionKind.ChatMessageCapture => $"{condition.Kind}: {condition.Text}", MetaConditionKind.Always or MetaConditionKind.Never => condition.Kind.ToString(), _ => $"{condition.Kind} {condition.Number:0.###}", }; private static string DescribeMetaAction(MetaAction action) => action.Kind switch { MetaActionKind.SetMetaState or MetaActionKind.CallMetaState or MetaActionKind.ChatCommand or MetaActionKind.ExpressionAction or MetaActionKind.ChatExpression => $"{action.Kind}: {action.Text}", _ => action.Kind.ToString(), }; private double DistanceFromAnyRoutePoint() { PluginNavigationSnapshot player = _host.Automation.Navigation.Snapshot; if (!player.IsAvailable) return double.PositiveInfinity; double nearest = double.PositiveInfinity; foreach (RouteWaypoint waypoint in _navigationSettings.Waypoints) { if (waypoint.Position.CellId == 0u) continue; nearest = Math.Min( nearest, player.Position.HorizontalDistanceMeters(waypoint.Position)); } return nearest; } private void LoadEmbeddedNavigationRoute(string source) { _navigation.Reset(); if (!VtankNavRouteSerializer.TryLoad( source, _navigationSettings, _host.Automation.Spells, out string error)) { _routeNotice = $"Embedded route rejected: {error}"; _host.Log.Warn($"MossTank Meta embedded route rejected: {error}"); return; } _routeProfiles.SaveCurrent(_navigationSettings); _selectedRouteWaypoint = 0; RefreshRouteEditor(); _routeNotice = $"Loaded embedded route ({_navigationSettings.Waypoints.Count} points)."; } private int CountMonstersByPriority(int priority, double distance) { int count = 0; foreach (PluginCombatTarget target in _host.Automation.Combat .CaptureHostileTargets(checked((float)distance))) { if (_combatSettings.ResolveRule(target).Priority == priority) count++; } return count; } private ExpressionValue GetMetaOption(string name) { string key = name.Trim(); return key.ToLowerInvariant() switch { "enablebuffing" => ExpressionValue.Boolean(_buffSettings.Enabled), "enablecombat" => ExpressionValue.Boolean(_combatSettings.Enabled), "enablenav" or "enablenavigation" or "enableautonavigator" => ExpressionValue.Boolean(_navigationSettings.Enabled), "enablelooting" => ExpressionValue.Boolean(_inventorySettings.Loot.Enabled), "enablemeta" => ExpressionValue.Boolean(_meta.Enabled), "spelldiffexcessthreshold-hunt" => ExpressionValue.Number( _combatSettings.HuntSkillExcessOverDifficulty), "spelldiffexcessthreshold-buff" => ExpressionValue.Number( _buffSettings.SkillExcessOverDifficulty), "arrowheadfletchdiffexcessthreshold" => ExpressionValue.Number( _inventorySettings.ArrowheadFletchDifficultyExcess), "dohelp" => ExpressionValue.Boolean(_vitalSettings.HelpOthers), "monsterrange" => ExpressionValue.Number(_combatSettings.MaximumRange), "attackdistance" => ExpressionValue.Number( _combatSettings.MaximumRange / 240d), "attackminimumdistance" => ExpressionValue.Number( _combatSettings.MinimumRange / 240d), "approachdistance" => ExpressionValue.Number( _combatSettings.ApproachDistance / 240d), "ringdistance" => ExpressionValue.Number(_combatSettings.RingDistance / 240d), "arcrange" => ExpressionValue.Number(_combatSettings.ArcRange / 240d), "targetselectanglerange" => ExpressionValue.Number( _combatSettings.TargetSelectAngleRange / 240d), "corpseapproachrange-max" => ExpressionValue.Number( _inventorySettings.Loot.CorpseApproachRange / 240d), "corpseapproachrange-min" => ExpressionValue.Number( _inventorySettings.Loot.CorpseMinimumApproachRange / 240d), "navclosestoprange" => ExpressionValue.Number( _navigationSettings.MinimumDistanceMeters / 240d), "navfarstoprange" => ExpressionValue.Number( _navigationSettings.MaximumDistanceMeters / 240d), "useportaldistance" => ExpressionValue.Number( _navigationSettings.PortalUseDistanceMeters / 240d), "helperdistancehitp" => ExpressionValue.Number( _vitalSettings.HelperHealthDistance / 240d), "helperdistancestam" => ExpressionValue.Number( _vitalSettings.HelperStaminaDistance / 240d), "helperdistancemana" => ExpressionValue.Number( _vitalSettings.HelperManaDistance / 240d), "minimumringtargets" => ExpressionValue.Number( _combatSettings.MinimumRingTargets), "defaultmeleeattackheight" => ExpressionValue.Number( (int)_combatSettings.AttackHeight), "defaultmeleeattackpower" or "attackpower" => ExpressionValue.Number(_combatSettings.AttackPower), "targetlock" => ExpressionValue.Boolean(_combatSettings.TargetLock), "idlepeacemode" => ExpressionValue.Boolean( _combatSettings.IdlePeaceMode), "stopmacroondeath" => ExpressionValue.Boolean( _combatSettings.StopMacroOnDeath), "jumpoutwandcasting" => ExpressionValue.Boolean( _combatSettings.JumpOutWandCasting), "dojiggle" => ExpressionValue.Boolean(_combatSettings.DoJiggle), "randomhelperbuffs" => ExpressionValue.Boolean( _buffSettings.RandomHelperBuffs), "randomhelperintervalseconds" => ExpressionValue.Number( _buffSettings.RandomHelperIntervalSeconds), "idlebufftopoff" => ExpressionValue.Boolean( _buffSettings.IdleBuffTopoff), "idlebufftopofftimeseconds" => ExpressionValue.Number( _buffSettings.IdleBuffTopoffSeconds), "buffprofile-prots" => ExpressionValue.String( _buffSettings.ProtectionElements), "buffprofile-banes" => ExpressionValue.String( _buffSettings.BaneElements), "buffprofile_prots" => ExpressionValue.Number( _buffSettings.ProtectionProfileMode), "buffprofile_banes" => ExpressionValue.Number( _buffSettings.BaneProfileMode), "targetselectmethod" => ExpressionValue.Number( (int)_combatSettings.SelectionMethod + 1), "autoattackpower" => ExpressionValue.Boolean( _combatSettings.AutoAttackPower), "userecklessness" => ExpressionValue.Boolean( _combatSettings.UseRecklessness), "debuffeachfirst" => ExpressionValue.Number( (int)_combatSettings.DebuffEachFirst), "debuffselectionmethod" => ExpressionValue.Number( (int)_combatSettings.DebuffSelectionMethod), "debuffprecastseconds" => ExpressionValue.Number( _combatSettings.DebuffPrecastSeconds), "switchwandstodebuff" => ExpressionValue.Boolean( _combatSettings.SwitchWandsToDebuff), "usearcs" => ExpressionValue.Boolean(_combatSettings.UseArcs), "deleteghostmonsters" => ExpressionValue.Boolean( _combatSettings.DeleteGhostMonsters), "ghostmonsterspellattemptcount" => ExpressionValue.Number( _combatSettings.GhostMonsterSpellAttemptCount), "blacklistmonsterattemptcount" => ExpressionValue.Number( _combatSettings.BlacklistMonsterAttemptCount), "blacklistmonstertimeoutseconds" => ExpressionValue.Number( _combatSettings.BlacklistMonsterTimeoutSeconds), "deleteghostmonstersbyhptracker" => ExpressionValue.Boolean( _combatSettings.DeleteGhostMonstersByHealthTracker), "ghostdeletehptrackerseconds" => ExpressionValue.Number( _combatSettings.GhostDeleteHealthTrackerSeconds), "summonpets" => ExpressionValue.Boolean(_combatSettings.SummonPets), "petrangemode" => ExpressionValue.Number((int)_combatSettings.PetRangeMode), "petcustomrange" => ExpressionValue.Number( _combatSettings.PetCustomRange / 240d), "petmonsterdensity" => ExpressionValue.Number( _combatSettings.PetMonsterDensity), "petrefillcount-idle" => ExpressionValue.Number( _combatSettings.PetRefillCountIdle), "petrefillcount-normal" => ExpressionValue.Number( _combatSettings.PetRefillCountNormal), "openapproachdoors" or "opendoors" => ExpressionValue.Boolean(_navigationSettings.OpenDoors), "dooridrange" => ExpressionValue.Number( _navigationSettings.DoorIdentifyRangeMeters / 240d), "dooropenrange" => ExpressionValue.Number( _navigationSettings.DoorOpenRangeMeters / 240d), "doorlockpickdiffexcessthreshold" => ExpressionValue.Number( _navigationSettings.DoorLockpickExcessThreshold), "navpriorityboost" => ExpressionValue.Boolean( _navigationSettings.Priority), "followaroundcorners" => ExpressionValue.Boolean( _navigationSettings.FollowAroundCorners), "autofellowmanagement" => ExpressionValue.Boolean( _combatSettings.AutoFellowManagement), "enablestack" or "enableautostack" => ExpressionValue.Boolean(_inventorySettings.AutoStack), "autostack" => ExpressionValue.Boolean(_inventorySettings.AutoStack), "enablecram" or "enableautocram" => ExpressionValue.Boolean(_inventorySettings.AutoCram), "autocram" => ExpressionValue.Boolean(_inventorySettings.AutoCram), "autocraftitems" => ExpressionValue.Boolean(_inventorySettings.AutoCraftItems), "splitpeas" => ExpressionValue.Boolean(_inventorySettings.SplitPeas), "spellcompmin-critical" => ExpressionValue.Number( _inventorySettings.CriticalComponentMinimum), "spellcompmin-normal" => ExpressionValue.Number( _inventorySettings.NormalComponentMinimum), "spellcompmin-idle" => ExpressionValue.Number( _inventorySettings.IdleComponentMinimum), "idlecraftcount_healthkits" or "idlecraftcount-healthkits" => ExpressionValue.Number( _inventorySettings.IdleHealthKitCount), "idlecraftcount_stamkits" or "idlecraftcount-stamkits" => ExpressionValue.Number( _inventorySettings.IdleStaminaKitCount), "idlecraftcount_manakits" or "idlecraftcount-manakits" => ExpressionValue.Number( _inventorySettings.IdleManaKitCount), "idlecraftcount_healthfood" or "idlecraftcount-healthfood" => ExpressionValue.Number( _inventorySettings.IdleHealthFoodCount), "idlecraftcount_stamfood" or "idlecraftcount-stamfood" => ExpressionValue.Number( _inventorySettings.IdleStaminaFoodCount), "idlecraftcount_manafood" or "idlecraftcount-manafood" => ExpressionValue.Number( _inventorySettings.IdleManaFoodCount), "refillwornmana" => ExpressionValue.Boolean( _inventorySettings.RefillWornMana), "manachargeswhenoff" => ExpressionValue.Boolean( _inventorySettings.ManaChargesWhenOff), "refillwornmana-item-manapercent" => ExpressionValue.Number( _inventorySettings.RefillWornManaPercent), "readunknownscrolls" => ExpressionValue.Boolean( _inventorySettings.Loot.ReadUnknownScrolls), "lootallcorpses" => ExpressionValue.Boolean( _inventorySettings.Loot.LootAllCorpses), "lootfellowcorpses" => ExpressionValue.Boolean( _inventorySettings.Loot.LootFellowCorpses), "lootpriorityboost" => ExpressionValue.Boolean( _inventorySettings.Loot.PriorityBoost), "lootonlyrarecorpses" => ExpressionValue.Boolean( _inventorySettings.Loot.LootOnlyRareCorpses), "combinesalvage" => ExpressionValue.Boolean( _inventorySettings.Loot.CombineSalvage), "manastonelootcount" => ExpressionValue.Number( _inventorySettings.Loot.ManaStoneLootCount), "manatankminimummana" => ExpressionValue.Number( _inventorySettings.Loot.ManaTankMinimumMana), "corpsecachetimeoutminutes" => ExpressionValue.Number( _inventorySettings.Loot.CorpseCacheTimeoutMinutes), "corpseitemappearancetimeoutseconds" => ExpressionValue.Number( _inventorySettings.Loot.CorpseItemAppearanceTimeoutSeconds), "corpseitemidtimeoutseconds" => ExpressionValue.Number( _inventorySettings.Loot.CorpseItemIdentifyTimeoutSeconds), "corpseopentimeoutseconds" => ExpressionValue.Number( _inventorySettings.Loot.CorpseOpenTimeoutSeconds), "blacklistcorpseopenattemptcount" => ExpressionValue.Number( _inventorySettings.Loot.BlacklistCorpseOpenAttemptCount), "blacklistcorpseopentimeoutseconds" => ExpressionValue.Number( _inventorySettings.Loot.BlacklistCorpseOpenTimeoutSeconds), "corpselootitemmaxattempts" => ExpressionValue.Number( _inventorySettings.Loot.CorpseLootItemMaxAttempts), "minimumhealkitsuccesschance" => ExpressionValue.Number( _vitalSettings.MinimumHealKitSuccessChance), "usehealersheart" => ExpressionValue.Boolean( _vitalSettings.UseHealersHeart), "rechargeboosttimeseconds" => ExpressionValue.Number( _vitalSettings.RechargeBoostTimeSeconds), "rechargeboostamount" => ExpressionValue.Number( _vitalSettings.RechargeBoostAmount), "clearlevelboostflagoncast" => ExpressionValue.Boolean( _vitalSettings.ClearLevelBoostFlagOnCast), "whoyougonnacall" => ExpressionValue.Boolean( _combatSettings.WhoYouGonnaCall), "castdispelself" => ExpressionValue.Boolean( _vitalSettings.CastDispelSelf), "usedispelitems" => ExpressionValue.Boolean( _vitalSettings.UseDispelItems), "usedispeldrum" => ExpressionValue.Boolean( _vitalSettings.UseDispelDrum), "usekitsinmagicmode" => ExpressionValue.Boolean( _vitalSettings.UseKitsInMagicMode), "gotopeacemodetousekits" => ExpressionValue.Boolean( _vitalSettings.GoToPeaceModeToUseKits), "staminatohealthmultiplier" => ExpressionValue.Number( _vitalSettings.StaminaToHealthMultiplier), "manatohealthmultiplier" => ExpressionValue.Number( _vitalSettings.ManaToHealthMultiplier), "recharge-norm-hitp" => ExpressionValue.Number( _vitalSettings.NormalHealth * 100d), "recharge-norm-stam" => ExpressionValue.Number( _vitalSettings.NormalStamina * 100d), "recharge-norm-mana" => ExpressionValue.Number( _vitalSettings.NormalMana * 100d), "recharge-notarg-hitp" => ExpressionValue.Number( _vitalSettings.NoTargetHealth * 100d), "recharge-notarg-stam" => ExpressionValue.Number( _vitalSettings.NoTargetStamina * 100d), "recharge-notarg-mana" => ExpressionValue.Number( _vitalSettings.NoTargetMana * 100d), "recharge-helper-hitp" => ExpressionValue.Number( _vitalSettings.HelperHealth * 100d), "recharge-helper-stam" => ExpressionValue.Number( _vitalSettings.HelperStamina * 100d), "recharge-helper-mana" => ExpressionValue.Number( _vitalSettings.HelperMana * 100d), "rebufftimeremainingseconds" => ExpressionValue.Number( _buffSettings.RebuffWhenUnderSeconds), "buffcastrecast_seconds" => ExpressionValue.Number( _buffSettings.BuffCastRecastSeconds), "buffcastrecastreset_seconds" => ExpressionValue.Number( _buffSettings.BuffCastRecastResetSeconds), "blacklistedspellcomps" => ExpressionValue.String( _buffSettings.BlacklistedSpellComponents), "droptopeacemoderetrycount" => ExpressionValue.Number( _vitalSettings.DropToPeaceModeRetryCount), "fastcastbuffs" => ExpressionValue.Boolean( _buffSettings.FastCastBuffs), "usebreakableturnto" => ExpressionValue.Boolean( _combatSettings.UseBreakableTurnTo), "useprojectileawareness" => ExpressionValue.Boolean( _combatSettings.UseProjectileAwareness), "collisionprojectileradius" => ExpressionValue.Number( _combatSettings.CollisionProjectileRadius), "collisionstepdistance" => ExpressionValue.Number( _combatSettings.CollisionStepDistance), "showcollisiondebug" => ExpressionValue.Boolean( _combatSettings.ShowCollisionDebug), "maximumcollisioncheckspertick" => ExpressionValue.Number( _combatSettings.MaximumCollisionChecksPerTick), "usespecialammo" => ExpressionValue.Number( _combatSettings.UseSpecialAmmo), "spellrangefudge" => ExpressionValue.Number( _combatSettings.SpellRangeFudge), "buffwithuntrained-item" => ExpressionValue.Number( _buffSettings.BuffWithUntrainedItemSkill), "buffwithuntrained-creature" => ExpressionValue.Number( _buffSettings.BuffWithUntrainedCreatureSkill), "buffwithuntrained-life" => ExpressionValue.Number( _buffSettings.BuffWithUntrainedLifeSkill), "allowdebufffallback" => ExpressionValue.Boolean( _combatSettings.AllowDebuffFallback), "rechargehandlerset" => ExpressionValue.String( _vitalSettings.RechargeHandlerSet), _ => _combatSettings.DynamicSettings.TryGetValue(key, out MonsterValue value) ? ToExpressionValue(value) : ToExpressionValue(VtankOptionCatalog.Default(key)), }; } private static ExpressionValue ToExpressionValue(MonsterValue value) => value.Kind switch { MonsterValueKind.Number => ExpressionValue.Number(value.Number), MonsterValueKind.Boolean => ExpressionValue.Boolean(value.Boolean), _ => ExpressionValue.String(value.Text), }; private double GetDynamicNumber(string name, double fallback) => _combatSettings.DynamicSettings.TryGetValue(name, out MonsterValue value) && value.Kind == MonsterValueKind.Number ? value.Number : fallback; private void RegisterVtankExpressionFunctions() { ExpressionFunctionRegistry functions = _expressions.Registry; functions.Register("vtsetmetastate", 1, 1, (_, args) => { _meta.Transition(args[0].AsString("vtsetmetastate")); _combatSettings.MetaState = _meta.CurrentState; return ExpressionValue.One; }, "vtsetmetastate[state]"); functions.Register("vtgetmetastate", 0, 0, (_, _) => ExpressionValue.String(_meta.CurrentState), "vtgetmetastate[]"); functions.Register("vtgetmeta", 0, 0, (_, _) => ExpressionValue.String(_metaProfiles.Selected), "vtgetmeta[]"); functions.Register("vtsetsetting", 2, 2, (_, args) => { string name = args[0].AsString("vtsetsetting"); ExpressionValue value = args[1]; if (value.Kind == ExpressionValueKind.String && double.TryParse( value.AsString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) { value = ExpressionValue.Number(number); } return ExpressionValue.Boolean(SetMetaOption(name, value)); }, "vtsetsetting[setting,value]"); functions.Register("vtgetsetting", 1, 1, (_, args) => ExpressionValue.String(GetMetaOption( args[0].AsString("vtgetsetting")).ToDisplayString()), "vtgetsetting[setting]"); functions.Register("uboptset", 2, 2, (_, args) => ExpressionValue.Boolean(SetMetaOption( args[0].AsString("uboptset"), args[1])), "uboptset[setting,value]"); functions.Register("uboptget", 1, 1, (_, args) => GetMetaOption(args[0].AsString("uboptget")), "uboptget[setting]"); functions.Register("actiontrygiveprofile", 2, 2, (_, args) => ExpressionValue.Boolean(_profileGive.TryStart( args[0].AsString("actiontrygiveprofile"), args[1].AsString("actiontrygiveprofile"))), "actiontrygiveprofile[lootprofile,target]"); functions.Register("vtmacroenabled", 0, 0, (_, _) => ExpressionValue.Boolean(_combat.Enabled), "vtmacroenabled[]"); } private bool SetMetaOption(string name, ExpressionValue value) { string canonical = VtankOptionCatalog.IsKnown(name) ? VtankOptionCatalog.Canonical(name) : name.Trim(); string key = canonical.ToLowerInvariant(); switch (key) { case "enablebuffing": _buffSettings.Enabled = value.IsTruthy; break; case "enablecombat": _combatSettings.Enabled = value.IsTruthy; break; case "enablenav": case "enablenavigation": case "enableautonavigator": _navigationSettings.Enabled = value.IsTruthy; break; case "enablelooting": _inventorySettings.Loot.Enabled = value.IsTruthy; break; case "enablemeta": _meta.SetEnabled(value.IsTruthy); break; case "spelldiffexcessthreshold-hunt": _combatSettings.HuntSkillExcessOverDifficulty = Math.Clamp( value.AsInt32("SpellDiffExcessThreshold-Hunt"), -100, 500); break; case "arrowheadfletchdiffexcessthreshold": _inventorySettings.ArrowheadFletchDifficultyExcess = Math.Clamp( value.AsInt32("ArrowheadFletchDiffExcessThreshold"), -100, 500); break; case "dohelp": _vitalSettings.HelpOthers = value.IsTruthy; break; case "monsterrange": _combatSettings.MaximumRange = Math.Clamp( checked((float)value.AsNumber("MonsterRange")), 1f, 100f); break; case "attackdistance": _combatSettings.MaximumRange = Math.Clamp( checked((float)(value.AsNumber("AttackDistance") * 240d)), 1f, 100f); break; case "attackminimumdistance": _combatSettings.MinimumRange = Math.Clamp( checked((float)(value.AsNumber("AttackMinimumDistance") * 240d)), 0f, 100f); break; case "approachdistance": _combatSettings.ApproachDistance = Math.Clamp( checked((float)(value.AsNumber("ApproachDistance") * 240d)), 0f, 100f); break; case "ringdistance": _combatSettings.RingDistance = Math.Clamp( checked((float)(value.AsNumber("RingDistance") * 240d)), 1f, 100f); break; case "arcrange": _combatSettings.ArcRange = Math.Clamp( checked((float)(value.AsNumber("ArcRange") * 240d)), 1f, 100f); break; case "targetselectanglerange": _combatSettings.TargetSelectAngleRange = Math.Clamp( checked((float)(value.AsNumber("TargetSelectAngleRange") * 240d)), 1f, 100f); break; case "corpseapproachrange-max": _inventorySettings.Loot.CorpseApproachRange = Math.Clamp( checked((float)(value.AsNumber("CorpseApproachRange-Max") * 240d)), 1f, 100f); break; case "corpseapproachrange-min": _inventorySettings.Loot.CorpseMinimumApproachRange = Math.Clamp( checked((float)(value.AsNumber("CorpseApproachRange-Min") * 240d)), 0f, 100f); break; case "navclosestoprange": _navigationSettings.MinimumDistanceMeters = Math.Clamp( value.AsNumber("NavCloseStopRange") * 240d, 0.5d, 50d); break; case "navfarstoprange": _navigationSettings.MaximumDistanceMeters = Math.Clamp( value.AsNumber("NavFarStopRange") * 240d, _navigationSettings.MinimumDistanceMeters, 240_000_000d); break; case "useportaldistance": _navigationSettings.PortalUseDistanceMeters = Math.Clamp( value.AsNumber("UsePortalDistance") * 240d, 0.5d, 50d); break; case "helperdistancehitp": _vitalSettings.HelperHealthDistance = Math.Clamp( checked((float)(value.AsNumber("HelperDistanceHitP") * 240d)), 1f, 100f); break; case "helperdistancestam": _vitalSettings.HelperStaminaDistance = Math.Clamp( checked((float)(value.AsNumber("HelperDistanceStam") * 240d)), 1f, 100f); break; case "helperdistancemana": _vitalSettings.HelperManaDistance = Math.Clamp( checked((float)(value.AsNumber("HelperDistanceMana") * 240d)), 1f, 100f); break; case "minimumringtargets": _combatSettings.MinimumRingTargets = Math.Clamp( value.AsInt32("MinimumRingTargets"), 1, 25); break; case "defaultmeleeattackheight": _combatSettings.AttackHeight = (PluginAttackHeight)Math.Clamp( value.AsInt32("DefaultMeleeAttackHeight"), 1, 3); break; case "defaultmeleeattackpower": case "attackpower": _combatSettings.AttackPower = Math.Clamp( checked((float)value.AsNumber("AttackPower")), 0f, 1f); break; case "targetlock": _combatSettings.TargetLock = value.IsTruthy; break; case "idlepeacemode": _combatSettings.IdlePeaceMode = value.IsTruthy; break; case "stopmacroondeath": _combatSettings.StopMacroOnDeath = value.IsTruthy; break; case "jumpoutwandcasting": _combatSettings.JumpOutWandCasting = value.IsTruthy; break; case "dojiggle": _combatSettings.DoJiggle = value.IsTruthy; break; case "randomhelperbuffs": _buffSettings.RandomHelperBuffs = value.IsTruthy; break; case "randomhelperintervalseconds": _buffSettings.RandomHelperIntervalSeconds = Math.Clamp( value.AsNumber("RandomHelperIntervalSeconds"), 0.25d, 3600d); break; case "idlebufftopoff": _buffSettings.IdleBuffTopoff = value.IsTruthy; break; case "idlebufftopofftimeseconds": _buffSettings.IdleBuffTopoffSeconds = Math.Clamp( value.AsNumber("IdleBuffTopoffTimeSeconds"), 30d, 7200d); break; case "buffprofile-prots": _buffSettings.ProtectionElements = NormalizeElementProfile( value.ToDisplayString()); break; case "buffprofile-banes": _buffSettings.BaneElements = NormalizeElementProfile( value.ToDisplayString()); break; case "buffprofile_prots": _buffSettings.ProtectionProfileMode = Math.Clamp( value.AsInt32("BuffProfile_Prots"), 1, 8); break; case "buffprofile_banes": _buffSettings.BaneProfileMode = Math.Clamp( value.AsInt32("BuffProfile_Banes"), 1, 8); break; case "targetselectmethod": _combatSettings.SelectionMethod = (TargetSelectionMethod)Math.Clamp( value.AsInt32("TargetSelectMethod") - 1, 0, 2); break; case "autoattackpower": _combatSettings.AutoAttackPower = value.IsTruthy; break; case "userecklessness": _combatSettings.UseRecklessness = value.IsTruthy; break; case "debuffeachfirst": _combatSettings.DebuffEachFirst = (DebuffEachFirst)Math.Clamp( value.AsInt32("DebuffEachFirst"), 1, 3); break; case "debuffselectionmethod": _combatSettings.DebuffSelectionMethod = (DebuffSelectionMethod)Math.Clamp( value.AsInt32("DebuffSelectionMethod"), 1, 2); break; case "debuffprecastseconds": _combatSettings.DebuffPrecastSeconds = Math.Clamp( value.AsNumber("DebuffPrecastSeconds"), 0d, 60d); break; case "switchwandstodebuff": _combatSettings.SwitchWandsToDebuff = value.IsTruthy; break; case "usearcs": _combatSettings.UseArcs = value.IsTruthy; break; case "deleteghostmonsters": _combatSettings.DeleteGhostMonsters = value.IsTruthy; break; case "ghostmonsterspellattemptcount": _combatSettings.GhostMonsterSpellAttemptCount = Math.Clamp( value.AsInt32("GhostMonsterSpellAttemptCount"), 1, 1000); break; case "blacklistmonsterattemptcount": _combatSettings.BlacklistMonsterAttemptCount = Math.Clamp( value.AsInt32("BlacklistMonsterAttemptCount"), 1, 20); break; case "blacklistmonstertimeoutseconds": _combatSettings.BlacklistMonsterTimeoutSeconds = Math.Clamp( value.AsNumber("BlacklistMonsterTimeoutSeconds"), 1d, 3600d); break; case "deleteghostmonstersbyhptracker": _combatSettings.DeleteGhostMonstersByHealthTracker = value.IsTruthy; break; case "ghostdeletehptrackerseconds": _combatSettings.GhostDeleteHealthTrackerSeconds = Math.Clamp( value.AsNumber("GhostDeleteHPTrackerSeconds"), 1d, 300d); break; case "summonpets": _combatSettings.SummonPets = value.IsTruthy; break; case "petrangemode": _combatSettings.PetRangeMode = (PetRangeMode)Math.Clamp( value.AsInt32("PetRangeMode"), 0, 1); break; case "petcustomrange": _combatSettings.PetCustomRange = Math.Clamp( checked((float)(value.AsNumber("PetCustomRange") * 240d)), 1f, 100f); break; case "petmonsterdensity": _combatSettings.PetMonsterDensity = Math.Clamp( value.AsInt32("PetMonsterDensity"), 1, 25); break; case "petrefillcount-idle": _combatSettings.PetRefillCountIdle = Math.Clamp( value.AsInt32("PetRefillCount-Idle"), 0, 3); break; case "petrefillcount-normal": _combatSettings.PetRefillCountNormal = Math.Clamp( value.AsInt32("PetRefillCount-Normal"), 0, 3); break; case "openapproachdoors": case "opendoors": _navigationSettings.OpenDoors = value.IsTruthy; break; case "dooridrange": _navigationSettings.DoorIdentifyRangeMeters = Math.Clamp( value.AsNumber("DoorIDRange") * 240d, 1d, 100d); break; case "dooropenrange": _navigationSettings.DoorOpenRangeMeters = Math.Clamp( value.AsNumber("DoorOpenRange") * 240d, 0.5d, _navigationSettings.DoorIdentifyRangeMeters); break; case "doorlockpickdiffexcessthreshold": _navigationSettings.DoorLockpickExcessThreshold = Math.Clamp( value.AsInt32("DoorLockpickDiffExcessThreshold"), -500, 500); break; case "navpriorityboost": _navigationSettings.Priority = value.IsTruthy; break; case "followaroundcorners": _navigationSettings.FollowAroundCorners = value.IsTruthy; break; case "autofellowmanagement": _combatSettings.AutoFellowManagement = value.IsTruthy; break; case "enablestack": case "enableautostack": case "autostack": _inventorySettings.AutoStack = value.IsTruthy; break; case "enablecram": case "enableautocram": case "autocram": _inventorySettings.AutoCram = value.IsTruthy; break; case "autocraftitems": _inventorySettings.AutoCraftItems = value.IsTruthy; break; case "splitpeas": _inventorySettings.SplitPeas = value.IsTruthy; break; case "spellcompmin-critical": _inventorySettings.CriticalComponentMinimum = Math.Clamp( value.AsInt32("SpellCompMin-Critical"), 0, 1000); break; case "spellcompmin-normal": _inventorySettings.NormalComponentMinimum = Math.Clamp( value.AsInt32("SpellCompMin-Normal"), 0, 1000); break; case "spellcompmin-idle": _inventorySettings.IdleComponentMinimum = Math.Clamp( value.AsInt32("SpellCompMin-Idle"), 0, 1000); break; case "idlecraftcount_healthkits": case "idlecraftcount-healthkits": _inventorySettings.IdleHealthKitCount = Math.Clamp( value.AsInt32("IdleCraftCount_HealthKits"), 0, 1000); break; case "idlecraftcount_stamkits": case "idlecraftcount-stamkits": _inventorySettings.IdleStaminaKitCount = Math.Clamp( value.AsInt32("IdleCraftCount_StamKits"), 0, 1000); break; case "idlecraftcount_manakits": case "idlecraftcount-manakits": _inventorySettings.IdleManaKitCount = Math.Clamp( value.AsInt32("IdleCraftCount_ManaKits"), 0, 1000); break; case "idlecraftcount_healthfood": case "idlecraftcount-healthfood": _inventorySettings.IdleHealthFoodCount = Math.Clamp( value.AsInt32("IdleCraftCount_HealthFood"), 0, 1000); break; case "idlecraftcount_stamfood": case "idlecraftcount-stamfood": _inventorySettings.IdleStaminaFoodCount = Math.Clamp( value.AsInt32("IdleCraftCount_StamFood"), 0, 1000); break; case "idlecraftcount_manafood": case "idlecraftcount-manafood": _inventorySettings.IdleManaFoodCount = Math.Clamp( value.AsInt32("IdleCraftCount_ManaFood"), 0, 1000); break; case "refillwornmana": _inventorySettings.RefillWornMana = value.IsTruthy; break; case "manachargeswhenoff": _inventorySettings.ManaChargesWhenOff = value.IsTruthy; break; case "refillwornmana-item-manapercent": _inventorySettings.RefillWornManaPercent = Math.Clamp( value.AsInt32("RefillWornMana-Item-ManaPercent"), 0, 100); break; case "readunknownscrolls": _inventorySettings.Loot.ReadUnknownScrolls = value.IsTruthy; break; case "lootallcorpses": _inventorySettings.Loot.LootAllCorpses = value.IsTruthy; break; case "lootfellowcorpses": _inventorySettings.Loot.LootFellowCorpses = value.IsTruthy; break; case "lootpriorityboost": _inventorySettings.Loot.PriorityBoost = value.IsTruthy; break; case "lootonlyrarecorpses": _inventorySettings.Loot.LootOnlyRareCorpses = value.IsTruthy; break; case "combinesalvage": _inventorySettings.Loot.CombineSalvage = value.IsTruthy; break; case "manastonelootcount": _inventorySettings.Loot.ManaStoneLootCount = Math.Clamp( value.AsInt32("ManaStoneLootCount"), 0, 1000); break; case "manatankminimummana": _inventorySettings.Loot.ManaTankMinimumMana = Math.Clamp( value.AsInt32("ManaTankMinimumMana"), 1, int.MaxValue); break; case "corpsecachetimeoutminutes": _inventorySettings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp( value.AsNumber("CorpseCacheTimeoutMinutes"), 1d, 1440d); break; case "corpseitemappearancetimeoutseconds": _inventorySettings.Loot.CorpseItemAppearanceTimeoutSeconds = Math.Clamp( value.AsNumber("CorpseItemAppearanceTimeoutSeconds"), 0d, 300d); break; case "corpseitemidtimeoutseconds": _inventorySettings.Loot.CorpseItemIdentifyTimeoutSeconds = Math.Clamp( value.AsNumber("CorpseItemIDTimeoutSeconds"), 1d, 600d); break; case "corpseopentimeoutseconds": _inventorySettings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp( value.AsNumber("CorpseOpenTimeoutSeconds"), 0.1d, 60d); break; case "blacklistcorpseopenattemptcount": _inventorySettings.Loot.BlacklistCorpseOpenAttemptCount = Math.Clamp( value.AsInt32("BlacklistCorpseOpenAttemptCount"), 1, 1000); break; case "blacklistcorpseopentimeoutseconds": _inventorySettings.Loot.BlacklistCorpseOpenTimeoutSeconds = Math.Clamp( value.AsNumber("BlacklistCorpseOpenTimeoutSeconds"), 1d, 3600d); break; case "corpselootitemmaxattempts": _inventorySettings.Loot.CorpseLootItemMaxAttempts = Math.Clamp( value.AsInt32("CorpseLootItemMaxAttempts"), 1, 1000); break; case "minimumhealkitsuccesschance": _vitalSettings.MinimumHealKitSuccessChance = Math.Clamp( value.AsInt32("MinimumHealKitSuccessChance"), 0, 100); break; case "usehealersheart": _vitalSettings.UseHealersHeart = value.IsTruthy; _vitalRecharge.Reset(); break; case "rechargeboosttimeseconds": _vitalSettings.RechargeBoostTimeSeconds = Math.Clamp( value.AsNumber("RechargeBoostTimeSeconds"), 0d, 300d); break; case "rechargeboostamount": _vitalSettings.RechargeBoostAmount = Math.Clamp( value.AsInt32("RechargeBoostAmount"), 0, 1000); break; case "clearlevelboostflagoncast": _vitalSettings.ClearLevelBoostFlagOnCast = value.IsTruthy; break; case "whoyougonnacall": _combatSettings.WhoYouGonnaCall = value.IsTruthy; break; case "castdispelself": _vitalSettings.CastDispelSelf = value.IsTruthy; _dispel.Reset(); break; case "usedispelitems": _vitalSettings.UseDispelItems = value.IsTruthy; _dispel.Reset(); break; case "usedispeldrum": _vitalSettings.UseDispelDrum = value.IsTruthy; _dispel.Reset(); break; case "usekitsinmagicmode": _vitalSettings.UseKitsInMagicMode = value.IsTruthy; break; case "gotopeacemodetousekits": _vitalSettings.GoToPeaceModeToUseKits = value.IsTruthy; break; case "staminatohealthmultiplier": _vitalSettings.StaminaToHealthMultiplier = Math.Clamp( value.AsNumber("StaminaToHealthMultiplier"), 0d, 10d); break; case "manatohealthmultiplier": _vitalSettings.ManaToHealthMultiplier = Math.Clamp( value.AsNumber("ManaToHealthMultiplier"), 0d, 10d); break; case "recharge-norm-hitp": _vitalSettings.NormalHealth = Math.Clamp( value.AsNumber("Recharge-Norm-HitP") / 100d, 0d, 1d); break; case "recharge-norm-stam": _vitalSettings.NormalStamina = Math.Clamp( value.AsNumber("Recharge-Norm-Stam") / 100d, 0d, 1d); break; case "recharge-norm-mana": _vitalSettings.NormalMana = Math.Clamp( value.AsNumber("Recharge-Norm-Mana") / 100d, 0d, 1d); break; case "recharge-notarg-hitp": _vitalSettings.NoTargetHealth = Math.Clamp( value.AsNumber("Recharge-NoTarg-HitP") / 100d, 0d, 1d); break; case "recharge-notarg-stam": _vitalSettings.NoTargetStamina = Math.Clamp( value.AsNumber("Recharge-NoTarg-Stam") / 100d, 0d, 1d); break; case "recharge-notarg-mana": _vitalSettings.NoTargetMana = Math.Clamp( value.AsNumber("Recharge-NoTarg-Mana") / 100d, 0d, 1d); break; case "recharge-helper-hitp": _vitalSettings.HelperHealth = Math.Clamp( value.AsNumber("Recharge-Helper-HitP") / 100d, 0d, 1d); break; case "recharge-helper-stam": _vitalSettings.HelperStamina = Math.Clamp( value.AsNumber("Recharge-Helper-Stam") / 100d, 0d, 1d); break; case "recharge-helper-mana": _vitalSettings.HelperMana = Math.Clamp( value.AsNumber("Recharge-Helper-Mana") / 100d, 0d, 1d); break; case "spelldiffexcessthreshold-buff": _buffSettings.SkillExcessOverDifficulty = Math.Clamp( value.AsInt32("SpellDiffExcessThreshold-Buff"), -100, 100); break; case "rebufftimeremainingseconds": _buffSettings.RebuffWhenUnderSeconds = Math.Clamp( value.AsNumber("RebuffTimeRemainingSeconds"), 0d, 3600d); break; case "buffcastrecast_seconds": _buffSettings.BuffCastRecastSeconds = Math.Clamp( value.AsNumber("BuffCastRecast_Seconds"), 0d, 3600d); break; case "buffcastrecastreset_seconds": _buffSettings.BuffCastRecastResetSeconds = Math.Clamp( value.AsNumber("BuffCastRecastReset_Seconds"), 0d, 3600d); break; case "blacklistedspellcomps": _buffSettings.BlacklistedSpellComponents = value.ToDisplayString(); _combatSettings.BlacklistedSpellComponents = _buffSettings.BlacklistedSpellComponents; break; case "droptopeacemoderetrycount": _vitalSettings.DropToPeaceModeRetryCount = Math.Clamp( value.AsInt32("DropToPeaceModeRetryCount"), 1, 1000); break; case "fastcastbuffs": _buffSettings.FastCastBuffs = value.IsTruthy; break; case "usebreakableturnto": _combatSettings.UseBreakableTurnTo = value.IsTruthy; break; case "useprojectileawareness": _combatSettings.UseProjectileAwareness = value.IsTruthy; break; case "collisionprojectileradius": _combatSettings.CollisionProjectileRadius = Math.Clamp( checked((float)value.AsNumber("CollisionProjectileRadius")), 0f, 10f); break; case "collisionstepdistance": _combatSettings.CollisionStepDistance = Math.Clamp( checked((float)value.AsNumber("CollisionStepDistance")), 0.01f, 10f); break; case "showcollisiondebug": _combatSettings.ShowCollisionDebug = value.IsTruthy; break; case "maximumcollisioncheckspertick": _combatSettings.MaximumCollisionChecksPerTick = Math.Clamp( value.AsInt32("MaximumCollisionChecksPerTick"), 1, 100_000); break; case "usespecialammo": _combatSettings.UseSpecialAmmo = Math.Clamp( value.AsInt32("UseSpecialAmmo"), 0, 3); break; case "spellrangefudge": _combatSettings.SpellRangeFudge = Math.Clamp( checked((float)value.AsNumber("SpellRangeFudge")), 0f, 75f); break; case "buffwithuntrained-item": _buffSettings.BuffWithUntrainedItemSkill = Math.Clamp( value.AsInt32("BuffWithUntrained-Item"), 0, 275); break; case "buffwithuntrained-creature": _buffSettings.BuffWithUntrainedCreatureSkill = Math.Clamp( value.AsInt32("BuffWithUntrained-Creature"), 0, 275); break; case "buffwithuntrained-life": _buffSettings.BuffWithUntrainedLifeSkill = Math.Clamp( value.AsInt32("BuffWithUntrained-Life"), 0, 275); break; case "allowdebufffallback": _combatSettings.AllowDebuffFallback = value.IsTruthy; break; case "rechargehandlerset": _vitalSettings.RechargeHandlerSet = value.ToDisplayString(); break; default: break; } _combatSettings.DynamicSettings[canonical] = ToMonsterValue(value); if (!_applyingProfileOptions) SaveProfile(); return true; } private static MonsterValue ToMonsterValue(ExpressionValue value) => value.Kind switch { ExpressionValueKind.Boolean => MonsterValue.FromBoolean(value.IsTruthy), ExpressionValueKind.Number => MonsterValue.FromNumber(value.AsNumber()), _ => MonsterValue.FromText(value.ToDisplayString()), }; private static string NormalizeElementProfile(string value) { const string order = "ALFCBPS"; var result = new StringBuilder(order.Length); foreach (char element in order) { if (value.IndexOf(element, StringComparison.OrdinalIgnoreCase) >= 0) result.Append(element); } return result.ToString(); } private void SelectProfile(string name) { SaveProfile(); if (!_profiles.Select(name)) { _profileLifecycleNotice = $"Profile '{name}' is unavailable."; return; } LoadSelectedProfile(); _profileLifecycleNotice = $"Loaded {_profiles.Selected}."; } private void CreateProfileCore(bool copyCurrent) { if (!_profiles.Create( _profileNameDraft, copyCurrent, _combatSettings, _buffSettings, _vitalSettings, _inventorySettings, _noBuffItemNames, out string notice)) { _profileLifecycleNotice = notice; return; } _profileNameDraft = string.Empty; _profileLifecycleNotice = notice; ResetProfileConsumers(); } private void ClearProfileCore() { _profiles.ClearCurrent( _combatSettings, _buffSettings, _vitalSettings, _inventorySettings, _noBuffItemNames); _profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults."; ResetProfileConsumers(); } private void LoadSelectedProfile() { _profiles.LoadCurrent( _combatSettings, _buffSettings, _vitalSettings, _inventorySettings, _noBuffItemNames); LoadLootProfile(); LoadRouteProfile(); ApplyPersistedOptionOverrides(); ResetProfileConsumers(); } private void ResetProfileConsumers() { _vitalRecharge.Reset(); _dispel.Reset(); _inventoryMaintenance.Reset(); _crafting.Reset(); _itemManaRecharge.Reset(); _loot.Reset(); _navigation.Reset(); _coverageSpellSnapshot = null; _coverageRefreshRemaining = 0d; RefreshMonsterEditor(); RefreshItemEditors(); RefreshLootEditor(); RefreshRouteEditor(); } private void ClearMossTankActionLocks() { ClearFastCastMovement(); _buffCastRecastRemaining = 0d; _randomHelperRemaining = 0d; _combat.ClearActionLocks(); _vitalRecharge.Reset(); _dispel.Reset(); _inventoryMaintenance.Reset(); _crafting.Reset(); _itemManaRecharge.Reset(); _loot.Reset(); _profileGive.Reset(); _navigation.ClearActionLocks(); } private void FakeImperil() { uint target = _host.Selection.SelectedObjectId ?? 0u; if (target == 0u || !_host.Automation.Objects.TryGet(target, out PluginWorldObject value) || value.ObjectClass != PluginObjectClass.Monster) { WriteVtank("Select a monster first."); return; } _combat.RecordFakeImperil(target); WriteVtank("Fake cast complete."); } private void EnsureCharacterProfile() { string characterName = _host.Automation.Character.Name; bool macroChanged = _profiles.BindCharacter(characterName); bool lootChanged = _lootProfiles.BindCharacter(characterName); bool routeChanged = _routeProfiles.BindCharacter(characterName); bool metaChanged = _metaProfiles.BindCharacter(characterName); if (!macroChanged && !lootChanged && !routeChanged && !metaChanged) return; if (macroChanged) LoadSelectedProfile(); else { if (lootChanged) LoadLootProfile(); if (routeChanged) LoadRouteProfile(); if (metaChanged) LoadMetaProfile(); } if (macroChanged && metaChanged) LoadMetaProfile(); _profileLifecycleNotice = $"Loaded {_profiles.Selected} for " + (_host.Automation.Character.Name.Length == 0 ? "this character." : _host.Automation.Character.Name + "."); } private void SaveProfile() { _profiles.SaveCurrent( _combatSettings, _buffSettings, _vitalSettings, _inventorySettings, _noBuffItemNames); _lootProfiles.SaveCurrent( _inventorySettings.Loot.Rules, _inventorySettings.Loot); SaveRouteProfile(); SaveMetaProfile(); } private void ApplyPersistedOptionOverrides() { KeyValuePair[] overrides = _combatSettings .DynamicSettings .Where(pair => VtankOptionCatalog.IsKnown(pair.Key)) .ToArray(); if (overrides.Length == 0) return; _applyingProfileOptions = true; try { foreach ((string name, MonsterValue value) in overrides) SetMetaOption(name, ToExpressionValue(value)); } finally { _applyingProfileOptions = false; } } // ── the loop ────────────────────────────────────────────────────────── private void Announce(string text) => _host.Automation.Chat.PostSystemMessage($"[MossTank] {text}"); private void StartOrStop() { _host.Log.Info( $"MossTank: Buff clicked (running={_running}, inWorld={_host.Automation.IsAvailable})"); if (_running) { Stop("Stopped."); Announce("Stopped."); return; } StartForceBuff(); } private void StartForceBuff() { if (_running) return; IAutomationSurface automation = _host.Automation; if (!automation.IsAvailable) { _status = "Not in world."; return; } StartBuffPass( automation, force: true, rebuffWhenUnderSeconds: null, announce: true); } private void CancelForceBuffCore() { if (!_running || !_forcePass) return; Stop("Stopped."); Announce("Stopped."); } private void StartBuffPass( IAutomationSurface automation, bool force, double? rebuffWhenUnderSeconds, bool announce) { double? effectiveThreshold = rebuffWhenUnderSeconds; if (!force && _buffCastRecastRemaining > 0d) { effectiveThreshold = (effectiveThreshold ?? _buffSettings.RebuffWhenUnderSeconds) + _buffSettings.BuffCastRecastSeconds; } _queue = BuildPlan( automation, force, effectiveThreshold); _queueIndex = 0; _castThisPass = 0; _sinceProgress = 0; _forcePass = force; _announceBuffPass = announce; if (_queue.Count > 0) { _selectionBeforePass = _host.Selection.SelectedObjectId; _buffCastRecastRemaining = Math.Max( 0d, _buffSettings.BuffCastRecastResetSeconds); } _running = _queue.Count > 0; if (_queue.Count == 0) { if (force) _status = "Nothing to buff."; if (announce) { Announce( "Nothing to buff — no known self-buffs match your skills."); } return; } string kind = force ? "Force buffing" : "Buffing"; _status = $"{kind} 0/{_queue.Count}…"; _host.Log.Info( $"MossTank: {(force ? "force" : "automatic")} pass started, " + $"{_queue.Count} buff(s) queued"); if (announce) Announce($"{kind} — {_queue.Count} spell(s)."); } private void Stop(string status) { ClearFastCastMovement(); _running = false; _forcePass = false; _announceBuffPass = false; _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, double? rebuffWhenUnderSeconds = null) { List plan = BuffPlan.Build( BuffProfile.Build(automation.Spells.KnownSelfBuffs), automation.Character.Skills, automation.Character.Attributes, automation.Character.ActiveEnchantments, _buffSettings, force, rebuffWhenUnderSeconds, automation.Character.Level); plan.RemoveAll(spell => SpellComponentPolicy.UsesBlacklistedComponent( automation.Spells, spell, _buffSettings.BlacklistedSpellComponents)); return plan; } private void ToggleMacro() => SetMacroRunning(!_combat.Enabled); private void SetMacroRunning(bool running) { if (_combat.Enabled == running) return; _combat.Toggle(); if (running || _combat.Enabled) return; // A stopped VTank macro owns no movement or staged maintenance work. // Worn-mana upkeep is intentionally not reset: VTank's // ManaChargesWhenOff option permits that one controller to continue. _vitalRecharge.Reset(); _dispel.Reset(); _inventoryMaintenance.Reset(); _crafting.Reset(); _loot.Reset(); _profileGive.Reset(); _navigation.Reset(); } /// Driven by on the host update thread. public void OnTick(double elapsedSeconds) { bool automationAvailable = _host.Automation.IsAvailable; if (!automationAvailable) { if (_automationWasAvailable) HandleSessionEnded(); _automationWasAvailable = false; RefreshDisplayBindings(elapsedSeconds); return; } if (!_automationWasAvailable) { _automationWasAvailable = true; HandleSessionStarted(); } ObserveFastCastMovement(elapsedSeconds); _buffCastRecastRemaining = Math.Max( 0d, _buffCastRecastRemaining - Math.Max(0d, elapsedSeconds)); EnsureCharacterProfile(); ShowFirstRunGuidance(); ObserveCommandPortalState(); bool macroRunning = _combat.Enabled; if (macroRunning && _combatSettings.StopMacroOnDeath && _host.Automation.IsAvailable && _host.Automation.Character.MaxHealth > 0u && _host.Automation.Character.CurrentHealth == 0u) { SetMacroRunning(false); macroRunning = false; Announce("Macro stopped because the character died."); } _fellowshipManager.Tick( elapsedSeconds, macroRunning && AutoFellowManagementEnabled); if (macroRunning) _meta.OnTick(elapsedSeconds); _combatSettings.MetaState = _meta.CurrentState; RefreshDisplayBindings(elapsedSeconds); bool commandJumpOwnsAction = TickCommandJump(elapsedSeconds); bool giveOwnsAction = _profileGive.Tick( elapsedSeconds, canAct: !_running && !commandJumpOwnsAction); bool criticalCraftOwnsAction = _crafting.TickCritical( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction); bool vitalOwnsAction = _vitalRecharge.Tick( elapsedSeconds, (macroRunning || _running) && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction, noTarget: !_combat.HasTarget); TickAutomaticBuffing( elapsedSeconds, macroRunning, canAct: !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction); bool dispelOwnsAction = _dispel.Tick( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction); bool manaRechargeOwnsAction = _itemManaRecharge.Tick( canAct: (macroRunning || _inventorySettings.ManaChargesWhenOff) && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction && !dispelOwnsAction); bool craftingOwnsAction = _crafting.Tick( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction && !dispelOwnsAction && !manaRechargeOwnsAction); bool idleCraftingOwnsAction = _crafting.TickIdle( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction && !dispelOwnsAction && !manaRechargeOwnsAction && !craftingOwnsAction && !_combat.HasTarget); bool lootOwnsAction = _loot.Tick( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction && !dispelOwnsAction && !manaRechargeOwnsAction && !craftingOwnsAction && !idleCraftingOwnsAction && (_inventorySettings.Loot.PriorityBoost || !_combat.HasTarget)); bool inventoryOwnsAction = _inventoryMaintenance.Tick( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction && !dispelOwnsAction && !manaRechargeOwnsAction && !lootOwnsAction && !craftingOwnsAction && !idleCraftingOwnsAction && !_combat.HasTarget); bool navigationOwnsAction = _navigation.Tick( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction && !dispelOwnsAction && !manaRechargeOwnsAction && !lootOwnsAction && !craftingOwnsAction && !idleCraftingOwnsAction && !inventoryOwnsAction && (_navigationSettings.Priority || !_combat.HasTarget)); bool randomHelperOwnsAction = TickRandomHelper( elapsedSeconds, canAct: macroRunning && !_running && !commandJumpOwnsAction && !giveOwnsAction && !criticalCraftOwnsAction && !vitalOwnsAction && !dispelOwnsAction && !manaRechargeOwnsAction && !lootOwnsAction && !craftingOwnsAction && !idleCraftingOwnsAction && !inventoryOwnsAction && !navigationOwnsAction && !_combat.HasTarget); _combat.SetPaused( _running || commandJumpOwnsAction || giveOwnsAction || criticalCraftOwnsAction || vitalOwnsAction || dispelOwnsAction || manaRechargeOwnsAction || lootOwnsAction || craftingOwnsAction || idleCraftingOwnsAction || inventoryOwnsAction || randomHelperOwnsAction || (navigationOwnsAction && _navigationSettings.Priority)); _combat.OnTick(elapsedSeconds, _navigationSettings.Enabled); if (_activeTab == TankTab.Route) RefreshRouteEditor(); if (!_running) return; IAutomationSurface automation = _host.Automation; if (!automation.IsAvailable) { Stop("Lost the session."); return; } _sinceProgress += elapsedSeconds; if (_sinceProgress > StallTimeoutSeconds) { Stop($"Stalled after {_castThisPass} cast(s)."); _host.Log.Warn("MossTank: pass stalled; stopping"); Announce($"Stopped — no progress after {_castThisPass} cast(s)."); return; } // The server is the only throttle: cast the moment the previous action // is acknowledged. The fixed delay this replaces was standing in for a // busy signal we now have, and it only made the pass slow. if (automation.Magic.IsCasting) return; if (vitalOwnsAction) return; if (inventoryOwnsAction) return; if (craftingOwnsAction) return; if (idleCraftingOwnsAction) return; if (manaRechargeOwnsAction) return; if (lootOwnsAction) return; if (giveOwnsAction) return; if (_queueIndex >= _queue.Count) { bool announce = _announceBuffPass; bool force = _forcePass; Stop($"Done — {_castThisPass} cast(s)."); _host.Log.Info( $"MossTank: {(force ? "force" : "automatic")} pass complete " + $"({_castThisPass} cast)"); if (announce) Announce($"Finished — {_castThisPass} spell(s) cast."); return; } PluginSpellInfo next = _queue[_queueIndex]; // Advance on both outcomes. A spell that will not go now (missing // components, a gate that stays shut) must not block the rest of the // queue behind it; the status line names why it was skipped. TryCast( automation, next, $"{(_forcePass ? "Force buffing" : "Buffing")} " + $"{_castThisPass + 1}/{_queue.Count}"); _queueIndex++; } private void TickAutomaticBuffing( double elapsedSeconds, bool macroRunning, bool canAct) { _automaticBuffScanRemaining -= Math.Max(0d, elapsedSeconds); if (!macroRunning || !_buffSettings.Enabled || _running || !canAct || _automaticBuffScanRemaining > 0d) { return; } _automaticBuffScanRemaining = 1d; double threshold = !_combat.HasTarget && _buffSettings.IdleBuffTopoff ? _buffSettings.IdleBuffTopoffSeconds : _buffSettings.RebuffWhenUnderSeconds; StartBuffPass( _host.Automation, force: false, rebuffWhenUnderSeconds: threshold, announce: false); } private bool TickRandomHelper(double elapsedSeconds, bool canAct) { _randomHelperRemaining = Math.Max( 0d, _randomHelperRemaining - Math.Max(0d, elapsedSeconds)); if (!canAct || !_buffSettings.RandomHelperBuffs || _randomHelperRemaining > 0d || !_host.Automation.IsAvailable) { return false; } if (_host.Automation.Magic.IsCasting) return true; PluginNavigationSnapshot navigation = _host.Automation.Navigation.Snapshot; if (!navigation.IsAvailable) return false; PluginWorldObject[] players = _host.Automation.Objects.CaptureObjects() .Where(value => value.ObjectClass == PluginObjectClass.Player && value.ObjectId != _host.Automation.Character.ObjectId && value.HasPosition && navigation.Position.HorizontalDistanceMeters(value.Position) < 18d) .OrderBy(static value => value.ObjectId) .ToArray(); if (players.Length == 0) return false; string[] stems = [ "Endurance Other", "Regeneration Other", "Rejuvenation Other", "Armor Other", "Blade Protection Other", "Bludgeoning Protection Other", "Cold Protection Other", "Fire Protection Other", "Lightning Protection Other", "Piercing Protection Other", "Acid Protection Other", ]; int attempts = players.Length * stems.Length; for (int offset = 0; offset < attempts; offset++) { int slot = (_randomHelperCursor + offset) % attempts; PluginWorldObject player = players[slot % players.Length]; string stem = stems[(slot / players.Length) % stems.Length]; PluginSpellInfo spell = _host.Automation.Spells.KnownSelfBuffs .Where(value => value.Name.StartsWith( stem, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(static value => value.Quality) .ThenByDescending(static value => value.Tier) .FirstOrDefault(); if (spell.SpellId == 0u || SpellComponentPolicy.UsesBlacklistedComponent( _host.Automation.Spells, spell, _buffSettings.BlacklistedSpellComponents) || _host.Automation.Magic.EvaluateGate( spell.SpellId, player.ObjectId) != PluginCastGate.Ready || !_host.Automation.Magic.Cast( spell.SpellId, player.ObjectId)) { continue; } _randomHelperCursor = (slot + 1) % attempts; _randomHelperRemaining = Math.Max( 0.25d, _buffSettings.RandomHelperIntervalSeconds); _host.Log.Info( $"MossTank: random helper {spell.Name} -> {player.Name}"); return true; } _randomHelperCursor = (_randomHelperCursor + 1) % attempts; return false; } private void ShowFirstRunGuidance() { if (!_firstRunGuidancePending || !_host.Automation.IsAvailable) return; _firstRunGuidancePending = false; const string guidance = "First run: choose profiles, configure the " + "Options tab, then press Run Macro. Minimize with –; MossTank " + "keeps running from the right-side plugin shelf. Put VTank " + ".nav/.utl/.met files in imports and use /vt nav, /vt loot, or " + "/vt meta import ."; Announce(guidance); try { _host.Storage.WriteText("onboarding/v1.txt", "shown"); } catch (Exception error) { _host.Log.Warn( "MossTank could not persist first-run guidance state: " + error.Message); } } private static bool NeedsFirstRunGuidance(IPluginHost host) { if (!host.Storage.IsAvailable) return false; try { return string.IsNullOrWhiteSpace( host.Storage.ReadText("onboarding/v1.txt")); } catch (Exception error) { host.Log.Warn( "MossTank could not read first-run guidance state: " + error.Message); return false; } } public void Disable() { if (_combat.Enabled) SetMacroRunning(false); if (_running) Stop("Stopped."); _vitalRecharge.Reset(); _inventoryMaintenance.Reset(); _crafting.Reset(); _itemManaRecharge.Reset(); _loot.Reset(); _profileGive.Reset(); _navigation.Reset(); _fellowshipManager.Reset(); _meta.SetEnabled(false); _metaViews.DestroyAll(); _expressions.DestroyAuxiliaryViews(); _expressions.ClearSession(); } private void HandleSessionEnded() { // Combat owns the authoritative physical abort and all of its receipt // cursors. Drive its existing session-loss path before resetting the // sibling schedulers. if (_combat.Enabled) _combat.OnTick(0d, navigationEnabled: false); ClearFastCastMovement(); _running = false; _forcePass = false; _announceBuffPass = false; _queue.Clear(); _queueIndex = 0; _selectionBeforePass = null; _status = "Lost the session."; ResetSessionScopedControllers(); } private void HandleSessionStarted() { // Re-baseline portal/death/chat state against the NEW session. This is // also required for a same-character relog, where identity-based // persistence cannot itself distinguish the old and new sessions. _meta.ResetSession(); _expressions.ClearSession(); _expressions.DestroyAuxiliaryViews(); _metaViews.DestroyAll(); ResetCommandSession(); _automaticBuffScanRemaining = 0d; _buffCastRecastRemaining = 0d; _randomHelperRemaining = 0d; _randomHelperCursor = 0; _coverageSpellSnapshot = null; _coverageRefreshRemaining = 0d; _status = "Idle."; } private void ResetSessionScopedControllers() { _vitalRecharge.Reset(); _dispel.Reset(); _inventoryMaintenance.Reset(); _crafting.Reset(); _itemManaRecharge.Reset(); _loot.Reset(); _profileGive.Reset(); _navigation.Reset(); _fellowshipManager.Reset(); _meta.ResetSession(); _metaViews.DestroyAll(); _expressions.DestroyAuxiliaryViews(); _expressions.ClearSession(); ResetCommandSession(); _automaticBuffScanRemaining = 0d; _buffCastRecastRemaining = 0d; _randomHelperRemaining = 0d; _randomHelperCursor = 0; _coverageSpellSnapshot = null; _coverageRefreshRemaining = 0d; _combatSettings.MetaState = MetaEngine.DefaultState; } private void RefreshDisplayBindings(double elapsedSeconds) { IAutomationSurface automation = _host.Automation; if (!automation.IsAvailable) { _vitals = string.Empty; _coverage = string.Empty; _vitalsInitialized = false; _coverageSpellSnapshot = null; _coverageBuffLineCount = 0; _coverageRefreshRemaining = 0.0; return; } ICharacterInfo character = automation.Character; uint currentHealth = character.CurrentHealth; uint maxHealth = character.MaxHealth; uint currentStamina = character.CurrentStamina; uint maxStamina = character.MaxStamina; uint currentMana = character.CurrentMana; uint maxMana = character.MaxMana; if (!_vitalsInitialized || currentHealth != _currentHealth || maxHealth != _maxHealth || currentStamina != _currentStamina || maxStamina != _maxStamina || currentMana != _currentMana || maxMana != _maxMana) { _currentHealth = currentHealth; _maxHealth = maxHealth; _currentStamina = currentStamina; _maxStamina = maxStamina; _currentMana = currentMana; _maxMana = maxMana; _vitals = $"Health {currentHealth}/{maxHealth}" + $" Stam {currentStamina}/{maxStamina}" + $" Mana {currentMana}/{maxMana}"; _vitalsInitialized = true; } IReadOnlyList spells = automation.Spells.KnownSelfBuffs; bool spellbookChanged = !ReferenceEquals(spells, _coverageSpellSnapshot); _coverageRefreshRemaining -= Math.Max(0.0, elapsedSeconds); if (!spellbookChanged && _coverageRefreshRemaining > 0.0) return; if (spellbookChanged) { _coverageSpellSnapshot = spells; _coverageBuffLineCount = BuffProfile.Build(spells).Count; } int trained = 0; foreach (PluginSkillInfo skill in character.Skills) { if (skill.Training is PluginSkillTraining.Trained or PluginSkillTraining.Specialized) { trained++; } } _coverage = $"{character.Attributes.Count} attributes, " + $"{trained} trained skills, " + $"{_coverageBuffLineCount} buff lines"; _coverageRefreshRemaining = CoverageRefreshIntervalSeconds; } 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) { _status = $"{spell.Name}: {gate}"; return false; } if (!automation.Magic.Cast(spell.SpellId)) { _status = $"Refused {spell.Name}."; return false; } BeginFastCastMovement(automation, spell); _castThisPass++; _sinceProgress = 0; _status = $"{label}: {spell.Name}"; _host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})"); return true; } private void BeginFastCastMovement( IAutomationSurface automation, in PluginSpellInfo spell) { if (!_buffSettings.FastCastBuffs || !IsVtankInstantCast(spell)) return; // VTank excludes War (school 1) and Void (school 5). The plugin API // projects schools as their retail skill ids: 34 and 43 respectively. if (spell.School is 34u or 43u) return; PluginNavigationCommandStatus result = automation.Navigation .SetMovementIntent(new PluginMovementIntent(Forward: true)); if (result != PluginNavigationCommandStatus.Accepted) return; _fastCastMovementActive = true; _fastCastStartCompletionRevision = automation.Magic.LastCompletion.Revision; _fastCastMovementElapsed = 0d; } private void ObserveFastCastMovement(double elapsedSeconds) { if (!_fastCastMovementActive) return; _fastCastMovementElapsed += Math.Max(0d, elapsedSeconds); IMagicCommands magic = _host.Automation.Magic; bool receiptArrived = magic.LastCompletion.Revision != _fastCastStartCompletionRevision; bool castEnded = _fastCastMovementElapsed >= 0.2d && !magic.IsCasting; if (receiptArrived || castEnded || _fastCastMovementElapsed >= 10d) ClearFastCastMovement(); } private void ClearFastCastMovement() { if (!_fastCastMovementActive) return; _host.Automation.Navigation.ClearMovementIntent(); _fastCastMovementActive = false; _fastCastStartCompletionRevision = 0; _fastCastMovementElapsed = 0d; } private static bool IsVtankInstantCast(in PluginSpellInfo spell) { if (spell.Difficulty < 50) return true; if (spell.IsUntargeted && !spell.IsFellowship && spell.DurationSeconds >= 60f && spell.School is 31u or 33u) { return true; } return spell.Family is >= 243u and <= 249u or 639u; } }