diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0a847ae6..4a33fc86 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -516,15 +516,15 @@ optimistic pre-confirm projection instead of treating equipment as backpack contents. The connected gate completed NonCombat → Missile → NonCombat → Missile, including a Jump press, without a server rejection. -**2026-07-17 active-switch follow-up:** A connected Magic wand -> missile -weapon trace exposed ACE's queued `Magic -> Melee -> NonCombat -> Missile -> -NonCombat` tail. `AutoWieldController` now retains the desired ready mode -through every confirmed blocker move and, only for a transaction that began -in active combat, arms settlement on authoritative `WieldObject`. An already- -correct first mode notice clears it; ACE's observed `NonCombat -> Missile -> -NonCombat` sequence sends the normal mode request from its trailing notice, -after the queued callback that caused that notice. -This keeps the missile bar present while retaining +**2026-07-17 active-switch follow-up:** Connected primary-weapon replacement +traces exposed ACE's queued old-stance transition and trailing `NonCombat`. +`AutoWieldController` now retains the desired ready mode through every +confirmed blocker move and, only for a transaction that began in active +combat, arms settlement on authoritative `WieldObject`. It recognizes both a +pre-wield transition followed by `ready -> NonCombat` and a wholly post-wield +`NonCombat -> ready -> NonCombat` tail, then sends the normal mode request from +the trailing notice after the queued callback that caused it. An explicit user +combat request cancels settlement. This keeps the correct missile or magic bar present while retaining ACE ownership of every state and motion; peace-mode switching is unchanged. The narrow ACE compatibility difference is registered as AP-118. @@ -2319,6 +2319,15 @@ request now retires the previous ground-object presentation immediately, as retail `SetGroundObject` does. This removes the stale range watcher whose close Use canceled ACE's active MoveTo chain before the new corpse could open. +**2026-07-17 input-order follow-up:** A second connected trace showed the +remaining far-R failure as `UseDone(0)` with no `ViewContents`. Silk delivered +R before the object phase had serialized a preceding movement-key release, so +ACE received `Use` and then `MoveToState`; ACE correctly cancelled the +Use-created MoveTo callback while acdream's local MoveTo kept walking. The new +`OutboundInteractionQueue` drains keyboard Use, world double-click, and pickup +immediately after the same frame's movement output and before inbound dispatch. +The flow still sends exactly one request and relies on ACE's arrival callback. + **Acceptance:** replacement sends `NoLongerViewingContents` once; authored close/range sends `Use` once and waits for `0x0052`; owned main/side-pack navigation sends neither; root/nested projections retire without deleting objects; corpse Use never attempts to pick up the corpse; a full-stack loot drop immediately shows a ghosted pending copy at the chosen destination slot and confirmation/failure clears it; canonical transfers remain server-authoritative. Focused Core, Core.Net, and App tests pin each path; visual acceptance remains the normal user gate. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a875bce1..ff5f7838 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -215,7 +215,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | | AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | -| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, an immediately correct mode clears settlement; local ACE's observed `NonCombat -> ready mode -> NonCombat` tail causes one normal `ChangeCombatMode` request from the trailing notice. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during wand-to-bow replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later replacement/logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | +| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | ## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) diff --git a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md index 1a631356..19464ff9 100644 --- a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md +++ b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md @@ -116,11 +116,20 @@ on server-confirmed WieldObject(item): retain the ready mode implied by the confirmed equip location on combat-mode notices after confirmed WieldObject: - if the first notice is the retained ready mode: + if the first notice is the retained ready mode + and no authoritative transition occurred during blocker removal: clear the retained mode; the server already settled correctly - else if notices form NonCombat -> retained ready mode -> NonCombat: + else if blocker removal already emitted a non-ready transition + and notices finish retained ready mode -> NonCombat: clear the retained mode request that ready mode from ACE's trailing NonCombat notice + else if post-wield notices form NonCombat -> retained ready mode -> NonCombat: + clear the retained mode + request that ready mode from ACE's trailing NonCombat notice + +on explicit user combat-mode request: + clear retained AutoWield mode intent + the user's newer request wins on PrivateUpdatePropertyInt(property = CombatMode, value): if value is exactly NonCombat, Melee, Missile, or Magic: @@ -145,12 +154,17 @@ not need a second request against the retail server. acdream therefore preserves the user's desired ready mode only when AutoWield begins in active combat. Authoritative `WieldObject` arms settlement. A server -whose first subsequent notice is already the desired mode needs no request. -Local ACE instead emits `NonCombat -> desired mode -> NonCombat`; the client +that emitted no blocker transition and immediately reports the desired mode +needs no request. Local ACE can begin its old-weapon transition before +`WieldObject`, report the desired mode for the new weapon, and then publish a +queued trailing `NonCombat`; it can also emit the complete +`NonCombat -> desired mode -> NonCombat` tail after `WieldObject`. The client sends one normal `ChangeCombatMode` request from that trailing notice, necessarily after the queued callback that caused it. All resulting state and animation remain server- authoritative. No update is ignored, delayed, or locally fabricated, and a -transaction begun in peace sends no mode request. This narrow server- +transaction begun in peace sends no mode request. An explicit user combat-mode +request cancels retained settlement so the compatibility owner cannot fight a +deliberate switch to Peace. This narrow server- compatibility adaptation is recorded as AP-118. The request is not complete when `GetAndWieldItem` is merely sent. Retail diff --git a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md index 733af021..ddc7cc48 100644 --- a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md +++ b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md @@ -30,6 +30,7 @@ Named Sept-2013 retail symbols: - `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0` - `ItemHolder::AttemptToPlaceInContainer @ 0x00588140` - `ItemHolder::UseObject @ 0x00588A80` +- `CommandInterpreter::SendMovementEvent @ 0x006B4680` The exact client dispatch for server event `CloseGroundContainer (0x0052)` is at `0x0055B187` in `acclient_2013_pseudo_c.txt`. @@ -40,6 +41,10 @@ Cross-references: `GameActionNoLongerViewingContents`, `Container.SendInventory`, and `Container.FinishClose` confirm the authoritative packet order and that ACE can send `ViewContents` for the root followed by its nested containers. +- ACE `GameActionMoveToState.Handle` confirms that a movement state received + after `Use` cancels the Use-created `CreateMoveToChain`. A cancelled chain + still reaches `TryUseItem(success:false)` and produces successful `UseDone` + without calling `Container.Open`, explaining the approach-with-no-loot trace. - holtburger protocol `inventory/events.rs` and `inventory/actions.rs` confirm the exact wire shapes. Its world inventory handler independently models open container contents as temporary preview retention and retires that preview on @@ -71,6 +76,13 @@ thumb, and arrow sprites from LayoutDesc `0x2100003E`. ## Pseudocode ```text +OnInputFrame(): + sample movement-key levels + serialize any movement press/release edge as MoveToState + drain later one-shot Use/PickUp inputs in original FIFO order + // A preceding movement release must reach ACE before Use. Sending it + // afterward cancels ACE's server-side approach callback. + UseWorldObject(objectId): decision = ItemHolder policy send Use(objectId) @@ -210,6 +222,11 @@ DragOwnedItemToExternalContainer(itemId, placement, splitAmount): - `ItemInteractionController` remains the only interpreter of retail item-use and placement policy. It gains explicit adapters for `SetGroundObject` and `PlaceInContainer`; panels do not reimplement those decisions. +- `OutboundInteractionQueue` owns the input-frame ordering seam. Keyboard Use, + world double-click, and keyboard pickup are captured by the Silk callback, + then drained immediately after `LocalPlayerOutboundController` serializes + that frame's movement edge and before inbound network dispatch. It sends one + request; it is not an arrival retry. - `GameWindow` only creates the lifecycle/UI bindings and supplies network delegates. No external-container feature body lives there. diff --git a/src/AcDream.App/Input/OutboundInteractionQueue.cs b/src/AcDream.App/Input/OutboundInteractionQueue.cs new file mode 100644 index 00000000..a23e12ca --- /dev/null +++ b/src/AcDream.App/Input/OutboundInteractionQueue.cs @@ -0,0 +1,41 @@ +namespace AcDream.App.Input; + +/// +/// Orders input-originated item interactions after the local player's movement +/// edge has been serialized for the same frame, but before inbound network +/// dispatch. +/// +/// +/// Silk input callbacks arrive before the retail object phase. A movement-key +/// release and a later Use press can therefore be observed in one frame even +/// though the movement controller has not yet emitted the release packet. +/// Draining here preserves that event order on the wire: the release is sent +/// first, then the one-shot interaction. ACE otherwise interprets the later +/// MoveToState as cancellation of the Use-created MoveTo chain. +/// +public sealed class OutboundInteractionQueue +{ + private readonly Queue _pending = new(); + + public int Count => _pending.Count; + + public void Enqueue(Action action) + { + ArgumentNullException.ThrowIfNull(action); + _pending.Enqueue(action); + } + + /// + /// Drain only the actions present at the frame boundary. An action that + /// enqueues another action leaves it for the next frame instead of making + /// the current dispatch recursively unbounded. + /// + public void Drain() + { + int count = _pending.Count; + while (count-- > 0) + _pending.Dequeue().Invoke(); + } + + public void Clear() => _pending.Clear(); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 12fd1861..0eac938f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -165,6 +165,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; + private readonly AcDream.App.Input.OutboundInteractionQueue _outboundInteractions = new(); private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; @@ -2917,6 +2918,7 @@ public sealed class GameWindow : IDisposable _magicRuntime?.Reset(); _itemInteractionController?.ClearBusy(); _selection.Reset(); + _outboundInteractions.Clear(); _pendingPostArrivalAction = null; _particleVisibility.Reset(); try @@ -10775,6 +10777,12 @@ public sealed class GameWindow : IDisposable // remote objects. Its outbound movement snapshot is completed here, // before SmartBox dispatch can apply F751/ForcePosition. _localPlayerFrame.AdvanceBeforeNetwork(dt); + // Retail input ordering: movement edges reach MoveToState before a + // later Use/PickUp action from the same input frame. ACE cancels an + // active server MoveTo chain when it receives MoveToState, so sending + // Use directly from Silk's key callback inverted that order and left + // the client walking to a corpse whose server callback was cancelled. + _outboundInteractions.Drain(); _scriptRunner?.PublishTime(_physicsScriptGameTime); if (_liveEntities is { } liveEntities) { @@ -12966,7 +12974,7 @@ public sealed class GameWindow : IDisposable case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp: if (_selection.SelectedObjectId is uint pickupTarget) - SendPickUp(pickupTarget); + _outboundInteractions.Enqueue(() => SendPickUp(pickupTarget)); else _debugVm?.AddToast("Nothing selected"); break; @@ -13004,6 +13012,7 @@ public sealed class GameWindow : IDisposable var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode( Combat.CurrentMode, defaultMode); + _itemInteractionController?.NotifyExplicitCombatModeRequest(); _liveSession.SendChangeCombatMode(nextMode); Combat.SetCombatMode(nextMode); string text = $"Combat mode {nextMode}"; @@ -13237,7 +13246,15 @@ public sealed class GameWindow : IDisposable Console.WriteLine(System.FormattableString.Invariant( $"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})")); _debugVm?.AddToast($"Selected: {label}"); - if (useImmediately) SendUse(guid); + if (useImmediately) + { + // Route world double-click through the same retail + // ItemHolder policy as keyboard Use. This both establishes + // ClientUISystem::groundObject for containers and preserves + // movement-release-before-Use wire ordering. + _outboundInteractions.Enqueue(() => + _itemInteractionController?.ActivateItem(guid)); + } } else { @@ -13259,7 +13276,9 @@ public sealed class GameWindow : IDisposable // the same policy owner used by the toolbar. Keeping a second classifier here // made BF_CORPSE containers look pickupable and sent PutItemInContainer, // which ACE correctly rejected as Stuck (0x0029). - _itemInteractionController?.UseSelectedOrEnterMode(sel); + uint selected = sel; + _outboundInteractions.Enqueue(() => + _itemInteractionController?.UseSelectedOrEnterMode(selected)); } private void SendUse(uint guid) diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index 25acef48..5c0d4751 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -69,6 +69,7 @@ internal sealed class AutoWieldController : IDisposable private PendingSwitch? _pendingSwitch; private PendingCombatSettlement? _pendingCombatSettlement; + private bool _combatTransitionObservedDuringSwitch; private bool _disposed; public bool IsBusy => _pendingSwitch is not null; @@ -109,6 +110,7 @@ internal sealed class AutoWieldController : IDisposable public bool TryWield(ClientObject item) { _pendingCombatSettlement = null; + _combatTransitionObservedDuringSwitch = false; return TryWield(item, EquipMask.None, combatModeAfterWield: null); } @@ -121,6 +123,7 @@ internal sealed class AutoWieldController : IDisposable public bool TryWield(ClientObject item, EquipMask requestedMask) { _pendingCombatSettlement = null; + _combatTransitionObservedDuringSwitch = false; return TryWield(item, requestedMask, combatModeAfterWield: null); } @@ -309,18 +312,32 @@ internal sealed class AutoWieldController : IDisposable _pendingCombatSettlement = new( itemId, readyMode, - CombatSettlementPhase.AwaitingPostWieldMode); + CombatSettlementPhase.AwaitingPostWieldMode, + _combatTransitionObservedDuringSwitch); } + _combatTransitionObservedDuringSwitch = false; } } private void OnCombatModeChanged(CombatMode mode) { + if (_pendingSwitch is { CombatModeAfterWield: { } switchReadyMode } + && mode != switchReadyMode) + { + // ACE's blocker/dequip callbacks can begin their old-stance -> + // Peace shuffle before WieldObject for the replacement arrives. + // Remembering that authoritative transition lets the settlement + // owner distinguish a later ready-mode notice followed by ACE's + // queued trailing Peace from a server that settled directly. + _combatTransitionObservedDuringSwitch = true; + } + if (_pendingCombatSettlement is not { } settlement) return; if (mode == settlement.ReadyMode) { - if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace) + if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace + || settlement.ExpectTrailingPeace) { // ACE selected the correct weapon stance, but its queued // dequip callback still has one trailing Peace notice. @@ -365,7 +382,10 @@ internal sealed class AutoWieldController : IDisposable if (_pendingSwitch is { } pending && (failure.ItemId == pending.BlockingItemId || failure.ItemId == pending.RequestedItemId)) + { _pendingSwitch = null; + _combatTransitionObservedDuringSwitch = false; + } } private void OnObjectRemoved(ClientObject item) @@ -373,7 +393,10 @@ internal sealed class AutoWieldController : IDisposable if (_pendingSwitch is { } pending && (item.ObjectId == pending.BlockingItemId || item.ObjectId == pending.RequestedItemId)) + { _pendingSwitch = null; + _combatTransitionObservedDuringSwitch = false; + } if (_pendingCombatSettlement is { } settlement && item.ObjectId == settlement.ItemId) _pendingCombatSettlement = null; @@ -383,6 +406,20 @@ internal sealed class AutoWieldController : IDisposable { _pendingSwitch = null; _pendingCombatSettlement = null; + _combatTransitionObservedDuringSwitch = false; + } + + /// + /// A user combat-mode command supersedes any ready-mode intent retained by + /// an in-flight AutoWield transaction. This prevents the ACE compatibility + /// settlement from fighting a deliberate request for Peace. + /// + public void NotifyExplicitCombatModeRequest() + { + _pendingCombatSettlement = null; + _combatTransitionObservedDuringSwitch = false; + if (_pendingSwitch is { } pending) + _pendingSwitch = pending with { CombatModeAfterWield = null }; } private EquipMask BestAvailableEquipMask(ClientObject item) @@ -496,6 +533,7 @@ internal sealed class AutoWieldController : IDisposable _disposed = true; _pendingSwitch = null; _pendingCombatSettlement = null; + _combatTransitionObservedDuringSwitch = false; _objects.ObjectMoved -= OnObjectMoved; _objects.ObjectRemoved -= OnObjectRemoved; _objects.MoveRequestFailed -= OnMoveRequestFailed; @@ -514,7 +552,8 @@ internal sealed class AutoWieldController : IDisposable private readonly record struct PendingCombatSettlement( uint ItemId, CombatMode ReadyMode, - CombatSettlementPhase Phase); + CombatSettlementPhase Phase, + bool ExpectTrailingPeace); private enum CombatSettlementPhase { diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 82ba13f0..5ec1d1c9 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -317,6 +317,10 @@ public sealed class ItemInteractionController : IDisposable return _autoWield.TryWield(item, targetMask); } + /// User combat-mode input supersedes AutoWield's retained mode. + public void NotifyExplicitCombatModeRequest() + => _autoWield.NotifyExplicitCombatModeRequest(); + public bool AcquireTarget(uint targetGuid) { if (!IsTargetModeActive || targetGuid == 0) return false; diff --git a/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs b/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs new file mode 100644 index 00000000..e561cb5e --- /dev/null +++ b/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs @@ -0,0 +1,56 @@ +using AcDream.App.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class OutboundInteractionQueueTests +{ + [Fact] + public void Drain_PreservesInputOrder() + { + var queue = new OutboundInteractionQueue(); + var actual = new List(); + + queue.Enqueue(() => actual.Add("use-corpse")); + queue.Enqueue(() => actual.Add("pickup-item")); + + queue.Drain(); + + Assert.Equal(new[] { "use-corpse", "pickup-item" }, actual); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void Drain_ReentrantEnqueueWaitsForNextFrame() + { + var queue = new OutboundInteractionQueue(); + var actual = new List(); + queue.Enqueue(() => + { + actual.Add("first"); + queue.Enqueue(() => actual.Add("next-frame")); + }); + + queue.Drain(); + + Assert.Equal(new[] { "first" }, actual); + Assert.Equal(1, queue.Count); + + queue.Drain(); + + Assert.Equal(new[] { "first", "next-frame" }, actual); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void Clear_DropsPendingSessionActions() + { + var queue = new OutboundInteractionQueue(); + bool invoked = false; + queue.Enqueue(() => invoked = true); + + queue.Clear(); + queue.Drain(); + + Assert.False(invoked); + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 9a142cc4..9622e244 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -655,6 +655,89 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.CombatModeRequests); } + [Fact] + public void BowToWand_inActiveCombat_reassertsMagicAfterAceTrailingPeace() + { + var h = new Harness(); + h.Combat.SetCombatMode(CombatMode.Missile); + const uint bow = 0x50000B93u; + const uint wand = 0x50000B94u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = bow, + Name = "Shortbow", + Type = ItemType.MissileWeapon, + CombatUse = 2, + ValidLocations = EquipMask.MissileWeapon, + }); + h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon); + h.AddContained(wand, item => + { + item.Name = "Training Wand"; + item.Type = ItemType.Caster; + item.CombatUse = 2; + item.ValidLocations = EquipMask.Held; + }); + + Assert.True(h.Controller.ActivateItem(wand)); + Assert.Equal(new[] { (bow, Player, 0) }, h.Puts); + + // ACE begins the old weapon's stance shuffle before confirming the + // replacement wield. + h.Combat.SetCombatMode(CombatMode.Melee); + h.Combat.SetCombatMode(CombatMode.NonCombat); + h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + wand, + Player, + EquipMask.Held)); + h.Combat.SetCombatMode(CombatMode.Magic); + Assert.Empty(h.CombatModeRequests); + + // The queued dequip callback publishes one final Peace after ACE has + // already selected Magic. Reassert only at that ordered boundary. + h.Combat.SetCombatMode(CombatMode.NonCombat); + + Assert.Equal(new[] { CombatMode.Magic }, h.CombatModeRequests); + } + + [Fact] + public void ExplicitCombatRequest_cancelsPendingAutoWieldSettlement() + { + var h = new Harness(); + h.Combat.SetCombatMode(CombatMode.Missile); + const uint bow = 0x50000B95u; + const uint wand = 0x50000B96u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = bow, + Type = ItemType.MissileWeapon, + ValidLocations = EquipMask.MissileWeapon, + }); + h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon); + h.AddContained(wand, item => + { + item.Type = ItemType.Caster; + item.ValidLocations = EquipMask.Held; + }); + + Assert.True(h.Controller.ActivateItem(wand)); + h.Combat.SetCombatMode(CombatMode.NonCombat); + h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerWield( + wand, + Player, + EquipMask.Held)); + h.Combat.SetCombatMode(CombatMode.Magic); + + h.Controller.NotifyExplicitCombatModeRequest(); + h.Combat.SetCombatMode(CombatMode.NonCombat); + + Assert.Empty(h.CombatModeRequests); + } + [Fact] public void ActiveWeaponReplacement_serverSettlesDirectly_doesNotReassertLaterPeace() { @@ -678,7 +761,6 @@ public sealed class ItemInteractionControllerTests }); Assert.True(h.Controller.ActivateItem(bow)); - h.Combat.SetCombatMode(CombatMode.Melee); h.Objects.MoveItem(wand, Player, 0, EquipMask.None); Assert.True(h.Objects.ApplyConfirmedServerWield( bow, Player, EquipMask.MissileWeapon));