fix(items): preserve wand stance and corpse use order
Keep active-combat AutoWield intent through ACE's pre-wield transition and queued trailing peace notice, while allowing explicit combat input to supersede it. Queue one-shot item interactions until the frame's movement edge has been serialized, so a movement release cannot cancel ACE's server-side corpse approach callback. Release build succeeds and all 5,890 runnable tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
0392c6d721
commit
e6dd8bf6fa
10 changed files with 303 additions and 22 deletions
|
|
@ -516,15 +516,15 @@ optimistic pre-confirm projection instead of treating equipment as backpack
|
||||||
contents. The connected gate completed NonCombat → Missile → NonCombat →
|
contents. The connected gate completed NonCombat → Missile → NonCombat →
|
||||||
Missile, including a Jump press, without a server rejection.
|
Missile, including a Jump press, without a server rejection.
|
||||||
|
|
||||||
**2026-07-17 active-switch follow-up:** A connected Magic wand -> missile
|
**2026-07-17 active-switch follow-up:** Connected primary-weapon replacement
|
||||||
weapon trace exposed ACE's queued `Magic -> Melee -> NonCombat -> Missile ->
|
traces exposed ACE's queued old-stance transition and trailing `NonCombat`.
|
||||||
NonCombat` tail. `AutoWieldController` now retains the desired ready mode
|
`AutoWieldController` now retains the desired ready mode through every
|
||||||
through every confirmed blocker move and, only for a transaction that began
|
confirmed blocker move and, only for a transaction that began in active
|
||||||
in active combat, arms settlement on authoritative `WieldObject`. An already-
|
combat, arms settlement on authoritative `WieldObject`. It recognizes both a
|
||||||
correct first mode notice clears it; ACE's observed `NonCombat -> Missile ->
|
pre-wield transition followed by `ready -> NonCombat` and a wholly post-wield
|
||||||
NonCombat` sequence sends the normal mode request from its trailing notice,
|
`NonCombat -> ready -> NonCombat` tail, then sends the normal mode request from
|
||||||
after the queued callback that caused that notice.
|
the trailing notice after the queued callback that caused it. An explicit user
|
||||||
This keeps the missile bar present while retaining
|
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.
|
ACE ownership of every state and motion; peace-mode switching is unchanged.
|
||||||
The narrow ACE compatibility difference is registered as AP-118.
|
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
|
retail `SetGroundObject` does. This removes the stale range watcher whose close
|
||||||
Use canceled ACE's active MoveTo chain before the new corpse could open.
|
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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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-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-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-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)
|
## 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,11 +116,20 @@ on server-confirmed WieldObject(item):
|
||||||
retain the ready mode implied by the confirmed equip location
|
retain the ready mode implied by the confirmed equip location
|
||||||
|
|
||||||
on combat-mode notices after confirmed WieldObject:
|
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
|
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
|
clear the retained mode
|
||||||
request that ready mode from ACE's trailing NonCombat notice
|
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):
|
on PrivateUpdatePropertyInt(property = CombatMode, value):
|
||||||
if value is exactly NonCombat, Melee, Missile, or Magic:
|
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
|
acdream therefore preserves the user's desired ready mode only when AutoWield
|
||||||
begins in active combat. Authoritative `WieldObject` arms settlement. A server
|
begins in active combat. Authoritative `WieldObject` arms settlement. A server
|
||||||
whose first subsequent notice is already the desired mode needs no request.
|
that emitted no blocker transition and immediately reports the desired mode
|
||||||
Local ACE instead emits `NonCombat -> desired mode -> NonCombat`; the client
|
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,
|
sends one normal `ChangeCombatMode` request from that trailing notice,
|
||||||
necessarily after the queued callback that caused it. All resulting state and animation remain server-
|
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
|
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.
|
compatibility adaptation is recorded as AP-118.
|
||||||
|
|
||||||
The request is not complete when `GetAndWieldItem` is merely sent. Retail
|
The request is not complete when `GetAndWieldItem` is merely sent. Retail
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ Named Sept-2013 retail symbols:
|
||||||
- `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0`
|
- `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0`
|
||||||
- `ItemHolder::AttemptToPlaceInContainer @ 0x00588140`
|
- `ItemHolder::AttemptToPlaceInContainer @ 0x00588140`
|
||||||
- `ItemHolder::UseObject @ 0x00588A80`
|
- `ItemHolder::UseObject @ 0x00588A80`
|
||||||
|
- `CommandInterpreter::SendMovementEvent @ 0x006B4680`
|
||||||
|
|
||||||
The exact client dispatch for server event `CloseGroundContainer (0x0052)` is
|
The exact client dispatch for server event `CloseGroundContainer (0x0052)` is
|
||||||
at `0x0055B187` in `acclient_2013_pseudo_c.txt`.
|
at `0x0055B187` in `acclient_2013_pseudo_c.txt`.
|
||||||
|
|
@ -40,6 +41,10 @@ Cross-references:
|
||||||
`GameActionNoLongerViewingContents`, `Container.SendInventory`, and
|
`GameActionNoLongerViewingContents`, `Container.SendInventory`, and
|
||||||
`Container.FinishClose` confirm the authoritative packet order and that ACE
|
`Container.FinishClose` confirm the authoritative packet order and that ACE
|
||||||
can send `ViewContents` for the root followed by its nested containers.
|
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
|
- holtburger protocol `inventory/events.rs` and `inventory/actions.rs` confirm
|
||||||
the exact wire shapes. Its world inventory handler independently models open
|
the exact wire shapes. Its world inventory handler independently models open
|
||||||
container contents as temporary preview retention and retires that preview on
|
container contents as temporary preview retention and retires that preview on
|
||||||
|
|
@ -71,6 +76,13 @@ thumb, and arrow sprites from LayoutDesc `0x2100003E`.
|
||||||
## Pseudocode
|
## Pseudocode
|
||||||
|
|
||||||
```text
|
```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):
|
UseWorldObject(objectId):
|
||||||
decision = ItemHolder policy
|
decision = ItemHolder policy
|
||||||
send Use(objectId)
|
send Use(objectId)
|
||||||
|
|
@ -210,6 +222,11 @@ DragOwnedItemToExternalContainer(itemId, placement, splitAmount):
|
||||||
- `ItemInteractionController` remains the only interpreter of retail item-use
|
- `ItemInteractionController` remains the only interpreter of retail item-use
|
||||||
and placement policy. It gains explicit adapters for `SetGroundObject` and
|
and placement policy. It gains explicit adapters for `SetGroundObject` and
|
||||||
`PlaceInContainer`; panels do not reimplement those decisions.
|
`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
|
- `GameWindow` only creates the lifecycle/UI bindings and supplies network
|
||||||
delegates. No external-container feature body lives there.
|
delegates. No external-container feature body lives there.
|
||||||
|
|
||||||
|
|
|
||||||
41
src/AcDream.App/Input/OutboundInteractionQueue.cs
Normal file
41
src/AcDream.App/Input/OutboundInteractionQueue.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
namespace AcDream.App.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orders input-originated item interactions after the local player's movement
|
||||||
|
/// edge has been serialized for the same frame, but before inbound network
|
||||||
|
/// dispatch.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OutboundInteractionQueue
|
||||||
|
{
|
||||||
|
private readonly Queue<Action> _pending = new();
|
||||||
|
|
||||||
|
public int Count => _pending.Count;
|
||||||
|
|
||||||
|
public void Enqueue(Action action)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(action);
|
||||||
|
_pending.Enqueue(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void Drain()
|
||||||
|
{
|
||||||
|
int count = _pending.Count;
|
||||||
|
while (count-- > 0)
|
||||||
|
_pending.Dequeue().Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear() => _pending.Clear();
|
||||||
|
}
|
||||||
|
|
@ -165,6 +165,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
||||||
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
|
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
|
||||||
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
|
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.Input.RetailLocalPlayerFrameController _localPlayerFrame;
|
||||||
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
|
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
|
||||||
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||||
|
|
@ -2917,6 +2918,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_magicRuntime?.Reset();
|
_magicRuntime?.Reset();
|
||||||
_itemInteractionController?.ClearBusy();
|
_itemInteractionController?.ClearBusy();
|
||||||
_selection.Reset();
|
_selection.Reset();
|
||||||
|
_outboundInteractions.Clear();
|
||||||
_pendingPostArrivalAction = null;
|
_pendingPostArrivalAction = null;
|
||||||
_particleVisibility.Reset();
|
_particleVisibility.Reset();
|
||||||
try
|
try
|
||||||
|
|
@ -10775,6 +10777,12 @@ public sealed class GameWindow : IDisposable
|
||||||
// remote objects. Its outbound movement snapshot is completed here,
|
// remote objects. Its outbound movement snapshot is completed here,
|
||||||
// before SmartBox dispatch can apply F751/ForcePosition.
|
// before SmartBox dispatch can apply F751/ForcePosition.
|
||||||
_localPlayerFrame.AdvanceBeforeNetwork(dt);
|
_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);
|
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||||
if (_liveEntities is { } liveEntities)
|
if (_liveEntities is { } liveEntities)
|
||||||
{
|
{
|
||||||
|
|
@ -12966,7 +12974,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
|
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
|
||||||
if (_selection.SelectedObjectId is uint pickupTarget)
|
if (_selection.SelectedObjectId is uint pickupTarget)
|
||||||
SendPickUp(pickupTarget);
|
_outboundInteractions.Enqueue(() => SendPickUp(pickupTarget));
|
||||||
else
|
else
|
||||||
_debugVm?.AddToast("Nothing selected");
|
_debugVm?.AddToast("Nothing selected");
|
||||||
break;
|
break;
|
||||||
|
|
@ -13004,6 +13012,7 @@ public sealed class GameWindow : IDisposable
|
||||||
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
|
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
|
||||||
Combat.CurrentMode,
|
Combat.CurrentMode,
|
||||||
defaultMode);
|
defaultMode);
|
||||||
|
_itemInteractionController?.NotifyExplicitCombatModeRequest();
|
||||||
_liveSession.SendChangeCombatMode(nextMode);
|
_liveSession.SendChangeCombatMode(nextMode);
|
||||||
Combat.SetCombatMode(nextMode);
|
Combat.SetCombatMode(nextMode);
|
||||||
string text = $"Combat mode {nextMode}";
|
string text = $"Combat mode {nextMode}";
|
||||||
|
|
@ -13237,7 +13246,15 @@ public sealed class GameWindow : IDisposable
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
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})"));
|
$"[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}");
|
_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
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -13259,7 +13276,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// the same policy owner used by the toolbar. Keeping a second classifier here
|
// the same policy owner used by the toolbar. Keeping a second classifier here
|
||||||
// made BF_CORPSE containers look pickupable and sent PutItemInContainer,
|
// made BF_CORPSE containers look pickupable and sent PutItemInContainer,
|
||||||
// which ACE correctly rejected as Stuck (0x0029).
|
// which ACE correctly rejected as Stuck (0x0029).
|
||||||
_itemInteractionController?.UseSelectedOrEnterMode(sel);
|
uint selected = sel;
|
||||||
|
_outboundInteractions.Enqueue(() =>
|
||||||
|
_itemInteractionController?.UseSelectedOrEnterMode(selected));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SendUse(uint guid)
|
private void SendUse(uint guid)
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
|
|
||||||
private PendingSwitch? _pendingSwitch;
|
private PendingSwitch? _pendingSwitch;
|
||||||
private PendingCombatSettlement? _pendingCombatSettlement;
|
private PendingCombatSettlement? _pendingCombatSettlement;
|
||||||
|
private bool _combatTransitionObservedDuringSwitch;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public bool IsBusy => _pendingSwitch is not null;
|
public bool IsBusy => _pendingSwitch is not null;
|
||||||
|
|
@ -109,6 +110,7 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
public bool TryWield(ClientObject item)
|
public bool TryWield(ClientObject item)
|
||||||
{
|
{
|
||||||
_pendingCombatSettlement = null;
|
_pendingCombatSettlement = null;
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
return TryWield(item, EquipMask.None, combatModeAfterWield: null);
|
return TryWield(item, EquipMask.None, combatModeAfterWield: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +123,7 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
public bool TryWield(ClientObject item, EquipMask requestedMask)
|
public bool TryWield(ClientObject item, EquipMask requestedMask)
|
||||||
{
|
{
|
||||||
_pendingCombatSettlement = null;
|
_pendingCombatSettlement = null;
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
return TryWield(item, requestedMask, combatModeAfterWield: null);
|
return TryWield(item, requestedMask, combatModeAfterWield: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,18 +312,32 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
_pendingCombatSettlement = new(
|
_pendingCombatSettlement = new(
|
||||||
itemId,
|
itemId,
|
||||||
readyMode,
|
readyMode,
|
||||||
CombatSettlementPhase.AwaitingPostWieldMode);
|
CombatSettlementPhase.AwaitingPostWieldMode,
|
||||||
|
_combatTransitionObservedDuringSwitch);
|
||||||
}
|
}
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnCombatModeChanged(CombatMode mode)
|
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)
|
if (_pendingCombatSettlement is not { } settlement)
|
||||||
return;
|
return;
|
||||||
if (mode == settlement.ReadyMode)
|
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
|
// ACE selected the correct weapon stance, but its queued
|
||||||
// dequip callback still has one trailing Peace notice.
|
// dequip callback still has one trailing Peace notice.
|
||||||
|
|
@ -365,7 +382,10 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
if (_pendingSwitch is { } pending
|
if (_pendingSwitch is { } pending
|
||||||
&& (failure.ItemId == pending.BlockingItemId
|
&& (failure.ItemId == pending.BlockingItemId
|
||||||
|| failure.ItemId == pending.RequestedItemId))
|
|| failure.ItemId == pending.RequestedItemId))
|
||||||
|
{
|
||||||
_pendingSwitch = null;
|
_pendingSwitch = null;
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnObjectRemoved(ClientObject item)
|
private void OnObjectRemoved(ClientObject item)
|
||||||
|
|
@ -373,7 +393,10 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
if (_pendingSwitch is { } pending
|
if (_pendingSwitch is { } pending
|
||||||
&& (item.ObjectId == pending.BlockingItemId
|
&& (item.ObjectId == pending.BlockingItemId
|
||||||
|| item.ObjectId == pending.RequestedItemId))
|
|| item.ObjectId == pending.RequestedItemId))
|
||||||
|
{
|
||||||
_pendingSwitch = null;
|
_pendingSwitch = null;
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
|
}
|
||||||
if (_pendingCombatSettlement is { } settlement
|
if (_pendingCombatSettlement is { } settlement
|
||||||
&& item.ObjectId == settlement.ItemId)
|
&& item.ObjectId == settlement.ItemId)
|
||||||
_pendingCombatSettlement = null;
|
_pendingCombatSettlement = null;
|
||||||
|
|
@ -383,6 +406,20 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
{
|
{
|
||||||
_pendingSwitch = null;
|
_pendingSwitch = null;
|
||||||
_pendingCombatSettlement = null;
|
_pendingCombatSettlement = null;
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void NotifyExplicitCombatModeRequest()
|
||||||
|
{
|
||||||
|
_pendingCombatSettlement = null;
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
|
if (_pendingSwitch is { } pending)
|
||||||
|
_pendingSwitch = pending with { CombatModeAfterWield = null };
|
||||||
}
|
}
|
||||||
|
|
||||||
private EquipMask BestAvailableEquipMask(ClientObject item)
|
private EquipMask BestAvailableEquipMask(ClientObject item)
|
||||||
|
|
@ -496,6 +533,7 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_pendingSwitch = null;
|
_pendingSwitch = null;
|
||||||
_pendingCombatSettlement = null;
|
_pendingCombatSettlement = null;
|
||||||
|
_combatTransitionObservedDuringSwitch = false;
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||||
_objects.MoveRequestFailed -= OnMoveRequestFailed;
|
_objects.MoveRequestFailed -= OnMoveRequestFailed;
|
||||||
|
|
@ -514,7 +552,8 @@ internal sealed class AutoWieldController : IDisposable
|
||||||
private readonly record struct PendingCombatSettlement(
|
private readonly record struct PendingCombatSettlement(
|
||||||
uint ItemId,
|
uint ItemId,
|
||||||
CombatMode ReadyMode,
|
CombatMode ReadyMode,
|
||||||
CombatSettlementPhase Phase);
|
CombatSettlementPhase Phase,
|
||||||
|
bool ExpectTrailingPeace);
|
||||||
|
|
||||||
private enum CombatSettlementPhase
|
private enum CombatSettlementPhase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,10 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
return _autoWield.TryWield(item, targetMask);
|
return _autoWield.TryWield(item, targetMask);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
|
||||||
|
public void NotifyExplicitCombatModeRequest()
|
||||||
|
=> _autoWield.NotifyExplicitCombatModeRequest();
|
||||||
|
|
||||||
public bool AcquireTarget(uint targetGuid)
|
public bool AcquireTarget(uint targetGuid)
|
||||||
{
|
{
|
||||||
if (!IsTargetModeActive || targetGuid == 0) return false;
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
||||||
|
|
|
||||||
|
|
@ -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<string>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -655,6 +655,89 @@ public sealed class ItemInteractionControllerTests
|
||||||
Assert.Empty(h.CombatModeRequests);
|
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]
|
[Fact]
|
||||||
public void ActiveWeaponReplacement_serverSettlesDirectly_doesNotReassertLaterPeace()
|
public void ActiveWeaponReplacement_serverSettlesDirectly_doesNotReassertLaterPeace()
|
||||||
{
|
{
|
||||||
|
|
@ -678,7 +761,6 @@ public sealed class ItemInteractionControllerTests
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(h.Controller.ActivateItem(bow));
|
Assert.True(h.Controller.ActivateItem(bow));
|
||||||
h.Combat.SetCombatMode(CombatMode.Melee);
|
|
||||||
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
|
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
|
||||||
Assert.True(h.Objects.ApplyConfirmedServerWield(
|
Assert.True(h.Objects.ApplyConfirmedServerWield(
|
||||||
bow, Player, EquipMask.MissileWeapon));
|
bow, Player, EquipMask.MissileWeapon));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue