docs(mosstank): plan combat-mode and caster arbitration for buffing and idle

Owner request 2026-09-06: the macro must use the Items profile to wield what
it needs, enter the right combat mode by itself to buff or fight from peace
mode, and return to peace when idle with Peace Mode When Idle on. Verified
gap: ACE drops any cast outside magic mode, and the buff pass never enters
magic mode or wields a caster; idle-peace lives only inside the combat
controller and so never runs with combat disabled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 18:29:11 +02:00
parent e61edd946b
commit 7d5fd7cc10

View file

@ -0,0 +1,159 @@
# 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)
1. 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.
2. 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:
1. **Resolve caster.** Snapshot `Equipment.CaptureOwnedEquipment()`. The
buffing caster is, in order: the currently wielded caster
(`ItemType & 0x8000`, `IsEquipped`); else the first profiled caster
(membership by `CombatItemObjectIds`/`CombatItemNames`, same predicate as
`VitalRecharge`), deterministic order (name, then object id). `no buffs`
membership is irrelevant here. No candidate at all → the pass STOPS with
VTank's own error text, announced once through `Chat.PostSystemMessage`:
`"You must add at least one wand to your Items profile."` (the string
`CombatController` already uses), and the automatic pass does not retry
for `BuffSettings`' next scan interval.
2. **Wield if needed.** If the caster is not wielded: if mode ≠ Peace →
`EnterMode(Peace)` and wait; when in Peace → `Equipment.Equip(caster)`;
while `Equipment.IsBusy` wait; if `Refused` → stop the pass with the
refusal in the status line.
3. **Enter Magic.** If mode ≠ Magic → `EnterMode(Magic)`; wait until
`Snapshot.Mode == Magic`.
4. **Ready** → the existing queue/`TryCast` loop 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.IdlePeaceMode` is on;
- no buff pass is running (`!_running`) and `!Magic.IsCasting`;
- no controller owned an action this tick (the existing `*OwnsAction` set,
plus `_combat.HasTarget` false and `Equipment.IsBusy` false);
- `Snapshot.Mode` is 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 gate `TryCast` on `Ready`;
reset on `Stop`/session end/macro stop; tick the arbiter at the end of
`OnTick` with 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 in `CombatControllerTests.cs`; extend it if it lacks equipment or
mode-change simulation).
## Tests (each shown to fail before its change)
1. Preparer, wielded caster + Peace mode: requests Magic, not Ready until the
snapshot reports Magic, then Ready; exactly one `EnterMode` call before the
snapshot flips.
2. 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.
3. Preparer, no caster anywhere: stops the pass, posts the VTank error once,
does not re-post on the next tick.
4. Preparer, mode request never confirmed: after the retry budget the pass
stops with the stage named; the number of `EnterMode` calls equals the
budget.
5. 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 `IsCasting` each suppress it; IdlePeaceMode off →
never requests; combat DISABLED but macro running still requests (the
case the old code missed).
6. 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.
7. `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
(filled at review time)