feat(mosstank): buff-caster preparer and single idle-peace arbiter — wield from the Items profile, enter magic/peace mode by itself

Implements docs/plans/2026-09-06-mosstank-mode-arbitration.md.

The buff pass used to fire TryCast with no regard for combat mode or
which caster was wielded. ACE's Player_Magic.cs:84-95 drops any cast
that arrives while CombatMode != Magic, and Player_Combat.cs:778+
(GetEquippedWand) requires a wielded caster before Magic mode can be
entered at all — so a buff pass started from Peace or Melee silently
cast nothing.

Design A: BuffCasterPreparer (new) is the single owner of "which
caster do we buff with and how do we get into Magic mode". It resolves
the wielded caster first, else the first profiled caster (same
membership predicate as VitalRecharge), wields it through Peace when
needed, then requests Magic — gating the buff queue on Ready. A
missing caster stops the pass with VTank's own notice, posted once
per Reset. A stuck mode request retries on a 2s cadence up to
VitalSettings.DropToPeaceModeRetryCount before stopping and naming the
stuck stage. Hosts that don't model combat-mode automation at all
(EnterMode returns Unavailable) bypass the gate rather than deadlock,
matching the existing TickEquipment convention for older/no-window
hosts.

Design B: MacroIdleModeArbiter (new) is the single owner of "Peace
Mode When Idle", deleting CombatController's own idle-peace branch.
The old branch only ran from CombatController's own no-target state,
which a disabled combat policy never reaches — so a running macro
with combat disabled never dropped to peace. The arbiter ticks after
every controller in MossTankPanel.OnTick and covers that case.

Tests 1-7 of the plan: BuffCasterPreparer (wielded-caster fast path,
wield-then-magic ordering, no-caster notice latch, exhausted retry
budget) and MacroIdleModeArbiter (retry gate, suppression, IdlePeaceMode
off, the disabled-combat case) are added to CombatControllerTests.cs,
reusing its FakeAutomation extended with deferred mode/equip
confirmation and a call log. A full buff-then-fight panel scenario is
added to MossTankPanelTests.cs via a new CombatCapableFakeAutomation.
CombatControllerTests' IdlePeaceIsTheNoTargetFallback is deleted and
re-pinned on the arbiter. Every new test was confirmed to fail (by
compile error or by runtime assertion) against the unmodified
production code via a temporary git stash before its fix landed.

337 -> 345 AcDream.Plugins.MossTank.Tests (336 baseline sans the moved
test, plus 9 new); AcDream.App.Tests MossTank/Plugin filter (79 tests,
including MossTankMarkupContractTests) stays green with no markup
changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 19:02:18 +02:00
parent 7d5fd7cc10
commit c406942ef9
6 changed files with 992 additions and 31 deletions

View file

@ -0,0 +1,274 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Gets the character ready to cast before a buff pass's first
/// <c>TryCast</c>: 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 <c>Player_Magic.cs:84-95</c>), and Magic
/// mode itself requires a wielded caster (ACE
/// <c>Player_Combat.cs:778+</c>, <c>GetEquippedWand</c>) — 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
/// <c>docs/plans/2026-09-06-mosstank-mode-arbitration.md</c> Design A.
/// </summary>
internal sealed class BuffCasterPreparer
{
/// <summary>
/// Retail's caster ITEM_TYPE bit (VTank's <c>CasterItemType</c>,
/// <see cref="VitalRecharge"/>'s recovery-caster predicate, and
/// <see cref="CombatController.SelectRecoveryCaster"/> all use the same
/// constant).
/// </summary>
private const uint CasterItemType = 0x00008000u;
/// <summary>
/// Mirrors <see cref="CombatController.TryEquipIfNeeded"/>'s retry
/// budget in spirit (same <see cref="VitalSettings.DropToPeaceModeRetryCount"/>
/// 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.
/// </summary>
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;
/// <summary>
/// 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 <see cref="Reset"/> (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.
/// </summary>
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();
}
/// <summary>The buff queue may run once this is true.</summary>
public bool Ready { get; private set; }
/// <summary>
/// The pass must stop (no caster, an equip/mode refusal, or an
/// exhausted retry budget). <see cref="Status"/> names why.
/// </summary>
public bool Stopped { get; private set; }
public string Status { get; private set; } = string.Empty;
/// <summary>
/// Ticked every frame while the buff pass runs, before its first
/// <c>TryCast</c>. A no-op once <see cref="Ready"/> or
/// <see cref="Stopped"/>.
/// </summary>
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";
}
/// <summary>Reset on Stop, on session end, and when the macro stops.</summary>
public void Reset()
{
Ready = false;
Stopped = false;
Status = string.Empty;
_pendingRequestedMode = null;
_modeWaitElapsed = 0d;
_modeRetryCount = 0;
_noCasterNoticePosted = false;
}
/// <summary>
/// Wielded caster first (retail's actual equipped wand); else the first
/// profiled caster, ordered by name then object id for a deterministic
/// choice. <c>_noBuffItemNames</c> (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.
/// </summary>
private bool TryResolveCaster(
IReadOnlyList<PluginEquipmentItem> 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;
}
/// <summary>
/// Issue (or re-issue) one mode request, gated to at most once per
/// <see cref="ModeRetrySeconds"/> and at most
/// <see cref="VitalSettings.DropToPeaceModeRetryCount"/> times; the
/// exhausted budget stops the pass naming the stage.
/// </summary>
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";
}
}

