acdream/src/AcDream.Plugins.MossTank/MossTankPanel.cs

4361 lines
177 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Globalization;
using System.Text;
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank.Expressions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// The panel's binding object and the buff loop's state machine.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed partial class MossTankPanel
{
private enum TankTab
{
Options,
Profiles,
Vitals,
Monsters,
Items,
Consumables,
Buffs,
Route,
Meta,
}
/// <summary>
/// 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.
/// </summary>
private const double StallTimeoutSeconds = 30.0;
/// <summary>
/// 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.
/// </summary>
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<PluginSpellInfo> _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<PluginSpellInfo>? _coverageSpellSnapshot;
private int _coverageBuffLineCount;
private double _coverageRefreshRemaining;
private TankTab _activeTab = TankTab.Options;
private readonly HashSet<string> _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<string> _monsterRows = Array.Empty<string>();
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<string> _itemRows = Array.Empty<string>();
private IReadOnlyList<string> _consumableRows = Array.Empty<string>();
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<string> _lootRuleRows = Array.Empty<string>();
private int _selectedLootRule;
private string _lootExpressionDraft = "*";
private string _lootEditorNotice = "Add a rule or select one to edit.";
private string _lootProfileNameDraft = string.Empty;
private IReadOnlyList<string> _routeRows = Array.Empty<string>();
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<string> _metaRows = Array.Empty<string>();
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;
/// <summary>
/// What the player had selected before the pass, so targeting yourself for
/// banes does not quietly steal the selection and leave it changed.
/// </summary>
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);
/// <summary>Keeps the gameplay window off login/character-select screens.</summary>
public bool WindowAvailable => _host.Automation.IsAvailable;
internal ExpressionValue EvaluateExpression(string source) =>
_expressions.Evaluate(source);
internal IReadOnlyCollection<string> 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<string> 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<int> SelectAdvancedOption => index =>
{
_selectedAdvancedOption = Math.Clamp(
index,
0,
VtankOptionCatalog.Names.Length - 1);
LoadAdvancedOptionDraft();
};
public Action<string> SetAdvancedOptionValueDraft => value =>
_advancedOptionValueDraft = value;
public Action<string> SubmitAdvancedOption => value =>
{
_advancedOptionValueDraft = value;
ApplyAdvancedOptionCore();
};
public Action ApplyAdvancedOption => ApplyAdvancedOptionCore;
/// <summary>The button is Force Buff; while a pass runs it cancels.</summary>
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<string> ItemRows => _itemRows;
public IReadOnlyList<string> ConsumableRows => _consumableRows;
public int SelectedItemRowIndex => _selectedItemRow;
public int SelectedConsumableRowIndex => _selectedConsumableRow;
public Action<int> SelectItemRow => index =>
_selectedItemRow = ClampRow(index, _itemRows.Count);
public Action<int> 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<float> 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<string> LootProfileNames =>
_lootProfiles.AvailableNames;
public string LootProfileName => _lootProfiles.Selected;
public string LootProfileNameDraft => _lootProfileNameDraft;
public IReadOnlyList<string> LootClassifierNames
{
get
{
var names = new List<string> { "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<string> LootRuleRows => _lootRuleRows;
public int SelectedLootRuleIndex => _selectedLootRule;
public string LootExpressionDraft => _lootExpressionDraft;
public string LootEditorNotice => _lootEditorNotice;
public IReadOnlyList<string> LootActionNames => Enum.GetNames<LootAction>();
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<string> SelectLootProfile => SelectLootProfileCore;
public Action<string> 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<string> SetLootProfileNameDraft => value =>
_lootProfileNameDraft = value;
public Action<string> 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<int> SelectLootRule => SelectLootRuleCore;
public Action<string> SetLootExpressionDraft => value =>
_lootExpressionDraft = value;
public Action<string> 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<string> 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<string> RouteRows => _routeRows;
public int SelectedRouteWaypointIndex => _selectedRouteWaypoint;
public IReadOnlyList<string> RouteModeNames => Enum.GetNames<RouteMode>();
public string SelectedRouteMode => _navigationSettings.Mode.ToString();
public IReadOnlyList<string> RouteRecallNames =>
Enum.GetNames<RouteRecallKind>();
public string SelectedRouteRecall => _routeRecallKind.ToString();
public IReadOnlyList<string> 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<string> 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<string> SelectRouteRecall => value =>
{
if (Enum.TryParse(value, ignoreCase: true, out RouteRecallKind recall))
_routeRecallKind = recall;
};
public Action<int> 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<string> SetRouteChatDraft => value =>
_routeChatDraft = value;
public Action<string> SetRouteProfileNameDraft => value =>
_routeProfileNameDraft = value;
public Action<string> SelectRouteProfile => SelectRouteProfileCore;
public Action<string> 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<string> MetaRows => _metaRows;
public int SelectedMetaRuleIndex => _selectedMetaRule;
public IReadOnlyList<string> MetaConditionNames =>
Enum.GetNames<MetaConditionKind>();
public IReadOnlyList<string> MetaActionNames => Enum.GetNames<MetaActionKind>();
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<string> 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<int> SelectMetaRule => SelectMetaRuleCore;
public Action<string> SelectMetaCondition => value =>
{
if (Enum.TryParse(value, ignoreCase: true, out MetaConditionKind parsed))
_metaConditionKind = parsed;
};
public Action<string> SelectMetaAction => value =>
{
if (Enum.TryParse(value, ignoreCase: true, out MetaActionKind parsed))
_metaActionKind = parsed;
};
public Action<string> SetMetaStateDraft => value => _metaStateDraft = value;
public Action<string> SetMetaConditionTextDraft => value =>
_metaConditionTextDraft = value;
public Action<string> SetMetaActionTextDraft => value =>
_metaActionTextDraft = value;
public Action<string> 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<string> SetMetaProfileNameDraft => value =>
_metaProfileNameDraft = value;
public Action<string> SelectMetaProfile => SelectMetaProfileCore;
public Action<string> 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<string> 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<string> SetProfileNameDraft => value =>
_profileNameDraft = value;
public Action<string> SelectMacroProfile => SelectProfile;
public Action<string> 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<string> MonsterRows => _monsterRows;
public int SelectedMonsterRuleIndex => _selectedMonsterRule;
public string MonsterExpressionDraft => _monsterExpressionDraft;
public string MonsterEditorNotice => _monsterEditorNotice;
public IReadOnlyList<string> DamageTypeNames => MonsterDamageNames;
public IReadOnlyList<string> ExtraVulnerabilityNames =>
MonsterExtraVulnerabilityNames;
public IReadOnlyList<string> 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<int> SelectMonsterRule => SelectMonsterRuleCore;
public Action<string> SetMonsterExpressionDraft => value =>
_monsterExpressionDraft = value;
public Action<string> 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<string> SelectMonsterDamage => value =>
SetMonsterDamage(value, extra: false);
public Action<string> SelectMonsterExtraVulnerability => value =>
SetMonsterDamage(value, extra: true);
public Action<string> 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);
/// <summary>Vitals line, using the same numbers the character panel shows.</summary>
public string Vitals => _vitals;
/// <summary>What a buff pass would cover, named from the retail tables.</summary>
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.
/// <summary>
/// VTank's <c>SpellDiffExcessThreshold-Buff</c>. 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.
/// </summary>
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<float> SetNormalHealth => value =>
UpdateVital(() => _vitalSettings.NormalHealth = Clamp(value));
public Action<float> SetNormalStamina => value =>
UpdateVital(() => _vitalSettings.NormalStamina = Clamp(value));
public Action<float> SetNormalMana => value =>
UpdateVital(() => _vitalSettings.NormalMana = Clamp(value));
public Action<float> SetNoTargetHealth => value =>
UpdateVital(() => _vitalSettings.NoTargetHealth = Clamp(value));
public Action<float> SetNoTargetStamina => value =>
UpdateVital(() => _vitalSettings.NoTargetStamina = Clamp(value));
public Action<float> SetNoTargetMana => value =>
UpdateVital(() => _vitalSettings.NoTargetMana = Clamp(value));
public Action<float> SetHelperHealth => value =>
UpdateVital(() => _vitalSettings.HelperHealth = Clamp(value));
public Action<float> SetHelperStamina => value =>
UpdateVital(() => _vitalSettings.HelperStamina = Clamp(value));
public Action<float> SetHelperMana => value =>
UpdateVital(() => _vitalSettings.HelperMana = Clamp(value));
public Action<float> 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<string> 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<string> 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<string> SetMonsterRangeText => value =>
SetDistanceText(value, 1f, 100f, distance =>
_combatSettings.MaximumRange = distance);
public Action<string> SetRingRangeText => value =>
SetDistanceText(value, 1f, 100f, distance =>
_combatSettings.RingDistance = distance);
public Action<string> SetApproachRangeText => value =>
SetDistanceText(value, 0f, 100f, distance =>
_combatSettings.ApproachDistance = distance);
public Action<string> 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<float> 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<string> 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<LootRule> 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 "<AUTO>";
foreach (PluginInventoryItem item in
_host.Automation.Items.CaptureOwnedItems())
{
if (item.ObjectId == objectId)
return item.Name;
}
return $"0x{objectId:X8}";
}
private void UpdateSelectedMonsterActions(
Func<MonsterRuleActions, MonsterRuleActions> 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<string, MonsterValue>[] 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<PluginSpellInfo>();
_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<PluginSpellInfo> BuildPlan(
IAutomationSurface automation,
bool force,
double? rebuffWhenUnderSeconds = null)
{
List<PluginSpellInfo> 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();
}
/// <summary>Driven by <see cref="IEvents.Tick"/> on the host update thread.</summary>
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 <name>.";
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<PluginSpellInfo> 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;
}
}