diff --git a/src/AcDream.Plugins.MossTank/BuffCasterPreparer.cs b/src/AcDream.Plugins.MossTank/BuffCasterPreparer.cs new file mode 100644 index 00000000..d234c60b --- /dev/null +++ b/src/AcDream.Plugins.MossTank/BuffCasterPreparer.cs @@ -0,0 +1,274 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Gets the character ready to cast before a buff pass's first +/// TryCast: resolve which caster to buff with, wield it if it is not +/// already equipped, and enter Magic mode. Retail drops any cast that +/// arrives outside Magic mode (ACE Player_Magic.cs:84-95), and Magic +/// mode itself requires a wielded caster (ACE +/// Player_Combat.cs:778+, GetEquippedWand) — the buff pass +/// used to assume both were already true. This is the single owner of +/// "which caster do we buff with and how do we get into Magic mode"; see +/// docs/plans/2026-09-06-mosstank-mode-arbitration.md Design A. +/// +internal sealed class BuffCasterPreparer +{ + /// + /// Retail's caster ITEM_TYPE bit (VTank's CasterItemType, + /// 's recovery-caster predicate, and + /// all use the same + /// constant). + /// + private const uint CasterItemType = 0x00008000u; + + /// + /// Mirrors 's retry + /// budget in spirit (same + /// constant), but on a fixed wall-clock cadence rather than once per + /// combat tick — a buff pass is not scanning for targets every 0.25 s, + /// so re-issuing a stuck mode request needs its own pacing. + /// + private const double ModeRetrySeconds = 2.0; + + private readonly IPluginHost _host; + private readonly CombatSettings _settings; + private readonly VitalSettings _vitalSettings; + + private PluginCombatMode? _pendingRequestedMode; + private double _modeWaitElapsed; + private int _modeRetryCount; + + /// + /// Posting VTank's "no wand" notice is latched so an automatic scan that + /// keeps finding no caster does not spam chat every scan interval. The + /// latch clears only on (Stop, session end, or macro + /// stop) — the simplest honest rule: it re-announces once per fresh + /// attempt at running the macro, not on every failed scan in between. + /// + private bool _noCasterNoticePosted; + + public BuffCasterPreparer( + IPluginHost host, + CombatSettings settings, + VitalSettings? vitalSettings = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _vitalSettings = vitalSettings ?? new VitalSettings(); + } + + /// The buff queue may run once this is true. + public bool Ready { get; private set; } + + /// + /// The pass must stop (no caster, an equip/mode refusal, or an + /// exhausted retry budget). names why. + /// + public bool Stopped { get; private set; } + + public string Status { get; private set; } = string.Empty; + + /// + /// Ticked every frame while the buff pass runs, before its first + /// TryCast. A no-op once or + /// . + /// + public void Tick(double elapsedSeconds) + { + if (Ready || Stopped) + return; + + IAutomationSurface automation = _host.Automation; + IEquipmentAutomation equipment = automation.Equipment; + + // Older/no-window hosts report unavailable rather than modelling + // equipment at all (the same convention as + // CombatController.TickEquipment); do not turn a missing optional + // projection into a permanent deadlock for every buff pass. + if (equipment.IsAvailable) + { + if (equipment.IsBusy) + { + Status = "Equipping caster"; + return; + } + + if (!TryResolveCaster(equipment.CaptureOwnedEquipment(), out PluginEquipmentItem caster)) + { + StopWithNoCasterNotice(); + return; + } + + if (!caster.IsEquipped) + { + PluginCombatMode wieldMode = automation.Combat.Snapshot.Mode; + if (wieldMode != PluginCombatMode.Peace) + { + RequestMode( + PluginCombatMode.Peace, + elapsedSeconds, + "could not enter peace mode to equip a caster"); + return; + } + + ResetModeWait(); + PluginEquipmentCommandResult equip = equipment.Equip(caster.ObjectId); + if (equip.Status == PluginEquipmentCommandStatus.Refused) + { + Stop(equip.Notice ?? $"Cannot equip {caster.Name}."); + return; + } + Status = $"Equipping {caster.Name}"; + return; + } + } + + PluginCombatMode currentMode = automation.Combat.Snapshot.Mode; + if (currentMode != PluginCombatMode.Magic) + { + RequestMode(PluginCombatMode.Magic, elapsedSeconds, "could not enter magic mode"); + return; + } + + Ready = true; + Status = "Ready to buff"; + } + + /// Reset on Stop, on session end, and when the macro stops. + public void Reset() + { + Ready = false; + Stopped = false; + Status = string.Empty; + _pendingRequestedMode = null; + _modeWaitElapsed = 0d; + _modeRetryCount = 0; + _noCasterNoticePosted = false; + } + + /// + /// Wielded caster first (retail's actual equipped wand); else the first + /// profiled caster, ordered by name then object id for a deterministic + /// choice. _noBuffItemNames (VTank's "do not cast item + /// enchantments on this weapon") is not a caster exclusion — that flag + /// is about weapon procs, not who may buff. + /// + private bool TryResolveCaster( + IReadOnlyList items, + out PluginEquipmentItem caster) + { + foreach (PluginEquipmentItem item in items) + { + if ((item.ItemType & CasterItemType) != 0u && item.IsEquipped) + { + caster = item; + return true; + } + } + + PluginEquipmentItem? best = null; + foreach (PluginEquipmentItem item in items) + { + if ((item.ItemType & CasterItemType) == 0u) + continue; + if (!_settings.CombatItemObjectIds.Contains(item.ObjectId) + && !_settings.CombatItemNames.Contains(item.Name)) + { + continue; + } + if (best is null + || string.CompareOrdinal(item.Name, best.Value.Name) < 0 + || (string.Equals(item.Name, best.Value.Name, StringComparison.Ordinal) + && item.ObjectId < best.Value.ObjectId)) + { + best = item; + } + } + + if (best is { } selected) + { + caster = selected; + return true; + } + caster = default; + return false; + } + + private void StopWithNoCasterNotice() + { + const string notice = "You must add at least one wand to your Items profile."; + Stopped = true; + Status = notice; + if (_noCasterNoticePosted) + return; + _host.Automation.Chat.PostSystemMessage("[MossTank] " + notice); + _noCasterNoticePosted = true; + } + + private void Stop(string status) + { + Stopped = true; + Status = status; + } + + private void ResetModeWait() + { + _pendingRequestedMode = null; + _modeWaitElapsed = 0d; + _modeRetryCount = 0; + } + + /// + /// Issue (or re-issue) one mode request, gated to at most once per + /// and at most + /// times; the + /// exhausted budget stops the pass naming the stage. + /// + private void RequestMode(PluginCombatMode mode, double elapsedSeconds, string exhaustedStatus) + { + if (_pendingRequestedMode != mode) + { + _pendingRequestedMode = mode; + _modeWaitElapsed = 0d; + _modeRetryCount = 1; + IssueModeRequest(mode); + return; + } + + _modeWaitElapsed += Math.Max(0d, elapsedSeconds); + if (_modeWaitElapsed < ModeRetrySeconds) + { + Status = $"Entering {mode} mode"; + return; + } + + _modeWaitElapsed = 0d; + _modeRetryCount++; + if (_modeRetryCount > _vitalSettings.DropToPeaceModeRetryCount) + { + Stop(exhaustedStatus); + return; + } + IssueModeRequest(mode); + } + + private void IssueModeRequest(PluginCombatMode mode) + { + PluginCombatCommandResult result = _host.Automation.Combat.EnterMode(mode); + if (result.Status == PluginCombatCommandStatus.Unavailable) + { + // The host does not model combat-mode automation at all (an + // older/no-window host projects only NoOpAutomationSurface + // here). Do not turn a missing optional projection into a + // permanent deadlock for every buff pass; proceed as ready. + Ready = true; + Status = "Ready to buff"; + return; + } + Status = result.Status == PluginCombatCommandStatus.Refused + ? result.Notice ?? $"Cannot enter {mode} mode" + : $"Entering {mode} mode"; + } +} diff --git a/src/AcDream.Plugins.MossTank/CombatController.cs b/src/AcDream.Plugins.MossTank/CombatController.cs index fe40d9ce..cf52b5ea 100644 --- a/src/AcDream.Plugins.MossTank/CombatController.cs +++ b/src/AcDream.Plugins.MossTank/CombatController.cs @@ -269,19 +269,12 @@ internal sealed class CombatController if (_targetId == 0u) { + // Peace-Mode-When-Idle is owned solely by MacroIdleModeArbiter, + // which ticks after every controller (including this one) and so + // also covers the case this controller cannot see: combat policy + // disabled but the macro still running. See + // docs/plans/2026-09-06-mosstank-mode-arbitration.md Design B. StopApproachMovement(); - PluginCombatSnapshot idle = _host.Automation.Combat.Snapshot; - if (_settings.IdlePeaceMode - && idle.Mode is not (PluginCombatMode.Unknown - or PluginCombatMode.Peace)) - { - PluginCombatCommandResult result = - _host.Automation.Combat.EnterMode(PluginCombatMode.Peace); - Status = result.Status == PluginCombatCommandStatus.Refused - ? result.Notice ?? "Cannot enter peace mode" - : "Entering peace mode"; - return; - } Status = "Waiting for a target"; return; } diff --git a/src/AcDream.Plugins.MossTank/MacroIdleModeArbiter.cs b/src/AcDream.Plugins.MossTank/MacroIdleModeArbiter.cs new file mode 100644 index 00000000..8d94ee41 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MacroIdleModeArbiter.cs @@ -0,0 +1,72 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// The single owner of VTank's "Peace Mode When Idle": with the macro +/// running and no controller doing anything this tick, drop to Peace mode. +/// Ticked from after every other +/// controller has ticked, so it also fires when combat is disabled — the +/// case 's own idle-peace branch missed, +/// because that branch only ran once combat had reached its no-target +/// state, which a disabled combat policy never reaches. See +/// docs/plans/2026-09-06-mosstank-mode-arbitration.md Design B. +/// +internal sealed class MacroIdleModeArbiter +{ + /// + /// Re-issue at most this often while the idle condition holds. VTank's + /// idle-peace request does not need combat's 0.25 s scan cadence; a + /// slower, gentler retry avoids hammering a refused request. + /// + private const double IdleRetrySeconds = 1.0; + + private readonly IPluginHost _host; + private readonly CombatSettings _settings; + + /// Starts armed so the first idle tick can request immediately. + private double _sinceLastRequest = IdleRetrySeconds; + + public MacroIdleModeArbiter(IPluginHost host, CombatSettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + /// Last request's outcome, or null while the arbiter is inactive. + public string? Status { get; private set; } + + /// VTank's Run Macro toggle (not the combat policy Enabled flag). + /// + /// True when nothing else owns this tick: no buff pass, not casting, no + /// controller's own owns-action flag, no hostile target, and equipment + /// is not mid-swap. + /// + public void Tick(double elapsedSeconds, bool macroRunning, bool idle) + { + _sinceLastRequest += Math.Max(0d, elapsedSeconds); + + if (!macroRunning || !_settings.IdlePeaceMode || !idle) + { + Status = null; + return; + } + + PluginCombatMode mode = _host.Automation.Combat.Snapshot.Mode; + if (mode is PluginCombatMode.Peace or PluginCombatMode.Unknown) + { + Status = null; + return; + } + + if (_sinceLastRequest < IdleRetrySeconds) + return; + + _sinceLastRequest = 0d; + PluginCombatCommandResult result = + _host.Automation.Combat.EnterMode(PluginCombatMode.Peace); + Status = result.Status == PluginCombatCommandStatus.Refused + ? result.Notice ?? "Cannot enter peace mode" + : "Entering peace mode"; + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index ebc1ca9b..586305fd 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -54,6 +54,8 @@ internal sealed partial class MossTankPanel private readonly MossTankMetaProfileStore _metaProfiles; private readonly MetaViewManager _metaViews; private readonly CombatController _combat; + private readonly BuffCasterPreparer _buffCasterPreparer; + private readonly MacroIdleModeArbiter _idleModeArbiter; private readonly VitalRechargeController _vitalRecharge; private readonly DispelController _dispel; private readonly InventoryMaintenanceController _inventoryMaintenance; @@ -203,6 +205,11 @@ internal sealed partial class MossTankPanel if (!_routeProfiles.LoadCurrent(_navigationSettings)) _routeProfiles.SaveCurrent(_navigationSettings); _combat = new CombatController(host, _combatSettings, _vitalSettings); + _buffCasterPreparer = new BuffCasterPreparer( + host, + _combatSettings, + _vitalSettings); + _idleModeArbiter = new MacroIdleModeArbiter(host, _combatSettings); _vitalRecharge = new VitalRechargeController( host, _vitalSettings, @@ -3665,6 +3672,7 @@ internal sealed partial class MossTankPanel _queueIndex = 0; _status = status; RestoreSelection(); + _buffCasterPreparer.Reset(); } private void RestoreSelection() @@ -3710,6 +3718,7 @@ internal sealed partial class MossTankPanel // 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. + _buffCasterPreparer.Reset(); _vitalRecharge.Reset(); _dispel.Reset(); _inventoryMaintenance.Reset(); @@ -3896,13 +3905,38 @@ internal sealed partial class MossTankPanel || randomHelperOwnsAction || (navigationOwnsAction && _navigationSettings.Priority)); _combat.OnTick(elapsedSeconds, _navigationSettings.Enabled); + + IAutomationSurface automation = _host.Automation; + // MacroIdleModeArbiter is the single owner of "peace when idle"; it + // ticks here — after every controller above, including combat — + // so it also fires with combat policy disabled, which + // CombatController's own no-target branch could never reach. + _idleModeArbiter.Tick( + elapsedSeconds, + macroRunning, + idle: !_running + && !automation.Magic.IsCasting + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !lootOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && !inventoryOwnsAction + && !navigationOwnsAction + && !randomHelperOwnsAction + && !_combat.HasTarget + && !automation.Equipment.IsBusy); + if (_activeTab == TankTab.Route) RefreshRouteEditor(); if (!_running) return; - IAutomationSurface automation = _host.Automation; if (!automation.IsAvailable) { Stop("Lost the session."); @@ -3945,6 +3979,23 @@ internal sealed partial class MossTankPanel if (giveOwnsAction) return; + // BuffCasterPreparer is the single owner of "which caster do we + // buff with and how do we get into Magic mode"; TryCast never runs + // until it reports Ready. + _buffCasterPreparer.Tick(elapsedSeconds); + if (_buffCasterPreparer.Stopped) + { + string reason = _buffCasterPreparer.Status; + Stop(reason); + _host.Log.Warn($"MossTank: buff pass stopped by preparer — {reason}"); + return; + } + if (!_buffCasterPreparer.Ready) + { + _status = _buffCasterPreparer.Status; + return; + } + if (_queueIndex >= _queue.Count) { bool announce = _announceBuffPass; @@ -4147,6 +4198,7 @@ internal sealed partial class MossTankPanel _queueIndex = 0; _selectionBeforePass = null; _status = "Lost the session."; + _buffCasterPreparer.Reset(); ResetSessionScopedControllers(); } diff --git a/tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs index ec52b79a..cbcb03d3 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs @@ -40,20 +40,12 @@ public sealed class CombatControllerTests Assert.Equal(10u, surface.LastBeginTarget); } - [Fact] - public void IdlePeaceIsTheNoTargetFallback() - { - var surface = new FakeAutomation { CombatSnapshot = Physical() }; - var controller = new CombatController( - new FakeHost(surface), - new CombatSettings { IdlePeaceMode = true }); - - controller.Toggle(); - controller.OnTick(0.25d); - - Assert.Equal(PluginCombatMode.Peace, surface.CombatSnapshot.Mode); - Assert.Contains("peace", controller.Status, StringComparison.OrdinalIgnoreCase); - } + // IdlePeaceIsTheNoTargetFallback moved: Peace-Mode-When-Idle is now the + // sole responsibility of MacroIdleModeArbiter (see + // docs/plans/2026-09-06-mosstank-mode-arbitration.md Design B), because + // CombatController's own no-target branch never runs when combat policy + // is disabled but the macro still is. Re-pinned in + // MacroIdleModeArbiterTests below. [Fact] public void HigherPriorityRuleWinsEvenWhenTargetIsFarther() @@ -851,6 +843,229 @@ public sealed class CombatControllerTests Assert.Contains("Debuffs complete", controller.Status, StringComparison.Ordinal); } + // ── BuffCasterPreparer ─────────────────────────────────────────────── + // Tests 1-4 of docs/plans/2026-09-06-mosstank-mode-arbitration.md. + + [Fact] + public void PreparerWieldedCasterInPeaceRequestsMagicThenReady() + { + var surface = new FakeAutomation + { + CombatSnapshot = Physical() with { Mode = PluginCombatMode.Peace }, + DeferModeConfirmation = true, + EquipmentItems = + [ + Equipment( + 800, + "Recovery Wand", + damageType: 0, + itemType: 0x00008000u, + equippedLocation: 0x00100000u), + ], + }; + var preparer = new BuffCasterPreparer( + new FakeHost(surface), new CombatSettings(), new VitalSettings()); + + preparer.Tick(0.1); + + Assert.False(preparer.Ready); + Assert.False(preparer.Stopped); + Assert.Equal(1, surface.ModeChangeRequests); + Assert.Equal(["EnterMode:Magic"], surface.CallLog); + + surface.ConfirmPendingModeChange(); + preparer.Tick(0.1); + + Assert.True(preparer.Ready); + Assert.Equal(1, surface.ModeChangeRequests); + } + + [Fact] + public void PreparerProfiledCasterNotWieldedWieldsThenEntersMagicInOrder() + { + var surface = new FakeAutomation + { + CombatSnapshot = Physical(), // Mode = Melee + DeferModeConfirmation = true, + SimulateAsyncEquip = true, + EquipmentItems = + [ + Equipment(800, "Recovery Wand", damageType: 0, itemType: 0x00008000u), + ], + }; + var settings = new CombatSettings(); + settings.CombatItemNames.Add("Recovery Wand"); + var preparer = new BuffCasterPreparer( + new FakeHost(surface), settings, new VitalSettings()); + + preparer.Tick(0.1); + Assert.Equal(["EnterMode:Peace"], surface.CallLog); + Assert.False(preparer.Ready); + + surface.ConfirmPendingModeChange(); + preparer.Tick(0.1); + // Equip only happens after the snapshot reports Peace, never before. + Assert.Equal(["EnterMode:Peace", "Equip:00000320"], surface.CallLog); + Assert.False(preparer.Ready); + + surface.ConfirmPendingEquip(); + preparer.Tick(0.1); + Assert.Equal( + ["EnterMode:Peace", "Equip:00000320", "EnterMode:Magic"], + surface.CallLog); + Assert.False(preparer.Ready); + + surface.ConfirmPendingModeChange(); + preparer.Tick(0.1); + Assert.True(preparer.Ready); + Assert.Equal( + ["EnterMode:Peace", "Equip:00000320", "EnterMode:Magic"], + surface.CallLog); + } + + [Fact] + public void PreparerNoCasterAnywhereStopsAndPostsNoticeOnceUntilReset() + { + var surface = new FakeAutomation { CombatSnapshot = Physical() }; + var preparer = new BuffCasterPreparer( + new FakeHost(surface), new CombatSettings(), new VitalSettings()); + + preparer.Tick(0.1); + + Assert.True(preparer.Stopped); + Assert.False(preparer.Ready); + Assert.Contains( + "wand", + Assert.Single(surface.PostedSystemMessages), + StringComparison.OrdinalIgnoreCase); + + preparer.Tick(0.1); + Assert.Single(surface.PostedSystemMessages); + + // The latch clears on Reset (Stop/session end/macro stop), matching + // "re-announce once per fresh attempt at running the macro." + preparer.Reset(); + preparer.Tick(0.1); + Assert.Equal(2, surface.PostedSystemMessages.Count); + } + + [Fact] + public void PreparerModeRequestNeverConfirmedStopsAfterRetryBudgetNamingStage() + { + var surface = new FakeAutomation + { + CombatSnapshot = Physical() with { Mode = PluginCombatMode.Peace }, + DeferModeConfirmation = true, // never confirmed: stuck forever + EquipmentItems = + [ + Equipment( + 800, + "Recovery Wand", + damageType: 0, + itemType: 0x00008000u, + equippedLocation: 0x00100000u), + ], + }; + var vitalSettings = new VitalSettings { DropToPeaceModeRetryCount = 2 }; + var preparer = new BuffCasterPreparer( + new FakeHost(surface), new CombatSettings(), vitalSettings); + + preparer.Tick(0.1); + Assert.Equal(1, surface.ModeChangeRequests); + Assert.False(preparer.Stopped); + + preparer.Tick(2.0); + Assert.Equal(2, surface.ModeChangeRequests); + Assert.False(preparer.Stopped); + + preparer.Tick(2.0); + Assert.True(preparer.Stopped); + Assert.False(preparer.Ready); + Assert.Equal(2, surface.ModeChangeRequests); + Assert.Contains( + "could not enter magic mode", + preparer.Status, + StringComparison.OrdinalIgnoreCase); + } + + // ── MacroIdleModeArbiter ───────────────────────────────────────────── + // Test 5 of docs/plans/2026-09-06-mosstank-mode-arbitration.md. + + [Fact] + public void ArbiterIdleWithPeaceModeOnRequestsOncePerRetryWindow() + { + var surface = new FakeAutomation + { + CombatSnapshot = Physical(), // Mode = Melee + IgnoreModeChanges = true, // isolate the 1 s retry gate itself + }; + var arbiter = new MacroIdleModeArbiter( + new FakeHost(surface), new CombatSettings { IdlePeaceMode = true }); + + arbiter.Tick(0.1, macroRunning: true, idle: true); + Assert.Equal(1, surface.ModeChangeRequests); + Assert.Contains("peace", arbiter.Status!, StringComparison.OrdinalIgnoreCase); + + arbiter.Tick(0.5, macroRunning: true, idle: true); + Assert.Equal(1, surface.ModeChangeRequests); + + arbiter.Tick(0.6, macroRunning: true, idle: true); + Assert.Equal(2, surface.ModeChangeRequests); + } + + [Fact] + public void ArbiterAnyOwnedActionTargetOrCastingSuppressesIt() + { + // MossTankPanel folds a running buff pass, a hostile target, a busy + // equipment swap, IsCasting, and every controller's owns-action flag + // into this one idle input. + var surface = new FakeAutomation { CombatSnapshot = Physical() }; + var arbiter = new MacroIdleModeArbiter( + new FakeHost(surface), new CombatSettings { IdlePeaceMode = true }); + + arbiter.Tick(5.0, macroRunning: true, idle: false); + + Assert.Equal(0, surface.ModeChangeRequests); + Assert.Null(arbiter.Status); + Assert.Equal(PluginCombatMode.Melee, surface.CombatSnapshot.Mode); + } + + [Fact] + public void ArbiterIdlePeaceModeOffNeverRequests() + { + var surface = new FakeAutomation { CombatSnapshot = Physical() }; + var arbiter = new MacroIdleModeArbiter( + new FakeHost(surface), new CombatSettings { IdlePeaceMode = false }); + + arbiter.Tick(5.0, macroRunning: true, idle: true); + + Assert.Equal(0, surface.ModeChangeRequests); + Assert.Null(arbiter.Status); + } + + [Fact] + public void ArbiterRequestsWhenCombatDisabledButMacroRunning() + { + // The case CombatController's own idle-peace branch missed: with + // combat policy disabled, OnTick returns at "Combat disabled" before + // ever reaching its no-target state, so a disabled-combat, running + // macro never dropped to peace under the old code. + var surface = new FakeAutomation { CombatSnapshot = Physical() }; + var settings = new CombatSettings { IdlePeaceMode = true, Enabled = false }; + var controller = new CombatController(new FakeHost(surface), settings); + var arbiter = new MacroIdleModeArbiter(new FakeHost(surface), settings); + + controller.Toggle(); + controller.OnTick(0.25); + Assert.False(controller.HasTarget); + Assert.Contains("disabled", controller.Status, StringComparison.OrdinalIgnoreCase); + + arbiter.Tick(1.5, macroRunning: controller.Enabled, idle: !controller.HasTarget); + + Assert.Equal(1, surface.ModeChangeRequests); + Assert.Equal(PluginCombatMode.Peace, surface.CombatSnapshot.Mode); + } + private static CombatSettings DebuffOnly(MonsterActionFlags flag) { var settings = new CombatSettings(); @@ -992,8 +1207,26 @@ public sealed class CombatControllerTests public IReadOnlyList ShownProjectileDebugSamples { get; private set; } = []; public List SelectionActions { get; } = []; + + /// + /// Ordered log of the calls that matter for pinning arbitration + /// order (BuffCasterPreparer/MacroIdleModeArbiter tests): every + /// EnterMode and Equip request, in the order issued. + /// + public List CallLog { get; } = []; + + /// + /// When set, does not mark the item equipped + /// immediately — it goes busy until the test calls + /// , simulating the host's real + /// asynchronous AutoWield confirmation. Default false preserves + /// every existing test's synchronous behavior exactly. + /// + public bool SimulateAsyncEquip { get; set; } + private uint? _pendingEquipObjectId; bool IEquipmentAutomation.IsAvailable => true; - bool IEquipmentAutomation.IsBusy => false; + bool IEquipmentAutomation.IsBusy => + SimulateAsyncEquip && _pendingEquipObjectId is not null; public IReadOnlyList CaptureOwnedEquipment() => EquipmentItems; public PluginEquipmentCommandResult Equip( @@ -1001,8 +1234,33 @@ public sealed class CombatControllerTests uint requestedLocation = 0u) { LastEquipObjectId = objectId; + CallLog.Add($"Equip:{objectId:X8}"); + if (SimulateAsyncEquip) + _pendingEquipObjectId = objectId; + else + EquipmentItems = MarkEquipped(EquipmentItems, objectId); return new(PluginEquipmentCommandStatus.Started); } + + /// + /// Simulates the AutoWield confirmation landing for an + /// equip request. + /// + public void ConfirmPendingEquip() + { + if (_pendingEquipObjectId is not { } objectId) + return; + EquipmentItems = MarkEquipped(EquipmentItems, objectId); + _pendingEquipObjectId = null; + } + + private static IReadOnlyList MarkEquipped( + IReadOnlyList items, + uint objectId) => items + .Select(item => item.ObjectId == objectId + ? item with { EquippedLocation = 0x00100000u } + : item) + .ToArray(); bool IItemAutomation.IsAvailable => true; bool IItemAutomation.IsBusy => false; int IItemAutomation.ActiveOwnedPetCount => 0; @@ -1024,13 +1282,45 @@ public sealed class CombatControllerTests float maximumDistance) => Targets; public PluginCombatCommandResult EnterDefaultMode() => new(PluginCombatCommandStatus.ModeChangeSent); + + /// + /// When set, does not flip + /// on the same call — it stashes the + /// requested mode until the test calls + /// , simulating the host's + /// real asynchronous mode confirmation. Distinct from + /// , which never applies the mode at + /// all. Default false preserves every existing test's synchronous + /// behavior exactly. + /// + public bool DeferModeConfirmation { get; set; } + private PluginCombatMode? _pendingMode; public PluginCombatCommandResult EnterMode(PluginCombatMode mode) { ModeChangeRequests++; - if (!IgnoreModeChanges) - CombatSnapshot = CombatSnapshot with { Mode = mode }; + CallLog.Add($"EnterMode:{mode}"); + if (IgnoreModeChanges) + return new(PluginCombatCommandStatus.ModeChangeSent); + if (DeferModeConfirmation) + { + _pendingMode = mode; + return new(PluginCombatCommandStatus.ModeChangeSent); + } + CombatSnapshot = CombatSnapshot with { Mode = mode }; return new(PluginCombatCommandStatus.ModeChangeSent); } + + /// + /// Simulates the server's async mode-change confirmation arriving + /// for a request. + /// + public void ConfirmPendingModeChange() + { + if (_pendingMode is not { } mode) + return; + CombatSnapshot = CombatSnapshot with { Mode = mode }; + _pendingMode = null; + } public PluginCombatCommandResult BeginPhysicalAttack( uint targetObjectId, PluginAttackHeight height, float power) { @@ -1119,7 +1409,9 @@ public sealed class CombatControllerTests CastSpellIds.Add(spellId); return true; } - public void PostSystemMessage(string text) { } + public List PostedSystemMessages { get; } = []; + public void PostSystemMessage(string text) => + PostedSystemMessages.Add(text); public IReadOnlyList CaptureMessages( ulong afterSequence) => ChatMessages .Where(message => message.Sequence > afterSequence) diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 15783456..6df87d0d 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -85,6 +85,101 @@ public sealed class MossTankPanelTests Assert.StartsWith("Buffing", panel.BuffStatus, StringComparison.Ordinal); } + [Fact] + public void MacroWieldsCasterEntersMagicBuffsThenWieldsWeaponFightsThenIdlePeace() + { + // Test 6 of docs/plans/2026-09-06-mosstank-mode-arbitration.md: the + // owner scenario. A profiled wand and melee weapon, buffing and + // combat both enabled, one buff due and one hostile in range, macro + // started in Peace. + PluginSpellInfo buff = Spell( + 1, 10, "Increases the caster's Life Magic skill by 10 points."); + var automation = new CombatCapableFakeAutomation + { + CurrentHealth = 100, + MaxHealth = 100, + CurrentStamina = 100, + MaxStamina = 100, + CurrentMana = 100, + MaxMana = 100, + Skills = [new PluginSkillInfo(1, "Life Magic", PluginSkillTraining.Trained, 300)], + KnownSelfBuffs = [buff], + ItemEntries = + [ + Item(10, "War Wand", itemType: 0x00008000u), + Item(20, "Battle Axe", itemType: 1), + ], + EquipmentItems = + [ + EquipmentItem(10, "War Wand", itemType: 0x00008000u), + EquipmentItem(20, "Battle Axe", itemType: 1), + ], + Targets = [new PluginCombatTarget(30, "Drudge", 700, 2f, 0f, true, 1f)], + }; + var host = new FakeHost(automation); + var panel = new MossTankPanel(host); + + host.Selection.Select(10); + panel.AddSelectedItem(); // Items profile: the wand + host.Selection.Select(20); + panel.SetMonsterWeapon(); // DEFAULT rule's weapon: the axe + // (DEFAULT's Attack flag is already on by construction — VTank's own + // MonsterRuleActions default — so it is never toggled here.) + panel.ToggleIdlePeaceMode(); + + panel.ToggleCombat(); // Run Macro, starting in Peace + + bool attacked = false; + for (int tick = 0; tick < 60 && !attacked; tick++) + { + panel.OnTick(0.1); + attacked = automation.BeginCount > 0; + } + Assert.True( + attacked, + "Never attacked. CallLog: " + string.Join(" | ", automation.CallLog)); + + int equipWand = automation.CallLog.IndexOf("Equip:0000000A"); + int enterMagic = automation.CallLog.IndexOf("EnterMode:Magic"); + int cast = automation.CallLog.IndexOf("Cast:1"); + int enterPeaceForWeapon = cast < 0 + ? -1 + : automation.CallLog.FindIndex(cast + 1, entry => entry == "EnterMode:Peace"); + int equipWeapon = automation.CallLog.IndexOf("Equip:00000014"); + int defaultMode = automation.CallLog.IndexOf("EnterDefaultMode:Melee"); + int attack = automation.CallLog.IndexOf("Attack:0000001E"); + + // Peace(already) -> Equip wand: no separate peace request was needed + // for the wand because the macro started in Peace already. + Assert.True(equipWand >= 0, "wand was never equipped"); + Assert.DoesNotContain( + "EnterMode:Peace", + automation.CallLog.Take(equipWand)); + // -> Magic -> cast + Assert.True(enterMagic > equipWand, "Magic requested before the wand was wielded"); + Assert.True(cast > enterMagic, "cast happened before Magic mode was entered"); + // (pass ends) -> Peace -> Equip weapon -> default mode -> attack + Assert.True( + enterPeaceForWeapon > cast, + "no peace request to wield the weapon after the buff pass"); + Assert.True( + equipWeapon > enterPeaceForWeapon, + "weapon equipped before peace mode for it was entered"); + Assert.True(defaultMode > equipWeapon, "default mode entered before the weapon was equipped"); + Assert.True(attack > defaultMode, "attack began before the default mode was entered"); + + // With the hostile gone and Peace Mode When Idle on, the macro + // returns to peace by itself. + automation.Targets = []; + for (int tick = 0; + tick < 60 && automation.CombatSnapshot.Mode != PluginCombatMode.Peace; + tick++) + { + panel.OnTick(0.5); + } + Assert.Equal(PluginCombatMode.Peace, automation.CombatSnapshot.Mode); + } + [Fact] public void FastCastBuffsHoldsForwardOnlyUntilInstantBuffCastEnds() { @@ -1246,6 +1341,23 @@ public sealed class MossTankPanelTests id, 0, name, itemType, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, false, 0, 0, 0, 0, 0, 0, 0, 0); + private static PluginEquipmentItem EquipmentItem( + uint id, + string name, + uint itemType) => new( + id, + name, + ItemType: itemType, + ValidLocations: 0x00100000, + EquippedLocation: 0, + ContainerObjectId: 1, + WielderObjectId: 0, + CombatUse: 1, + DamageType: 0, + WeaponSkill: 44, + Damage: 20, + DamageVariance: 0.25); + private static PluginSpellInfo Spell(uint id, uint family, string description) => new( id, $"Spell {id}", @@ -1426,6 +1538,172 @@ public sealed class MossTankPanelTests } } + /// + /// A combat/equipment-capable automation surface for Test 6 of + /// docs/plans/2026-09-06-mosstank-mode-arbitration.md — the full + /// buff-then-fight scenario, which needs real (synchronous) mode/equip + /// simulation rather than 's NoOp-host + /// bypass. Kept separate from the shared so + /// every other test here is unaffected. + /// + private sealed class CombatCapableFakeAutomation : + IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, + IPluginChat, ICombatAutomation, IEquipmentAutomation, IItemAutomation + { + public bool IsAvailable { get; set; } = true; + public ICharacterInfo Character => this; + public ISpellCatalog Spells => this; + public IMagicCommands Magic => this; + public IPluginChat Chat => this; + public ICombatAutomation Combat => this; + public IEquipmentAutomation Equipment => this; + public IItemAutomation Items => this; + + /// Ordered log of every mode/equip/attack/cast call. + public List CallLog { get; } = []; + + // ── character ───────────────────────────────────────────────── + public bool IsInWorld => IsAvailable; + public uint ObjectId { get; set; } = 1; + public uint CurrentHealth { get; set; } + public uint MaxHealth { get; set; } + public uint CurrentStamina { get; set; } + public uint MaxStamina { get; set; } + public uint CurrentMana { get; set; } + public uint MaxMana { get; set; } + public int SummoningMastery => 0; + public IReadOnlyList Skills { get; set; } = []; + public IReadOnlyList Attributes { get; set; } = []; + public IReadOnlyList ActiveEnchantments { get; set; } = []; + public IReadOnlyList KnownSelfBuffs { get; set; } = []; + public IReadOnlyList KnownAttackSpells { get; set; } = []; + public IReadOnlyList KnownCombatSpells { get; set; } = []; + public bool TryGetSkill(uint skillId, out PluginSkillInfo skill) + { + foreach (PluginSkillInfo candidate in Skills) + { + if (candidate.SkillId == skillId) + { + skill = candidate; + return true; + } + } + skill = default; + return false; + } + public bool TryGet(uint spellId, out PluginSpellInfo info) + { + foreach (PluginSpellInfo candidate in KnownSelfBuffs) + { + if (candidate.SpellId == spellId) + { + info = candidate; + return true; + } + } + info = default; + return false; + } + + // ── magic ───────────────────────────────────────────────────── + public bool IsCasting { get; set; } + public List CastSpellIds { get; } = []; + public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready; + public bool Cast(uint spellId) + { + CastSpellIds.Add(spellId); + CallLog.Add($"Cast:{spellId}"); + return true; + } + + // ── chat ────────────────────────────────────────────────────── + public List Messages { get; } = []; + public void PostSystemMessage(string text) => Messages.Add(text); + + // ── inventory (used only to build the Items profile through the + // panel's real AddSelectedItem/SetMonsterWeapon UI flow) ──────── + bool IItemAutomation.IsAvailable => true; + bool IItemAutomation.IsBusy => false; + public IReadOnlyList ItemEntries { get; set; } = []; + public IReadOnlyList CaptureOwnedItems() => ItemEntries; + + // ── combat ──────────────────────────────────────────────────── + public PluginCombatSnapshot CombatSnapshot { get; set; } = new( + SelectedObjectId: 0, + PluginCombatMode.Peace, + PluginAttackHeight.Medium, + DesiredPower: 0.5f, + PowerBarLevel: 0f, + BuildInProgress: false, + RequestInProgress: false, + ServerResponsePending: false, + RepeatAttackInProgress: false); + PluginCombatSnapshot ICombatAutomation.Snapshot => CombatSnapshot; + public IReadOnlyList Targets { get; set; } = []; + public IReadOnlyList CaptureHostileTargets( + float maximumDistance) => Targets; + public int ModeChangeRequests { get; private set; } + public PluginCombatCommandResult EnterMode(PluginCombatMode mode) + { + ModeChangeRequests++; + CombatSnapshot = CombatSnapshot with { Mode = mode }; + CallLog.Add($"EnterMode:{mode}"); + return new(PluginCombatCommandStatus.ModeChangeSent); + } + public PluginCombatCommandResult EnterDefaultMode() + { + PluginCombatMode mode = PluginCombatMode.Peace; + foreach (PluginEquipmentItem item in EquipmentItems) + { + if (!item.IsEquipped) + continue; + mode = (item.ItemType & 0x00008000u) != 0u + ? PluginCombatMode.Magic + : PluginCombatMode.Melee; + break; + } + CombatSnapshot = CombatSnapshot with { Mode = mode }; + CallLog.Add($"EnterDefaultMode:{mode}"); + return new(PluginCombatCommandStatus.ModeChangeSent); + } + public int BeginCount { get; private set; } + public uint LastBeginTarget { get; private set; } + public PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, PluginAttackHeight height, float power) + { + LastBeginTarget = targetObjectId; + BeginCount++; + CallLog.Add($"Attack:{targetObjectId:X8}"); + return new(PluginCombatCommandStatus.Started); + } + public PluginCombatCommandResult ReleasePhysicalAttack() => + new(PluginCombatCommandStatus.Released); + public PluginCombatCommandResult AbortPhysicalAttack() => + new(PluginCombatCommandStatus.Stopped); + + // ── equipment ───────────────────────────────────────────────── + bool IEquipmentAutomation.IsAvailable => true; + bool IEquipmentAutomation.IsBusy => false; + public IReadOnlyList EquipmentItems { get; set; } = []; + public IReadOnlyList CaptureOwnedEquipment() => + EquipmentItems; + public PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) + { + CallLog.Add($"Equip:{objectId:X8}"); + // Retail AutoWield swaps whatever else occupied the held-item + // slot; this fake has exactly one such slot in play, so wielding + // a new item unequips any other. + EquipmentItems = EquipmentItems + .Select(item => item.ObjectId == objectId + ? item with { EquippedLocation = 0x00100000u } + : item with { EquippedLocation = 0u }) + .ToArray(); + return new(PluginEquipmentCommandStatus.Started); + } + } + private sealed class FakeLogger : IPluginLogger { public void Info(string message) { }