13 KiB
MossTank — combat-mode and caster arbitration for buffing and idle
Date: 2026-09-06
Status: ACTIVE (owner request after the plugin-UI acceptance the same day)
Parent: docs/plans/2026-08-26-mosstank-parity-campaign.md (MT3 buff parity /
MT1 autocombat); this is the missing arbiter between the two.
Owner direction: plan (Fable) → implement (Sonnet) → review (Opus, two lenses).
Owner request (verbatim intent)
- When the macro is running and needs to buff or fight, it must use the Items page (the profile of items the macro may equip). With buffing and fighting enabled and the character in peace mode when the macro starts, the macro must enter the right combat mode by itself and fight or buff as needed.
- With Peace Mode When Idle on, the macro must return to peace mode when it has no action (no buff, no fight) to perform.
Verified current state (HEAD e61edd946)
| Piece | Where | Fact |
|---|---|---|
| Items profile | CombatSettings.CombatItemNames (durable exact names) + CombatItemObjectIds (session ids); _noBuffItemNames in MossTankPanel |
Items page Add / Add (no buffs) / Remove mutate these. no buffs = VTank's "do not cast item enchantments on this weapon"; it does NOT mean "not a buffing caster". |
| Combat side | CombatController.OnTick (:185-345) |
With a target: TickEquipment picks the rule weapon from the profile (SelectAutomaticWeapon filters by CombatItemNames/ObjectIds), TryEquipIfNeeded drops to Peace to wield (retry budget VitalSettings.DropToPeaceModeRetryCount, then the wand-recovery path), then EnterDefaultMode. With no target and CombatSettings.IdlePeaceMode: EnterMode(Peace) — but ONLY while _settings.Enabled (combat enabled); with combat disabled the tick returns at "Combat disabled" and nobody drops to peace. |
| Buff side | MossTankPanel.StartBuffPass / the pass tick (~3905-3975) / TryCast |
Builds the queue and casts through Magic.EvaluateGate + Magic.Cast. No combat-mode and no caster handling at all. The combat controller is SetPaused while _running (pass active). |
| Host gates | AppAutomationSurface.EvaluateGate |
Knows / busy / target-compatibility only; it does not check combat mode. |
| Server truth | ACE Player_Magic.cs:84-95, 275-286 |
A cast arriving with CombatMode != Magic is DROPPED (SendUseDoneEvent) unless LastCombatMode == Magic. So a buff pass started from peace or melee mode silently casts nothing. |
| Magic mode entry | ACE Player_Combat.cs:778+ (GetEquippedWand), runtime RuntimeCombatModeState.Request(Magic) |
Magic mode requires a wielded caster; a request without one is denied server-side. Wielding requires Peace mode (retail rule already modelled by TryEquipIfNeeded). |
| Caster selection precedents | VitalRecharge.cs:495-540 (CasterItemType = 0x8000, profile membership), CombatController.SelectRecoveryCaster |
Both already pick a profiled caster. |
| Mode requests | ICombatAutomation.EnterMode(mode) → ModeChangeSent; Snapshot.Mode updates when the server confirms |
Asynchronous; callers must wait on the snapshot. |
Design
Two small, testable owners inside the plugin. No host/API changes; the plugin-facing surface already has everything needed.
A. BuffCasterPreparer — get the character ready to cast
A state machine the buff pass runs BEFORE its first TryCast, ticked every
frame while the pass is running, with Ready gating the queue. It is the
single owner of "which caster do we buff with and how do we get into Magic
mode". States:
- Resolve caster. Snapshot
Equipment.CaptureOwnedEquipment(). The buffing caster is, in order: the currently wielded caster (ItemType & 0x8000,IsEquipped); else the first profiled caster (membership byCombatItemObjectIds/CombatItemNames, same predicate asVitalRecharge), deterministic order (name, then object id).no buffsmembership is irrelevant here. No candidate at all → the pass STOPS with VTank's own error text, announced once throughChat.PostSystemMessage:"You must add at least one wand to your Items profile."(the stringCombatControlleralready uses), and the automatic pass does not retry forBuffSettings' next scan interval. - Wield if needed. If the caster is not wielded: if mode ≠ Peace →
EnterMode(Peace)and wait; when in Peace →Equipment.Equip(caster); whileEquipment.IsBusywait; ifRefused→ stop the pass with the refusal in the status line. - Enter Magic. If mode ≠ Magic →
EnterMode(Magic); wait untilSnapshot.Mode == Magic. - Ready → the existing queue/
TryCastloop runs unchanged.
Waits carry a bounded budget: each mode request may be re-issued after
ModeRetrySeconds = 2.0 and at most VitalSettings.DropToPeaceModeRetryCount
times, mirroring TryEquipIfNeeded; exhausting it stops the pass with a
status naming the stage ("could not enter magic mode"). Magic.IsCasting
true at any stage → wait. The preparer is reset on Stop, on session end, and
when the macro stops.
After the pass ends nothing is restored here: the combat controller resumes
(it is un-paused) and, with a target, TickEquipment swaps to the rule
weapon exactly as today; with no target the idle owner below decides.
B. MacroIdleModeArbiter — one owner for "peace when idle"
Move the idle-peace decision OUT of CombatController.OnTick (delete its
_settings.IdlePeaceMode branch at :274-282; keep "Waiting for a target")
into one arbiter ticked from MossTankPanel.OnTick after every controller has
ticked, so it also works with combat disabled. It requests
EnterMode(Peace) when ALL hold:
- the macro is running;
CombatSettings.IdlePeaceModeis on;- no buff pass is running (
!_running) and!Magic.IsCasting; - no controller owned an action this tick (the existing
*OwnsActionset, plus_combat.HasTargetfalse andEquipment.IsBusyfalse); Snapshot.Modeis not Peace/Unknown.
It re-issues at most once per IdleRetrySeconds = 1.0 while the condition
holds and writes the status line "Entering peace mode" / the refusal notice.
With IdlePeaceMode off it never touches the mode (VTank stays in combat
mode when idle).
C. Macro start from peace mode (owner scenario)
Nothing new is needed beyond A and B: on Run Macro the combat controller
already scans for hostiles and, on a target, wields the profiled weapon and
enters the default mode; the automatic buff scan (TickAutomaticBuffing)
starts a pass which now prepares the caster and enters Magic. Pin the whole
scenario in a panel-level test (see Tests, item 6).
Non-goals
- No new VTank options.
IdlePeaceMode, the Items profile, and the buff settings are the only inputs. - No host/API change. Do not add combat-mode logic to
AppAutomationSurface. - Do not change
TickEquipment's weapon policy or the recovery-caster path.
Files
- New:
src/AcDream.Plugins.MossTank/BuffCasterPreparer.cs,src/AcDream.Plugins.MossTank/MacroIdleModeArbiter.cs. src/AcDream.Plugins.MossTank/MossTankPanel.cs: construct both; run the preparer at the head of the pass tick and gateTryCastonReady; reset onStop/session end/macro stop; tick the arbiter at the end ofOnTickwith the owns-action set already computed there.src/AcDream.Plugins.MossTank/CombatController.cs: delete the idle-peace branch (single owner), keep the rest.- Tests in
tests/AcDream.Plugins.MossTank.Tests/(reuse the fake automation surface inCombatControllerTests.cs; extend it if it lacks equipment or mode-change simulation).
Tests (each shown to fail before its change)
- Preparer, wielded caster + Peace mode: requests Magic, not Ready until the
snapshot reports Magic, then Ready; exactly one
EnterModecall before the snapshot flips. - Preparer, caster in profile but not wielded, mode Melee: requests Peace → equips the caster (only after the snapshot reports Peace) → requests Magic → Ready; order pinned via a recorded call list.
- Preparer, no caster anywhere: stops the pass, posts the VTank error once, does not re-post on the next tick.
- Preparer, mode request never confirmed: after the retry budget the pass
stops with the stage named; the number of
EnterModecalls equals the budget. - Arbiter: idle + IdlePeaceMode + Melee → one Peace request; second tick
within 1 s → no second request; a running buff pass, a hostile target, a
busy equipment swap, or
IsCastingeach suppress it; IdlePeaceMode off → never requests; combat DISABLED but macro running still requests (the case the old code missed). - Panel scenario: macro started in Peace with buffing + combat enabled, a profiled wand and a profiled melee weapon, one buff due, one hostile in range → observed order: Peace(already) → Equip wand → Magic → cast → (pass ends) → Peace → Equip weapon → default mode → attack. Then, with the hostile gone and IdlePeaceMode on → Peace.
CombatControllerTests: the moved idle-peace expectation is deleted there and re-pinned on the arbiter; every other test stays green.
Acceptance
dotnet build AcDream.slnx -c Release; dotnet test tests/AcDream.Plugins.MossTank.Tests -c Release all green (337 + new);
dotnet test tests/AcDream.App.Tests -c Release unchanged failure set. Then
the owner's connected gate: with a wand and a weapon on the Items page, start
the macro in peace mode with buffing and combat enabled; watch it wield the
wand, enter magic mode, buff, swap to the weapon and fight when a creature
appears, and drop to peace when nothing is left and Peace Mode When Idle is on.
Review ledger
- Implementation commit:
c406942ef9c72dbf6744b30d46c0acf70537e298—feat(mosstank): buff-caster preparer and single idle-peace arbiter — wield from the Items profile, enter magic/peace mode by itself. Design A (BuffCasterPreparer), Design B (MacroIdleModeArbiter), theMossTankPanel/CombatControllerwiring, and Tests 1-7 landed.AcDream.Plugins.MossTank.Tests336 -> 345 (336 baseline sans the movedIdlePeaceIsTheNoTargetFallback, plus 9 new);AcDream.App.TestsMossTank/Plugin filter (79 tests, incl.MossTankMarkupContractTests) stays green with no markup changes. Owner review still owed (Opus, two lenses) plus the connected gate in Acceptance.
Opus review of c406942ef against VTank's decompiled source (2026-09-06)
Verdict PASS-WITH-FIXES; owner direction is knowledge base first, so the
fixes are DEFERRED to Campaign VT (VT2 re-judges this commit against
docs/research/vtank-kb/02-scheduler-and-actions.md). Oracle citations are
into refs/vtank/decompiled/.
VTank facts established (cite these, do not re-derive): IdlePeace is one rule
type cm registered LAST after the "END" sentinel (cLogic.cs:576-577); the
engine runs strictly the first valid rule per tick (cLogic.cs:220-253); the
buff rule fz (cLogic.cs:466) is valid whenever a buff is DUE
(fz.cs:69-86), so a due buff suppresses idle-peace even between passes;
cm(0) is also a pre-chain on idle loot, corpse approach, target approach and
nav route (cLogic.cs:530-570, LogicRulePreChain.cs:52-62) — VTank drops to
Peace BEFORE those; fd.cs:129-133 forces Magic in the tight-waypoint case.
Caster/mode sequencing is one idempotent per-tick function ga.a
(ga.cs:1432-1565): preferred item → wielded item if its class maps to the
wanted mode → profile scan in Items-page insertion order (ga.cs:1400-1408,
eq.cs:85-93; the "no buffs" spell field is ignored); no caster → one error
"You must add at least one wand to your profile." + StopMacro()
(ga.cs:1469-1472); retry counter per tick, exhaustion runs the bugged-
combat-state wand-use recovery, never a give-up (ga.cs:1502-1524); nothing
is restored after buffing — combat re-wields through the same function.
Findings to fix in VT3 (numbers from the review):
- BLOCKING — no-caster path re-posts the notice ~1 Hz forever (
Stopclears the latch, the auto scan restarts the pass); VTank posts once and stops the macro. Fix: stop the macro (asCombatController.Disabledoes). - BLOCKING — 2.0 s ×
DropToPeaceModeRetryCount(34) = 68 s > the pass's 30 sStallTimeoutSeconds, so the exhausted-budget stop is unreachable; and VTank does not stop there anyway — it runs the wand-use recovery. - SHOULD-FIX — arbiter drops to peace while a buff is due (1 s scan gap); add "a buff is due" to the idle set.
- SHOULD-FIX —
MacroIdleModeArbiter.Statusis never displayed. - SHOULD-FIX — profiled-caster order must be Items-page insertion order,
not name order (needs an ordered profile list;
CombatItemNamesis a set). - SHOULD-FIX — equip stage re-issues
Equipevery frame with no cadence or budget and no wieldability check (ga.cs:1456-1460). - NIT —
Mode == Unknownspins the preparer (arbiter treats it as hands-off). - NIT — two caster predicates (
& 0x8000vs exact); share one helper. - NIT — the notice text differs from VTank's ("…to your profile.").
- CARRIED — the
cm(0)pre-chains and thefd.cs:131Magic case are unported; ours suppresses idle-peace on nav/loot instead. Also:CombatController.EnterDebuffMode(:1373) and the metasetcombatstateexpression can fight the arbiter (no owns-action flag) — addHasPendingItemDebuffand meta activity to the idle set. Tests T3/T4/T5b/T5d are vacuous as written (pass with the defect present); T1/T2/T5a/T5c/T6 are real pins.