View file

@ -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;
}

View file

@ -0,0 +1,72 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// 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 <see cref="MossTankPanel.OnTick"/> after every other
/// controller has ticked, so it also fires when combat is disabled — the
/// case <see cref="CombatController"/>'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
/// <c>docs/plans/2026-09-06-mosstank-mode-arbitration.md</c> Design B.
/// </summary>
internal sealed class MacroIdleModeArbiter
{
/// <summary>
/// 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.
/// </summary>
private const double IdleRetrySeconds = 1.0;
private readonly IPluginHost _host;
private readonly CombatSettings _settings;
/// <summary>Starts armed so the first idle tick can request immediately.</summary>
private double _sinceLastRequest = IdleRetrySeconds;
public MacroIdleModeArbiter(IPluginHost host, CombatSettings settings)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
/// <summary>Last request's outcome, or null while the arbiter is inactive.</summary>
public string? Status { get; private set; }
/// <param name="macroRunning">VTank's Run Macro toggle (not the combat policy Enabled flag).</param>
/// <param name="idle">
/// 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.
/// </param>
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";
}
}

View file

@ -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();
}

View file

@ -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<PluginProjectileDebugSample>
ShownProjectileDebugSamples { get; private set; } = [];
public List<PluginSelectionAction> SelectionActions { get; } = [];
/// <summary>
/// Ordered log of the calls that matter for pinning arbitration
/// order (BuffCasterPreparer/MacroIdleModeArbiter tests): every
/// EnterMode and Equip request, in the order issued.
/// </summary>
public List<string> CallLog { get; } = [];
/// <summary>
/// When set, <see cref="Equip"/> does not mark the item equipped
/// immediately — it goes busy until the test calls
/// <see cref="ConfirmPendingEquip"/>, simulating the host's real
/// asynchronous AutoWield confirmation. Default false preserves
/// every existing test's synchronous behavior exactly.
/// </summary>
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<PluginEquipmentItem> 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);
}
/// <summary>
/// Simulates the AutoWield confirmation landing for an
/// <see cref="SimulateAsyncEquip"/> equip request.
/// </summary>
public void ConfirmPendingEquip()
{
if (_pendingEquipObjectId is not { } objectId)
return;
EquipmentItems = MarkEquipped(EquipmentItems, objectId);
_pendingEquipObjectId = null;
}
private static IReadOnlyList<PluginEquipmentItem> MarkEquipped(
IReadOnlyList<PluginEquipmentItem> 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);
/// <summary>
/// When set, <see cref="EnterMode"/> does not flip
/// <see cref="CombatSnapshot"/> on the same call — it stashes the
/// requested mode until the test calls
/// <see cref="ConfirmPendingModeChange"/>, simulating the host's
/// real asynchronous mode confirmation. Distinct from
/// <see cref="IgnoreModeChanges"/>, which never applies the mode at
/// all. Default false preserves every existing test's synchronous
/// behavior exactly.
/// </summary>
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);
}
/// <summary>
/// Simulates the server's async mode-change confirmation arriving
/// for a <see cref="DeferModeConfirmation"/> request.
/// </summary>
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<string> PostedSystemMessages { get; } = [];
public void PostSystemMessage(string text) =>
PostedSystemMessages.Add(text);
public IReadOnlyList<PluginChatMessage> CaptureMessages(
ulong afterSequence) => ChatMessages
.Where(message => message.Sequence > afterSequence)

View file

@ -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
}
}
/// <summary>
/// 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 <see cref="FakeAutomation"/>'s NoOp-host
/// bypass. Kept separate from the shared <see cref="FakeAutomation"/> so
/// every other test here is unaffected.
/// </summary>
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;
/// <summary>Ordered log of every mode/equip/attack/cast call.</summary>
public List<string> 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<PluginSkillInfo> Skills { get; set; } = [];
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; set; } = [];
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; set; } = [];
public IReadOnlyList<PluginSpellInfo> KnownAttackSpells { get; set; } = [];
public IReadOnlyList<PluginSpellInfo> 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<uint> 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<string> 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<PluginInventoryItem> ItemEntries { get; set; } = [];
public IReadOnlyList<PluginInventoryItem> 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<PluginCombatTarget> Targets { get; set; } = [];
public IReadOnlyList<PluginCombatTarget> 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<PluginEquipmentItem> EquipmentItems { get; set; } = [];
public IReadOnlyList<PluginEquipmentItem> 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) { }