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