From f9736ece6cb17743eaca7a940be4b1b7d34af87b Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Jul 2026 05:51:51 +0200 Subject: [PATCH] fix(runtime): restore interaction completion ownership Preserve prepublication local motion completion, require the PartArray enter-world lifecycle port, and balance deferred Use busy ownership across dispatch and cancellation. Reconcile the completed GameWindow connected gates and add regression coverage. Co-authored-by: Codex --- AGENTS.md | 14 ++- CLAUDE.md | 14 ++- docs/ISSUES.md | 40 +++++- docs/architecture/code-structure.md | 8 +- docs/plans/2026-04-11-roadmap.md | 5 +- docs/plans/2026-05-12-milestones.md | 5 +- ...23-retail-use-busy-ownership-pseudocode.md | 117 ++++++++++++++++++ memory/project_gamewindow_decomposition.md | 16 +-- .../InteractionRetainedUiComposition.cs | 5 +- .../InteractionUiRuntimeSources.cs | 12 ++ .../LivePresentationComposition.cs | 5 +- .../Input/LocalPlayerRuntimeState.cs | 63 +++++++++- src/AcDream.App/Input/PlayerModeController.cs | 9 ++ .../SelectionInteractionController.cs | 28 ++++- .../LiveAnimationPresentationContext.cs | 10 +- .../UI/ItemInteractionController.cs | 103 +++++++++++++-- .../LiveEntityPartArrayEnterWorldPort.cs | 22 ++++ .../World/LiveEntityPresentationController.cs | 13 +- .../Input/LocalPlayerControllerSlotTests.cs | 57 +++++++++ .../SelectionInteractionControllerTests.cs | 106 +++++++++++++++- .../Physics/RemotePhysicsUpdaterTests.cs | 1 + .../Physics/RemoteTeleportControllerTests.cs | 1 + .../UI/ItemInteractionControllerTests.cs | 68 +++++++++- .../World/LiveEntityLifecycleStressTests.cs | 1 + .../LiveEntityPresentationControllerTests.cs | 17 +-- ...tityProjectionWithdrawalControllerTests.cs | 3 +- 26 files changed, 675 insertions(+), 68 deletions(-) create mode 100644 docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md create mode 100644 src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs create mode 100644 tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs diff --git a/AGENTS.md b/AGENTS.md index d21a8163..9d67886c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,8 +115,9 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the automated structural closeout passes 293 focused tests, -3,451 App tests / 3 skips, and 7,823 complete-solution tests / 5 skips. +Release builds; the structural closeout plus verified interaction/recall +regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,834 +complete-solution tests / 5 skips. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated @@ -125,12 +126,13 @@ composition/callback shell, down 14,101 lines (89.7%) from baseline. Ordered startup, update/render frame graphs, native/input callbacks, settings, session/reset wiring, canonical checkpoint capture, and retryable shutdown are owned by focused typed collaborators without a service locator or stored -window back-reference. All 293 focused tests, App Release 3,451/3, the complete -Release suite 7,823/5, connected lifecycle/reconnect, two fresh-process +window back-reference. All 293 focused tests, App Release 3,462/3, the complete +Release suite 7,834/5, connected lifecycle/reconnect, two fresh-process canonical nine-stop soaks, three corrected-diff reviews, and the exact Slice-7 framebuffer comparison pass. Issue #232 is closed without loosening process -limits. Only the user's final connected visual matrix remains before M4 feature -bodies resume. +limits. The user's connected visual matrix passed 2026-07-23, including the +post-closeout radar correction; the structural campaign is complete and M4 +feature bodies may resume after any active regression gate. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's diff --git a/CLAUDE.md b/CLAUDE.md index 58d5cde5..dcffe858 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,8 +113,9 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the automated structural closeout passes 293 focused tests, -3,451 App tests / 3 skips, and 7,823 complete-solution tests / 5 skips. +Release builds; the structural closeout plus verified interaction/recall +regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,834 +complete-solution tests / 5 skips. **Structural prerequisite before new M4 subsystem work:** all eight behavior-preserving `GameWindow` decomposition slices and the automated @@ -123,12 +124,13 @@ composition/callback shell, down 14,101 lines (89.7%) from baseline. Ordered startup, update/render frame graphs, native/input callbacks, settings, session/reset wiring, canonical checkpoint capture, and retryable shutdown are owned by focused typed collaborators without a service locator or stored -window back-reference. All 293 focused tests, App Release 3,451/3, the complete -Release suite 7,823/5, connected lifecycle/reconnect, two fresh-process +window back-reference. All 293 focused tests, App Release 3,462/3, the complete +Release suite 7,834/5, connected lifecycle/reconnect, two fresh-process canonical nine-stop soaks, three corrected-diff reviews, and the exact Slice-7 framebuffer comparison pass. Issue #232 is closed without loosening process -limits. Only the user's final connected visual matrix remains before M4 feature -bodies resume. +limits. The user's connected visual matrix passed 2026-07-23, including the +post-closeout radar correction; the structural campaign is complete and M4 +feature bodies may resume after any active regression gate. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e927a0de..2355586d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -30,9 +30,9 @@ What does NOT go here: automated closeout are complete. The 1,622-line native shell has typed startup, frame, callback, and shutdown owners; 293 focused tests, the 7,823/5 Release suite, the lifecycle/reconnect route, two canonical - nine-stop soaks, and the Slice-7 framebuffer comparison pass. Only the - user's final connected visual matrix remains before the campaign is marked - complete in [`docs/architecture/code-structure.md`](architecture/code-structure.md). + nine-stop soaks, and the Slice-7 framebuffer comparison pass. The user's + connected visual matrix passed 2026-07-23, so the campaign is complete in + [`docs/architecture/code-structure.md`](architecture/code-structure.md). - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its connected performance, lifetime, and unattended portal routes pass. - **Carried behavior debt:** `#153` far-teleport unstreamed-edge arrival and @@ -7600,6 +7600,40 @@ outdoors at the angle that previously erased it. # Recently closed +## #234 — [DONE 2026-07-23] Cancelled close-range Use could strand the busy cursor + +**Closed:** 2026-07-23 +**Severity:** HIGH +**Component:** interaction / retained UI / local approach + +**Resolution:** `ItemInteractionController` acquired retail's shared busy +reference before `SelectionInteractionController` completed a close-range +turn. If that pre-wire approach was cancelled, failed to install, lost its +session, or crossed an entity-incarnation boundary, no Use packet existed and +therefore ACE could never return the `UseDone` needed to release the count. + +An explicit, generation-bound `ItemUseRequestReservation` now owns that +boundary. Successful wire dispatch transfers ownership to authoritative +`UseDone`; every pre-dispatch cancellation path releases the reservation +locally and idempotently. Session reset invalidates late reservations so an +old callback cannot decrement a new session. Focused tests pin cancellation, +failed movement installation, stale identity, synchronous turn completion, +successful dispatch, and late post-reset resolution. + +The connected report itself completed normally: the log contained the +successful NPC Use and `UseDone(0)`, and a managed heap snapshot afterward +showed busy count zero with no pending inventory or auto-wield transaction. +That evidence separated an ordinary in-flight rejection from the real latent +pre-wire leak. + +**Connected gate:** Passed 2026-07-23. Cancelling the close-range turn no +longer strands the busy cursor, and the next NPC/item Use succeeds normally. + +**Research:** +[`docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md`](research/2026-07-23-retail-use-busy-ownership-pseudocode.md). + +--- + ## #229 — [DONE 2026-07-20] Initial login revealed an incomplete world **Closed:** 2026-07-20 diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 96d72b51..40ea38a6 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -2,8 +2,8 @@ **Status:** Living document. Created 2026-05-16; implementation reconciliation completed 2026-07-21; Slices 1–8 and their automated closeout landed by -2026-07-22. The final connected visual matrix remains before new M4 subsystems -enter the App layer. +2026-07-22. The connected visual matrix passed 2026-07-23; the campaign is +complete and new M4 subsystems may enter through the extracted owners. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction sequence from today's reality (one 15,723-line `GameWindow.cs` at the @@ -758,7 +758,9 @@ with graceful exits; deterministic framebuffer captures preserve accepted Slice 7 geometry, UI/paperdoll layering, private viewports, depth/alpha, and reveal. The clean solution build retains only the 17 test-project warnings tracked by #228, and all three final corrected-diff reviews are clean. Only the -user's final connected visual matrix remains. +user's connected visual matrix passed 2026-07-23, including first-login radar, +movement/combat, shared retained panels, recall/portal presentation, and +graceful reconnect. ### 4.4 Exit criteria diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c4fad285..48ebd4bf 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the final `GameWindow` visual handoff.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md) and their automated closeout are complete; only the user's connected visual matrix remains. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-23. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -116,7 +116,8 @@ snapshots and closes #232 through two clean fresh-process nine-stop routes. The shell is 1,622 raw lines. All 293 focused tests, App Release 3,451/3, the complete Release suite 7,823/5, connected lifecycle/reconnect, both canonical soaks, three corrected-diff reviews, and the exact Slice-7 framebuffer -comparison pass. Only the user's final connected visual matrix remains. +comparison pass. The user's connected visual matrix passed 2026-07-23 and the +structural program is complete. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 98eb1d46..0ebf0285 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -74,8 +74,9 @@ without loosening the process thresholds. The automated Slice 8 closeout is complete: `GameWindow` is 1,622 raw lines; 293 focused tests, App Release 3,451/3, the complete Release suite 7,823/5, lifecycle/reconnect, both canonical soaks, all three corrected-diff reviews, and the Slice-7 framebuffer -comparison pass. Only the user's final connected visual matrix remains before -new M4 feature bodies begin. +comparison pass. The user's connected visual matrix passed 2026-07-23, +including the post-closeout radar correction, so new M4 feature bodies may +begin through the extracted owners. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md b/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md new file mode 100644 index 00000000..dce2a361 --- /dev/null +++ b/docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md @@ -0,0 +1,117 @@ +# Retail Use busy-reference ownership + +Date: 2026-07-23 + +Scope: the retained UI busy cursor and the +`You can only move or use one item at a time` gate while an object/NPC Use +crosses acdream's close-range turn/approach boundary. + +## Retail oracle + +Named Sept-2013 retail symbols: + +- `ItemHolder::UseObject @ 0x00588A80` +- `ClientUISystem::Handle_Item__UseDone @ 0x00564900` +- `CM_Item::DispatchUI_UseDone @ 0x006A8510` +- `ClientUISystem::UsageCallback @ 0x00565B20` + +`ItemHolder::UseObject` checks the 200 ms throttle and +`IsPlayerReadyToMakeInventoryRequest`, emits `CM_Inventory::Event_UseEvent`, +increments `ClientUISystem::m_cBusy`, and then calls +`CPlayerSystem::UsingItem`. The confirmation callback has the same order. +`CM_Item::DispatchUI_UseDone` routes event `0x01C7` to +`Handle_Item__UseDone`, which decrements exactly one busy reference and updates +the cursor when the count reaches zero. + +Therefore the invariant is: + +```text +one accepted Use request that reached the wire + owns one busy reference + until one authoritative UseDone arrives +``` + +There is no retail timeout and movement cancellation is not a substitute for +`UseDone` after dispatch. + +## ACE and protocol cross-reference + +- ACE `GameActionUseItem` and `Player_Use.TryUseItem` confirm that the Use + request owns the server-side operation and that every completed/rejected + operation returns `UseDone`. +- ACE `Player_Move.CreateMoveToChain` confirms that the server may approach a + distant target before executing that operation. +- holtburger `inventory/actions.rs` and `inventory/events.rs` independently + confirm the one Use action / one UseDone event protocol pair. + +The existing close-range compatibility seam is recorded as divergence AD-27: +acdream may finish its local turn before emitting Use. That seam creates a +real pre-wire interval which retail does not have, so it needs explicit +ownership rather than pretending a server completion is already owed. + +## Ported ownership pseudocode + +```text +BeginAcceptedUse(object): + reservation = BusyOwner.Reserve() + busyCount += 1 + RequestUse(object, reservation) + +RequestUse(object, reservation): + if session/identity/approach is invalid: + reservation.CancelBeforeDispatch() + return + + if a close-range turn must finish first: + pending = (exact object incarnation, approach token, reservation) + if movement installation fails: + pending.reservation.CancelBeforeDispatch() + return + + if SendUse(object) succeeds: + reservation.MarkDispatched() + else: + reservation.CancelBeforeDispatch() + +OnNaturalApproachCompletion(pending): + if pending incarnation is stale: + pending.reservation.CancelBeforeDispatch() + else if SendUse(pending.object) succeeds: + pending.reservation.MarkDispatched() + else: + pending.reservation.CancelBeforeDispatch() + +OnApproachCancelledOrTargetRemoved(pending): + pending.reservation.CancelBeforeDispatch() + +reservation.MarkDispatched(): + // Ownership transfers to the server completion. Keep busyCount unchanged. + make later local cancellation inert + +reservation.CancelBeforeDispatch(): + // No packet exists, therefore no UseDone can arrive for this reservation. + busyCount -= 1 + make duplicate cancellation inert + +OnUseDone(error): + busyCount -= 1 + if busyCount == 0: + update cursor + +OnSessionReset(): + invalidate the reservation generation + busyCount = 0 +``` + +The reservation is idempotent and generation-bound. A late callback from a +retired session cannot decrement a new session's busy reference. + +## Regression evidence + +The connected trace showed successful NPC Use packets and matching +`UseDone(0)`. A managed heap snapshot after the final completion showed +`ItemInteractionController._busyCount == 0`, no pending inventory request, and +no pending auto-wield transaction. The permanent failure path was instead the +pre-wire close-range branch: it incremented busy before local movement, but +cancellation or stale identity discarded the pending send, leaving no packet +and therefore no possible `UseDone`. diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index e9fea187..c6846406 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -6,8 +6,8 @@ All eight behavior-preserving App ownership slices and their automated closeout are complete. `GameWindow.cs` moved from the 2026-07-21 baseline of 15,723 lines / 278 fields / 205 methods to a 1,622-line native composition/callback shell: 14,101 lines (89.7%) were removed without changing -accepted gameplay or rendering behavior. Only the user's final connected -visual matrix remains before the campaign is marked complete. +accepted gameplay or rendering behavior. The user's final connected visual +matrix passed 2026-07-23; the campaign is complete. | Slice | Ownership moved out | Closeout size | |---|---|---:| @@ -209,12 +209,12 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Run the final connected visual matrix: first login/radar; movement, mouse, -resize, focus, and combat; shared inventory/skills/spellbook panel; outdoor, -building, dungeon, portal, paperdoll, particle, and alpha presentation; then -graceful close and fresh reconnect. After user confirmation, mark Checkpoint L -and the campaign complete and resume M4. Full `GameEntity` aggregation remains -a separate migration and must not be folded into the visual handoff. +Resume M4 feature work through the extracted owners. The connected matrix +passed first login/radar; movement, mouse, resize, focus, and combat; the shared +inventory/skills/spellbook panel; outdoor, building, dungeon, recall/portal, +paperdoll, and particle presentation; then graceful reconnect. Full +`GameEntity` aggregation remains a separate migration and must not be folded +into ordinary M4 work. ## Do not retry diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 94389ba7..cf127ad3 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -287,7 +287,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory return new ItemInteractionController( d.Objects, playerGuid: () => d.PlayerIdentity.ServerGuid, - sendUse: selection.SendUse, + sendUse: null, sendExamine: guid => session.CurrentSession?.SendAppraise(guid), sendUseWithTarget: (source, target) => session.CurrentSession?.SendUseWithTarget(source, target), @@ -332,7 +332,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory requestExternalContainer: guid => { d.ExternalContainers.RequestOpen(guid); - }); + }, + requestUse: selection.RequestUse); } public RetainedUiComposition CreateRetainedUi( diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 3d303a5b..46c19073 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -169,6 +169,18 @@ internal sealed class DeferredSelectionUiAuthority target?.SendUse(guid); } + public void RequestUse(uint guid, ItemUseRequestReservation reservation) + { + ArgumentNullException.ThrowIfNull(reservation); + SelectionInteractionController? target; + lock (_gate) + target = !_deactivated ? _interactions : null; + if (target is null) + reservation.CancelBeforeDispatch(); + else + target.RequestUse(guid, reservation); + } + public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) { SelectionInteractionController? target; diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 6dc3af07..6e1be6ea 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -460,10 +460,11 @@ internal sealed class LivePresentationCompositionPhase liveEntities, d.PhysicsEngine.ShadowObjects, entityEffects.PlayTypedFromHiddenTransition, + new LiveEntityPartArrayEnterWorldPort( + partArrayLifecycle.HandleEnterWorld), equippedLease.Resource.SetDirectChildrenNoDraw, d.MotionBindings.ClearTargetForHiddenEntity, - d.WorldOrigin.GetCenter, - partArrayLifecycle.HandleEnterWorld), + d.WorldOrigin.GetCenter), static value => value.Dispose()); var remoteShadowPlacement = new RemoteShadowPlacementSynchronizer( d.RemotePhysicsUpdater, diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs index 14e10965..da50fc5c 100644 --- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs +++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs @@ -21,13 +21,74 @@ internal interface ILocalPlayerControllerSource PlayerMovementController? Controller { get; } } +internal interface ILocalPlayerMotionSource +{ + MotionInterpreter? Motion { get; } +} + /// /// The one mutable local movement-controller slot. Player-mode lifecycle owns /// assignment; update and presentation owners receive the read-only seam. /// -internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource +internal sealed class LocalPlayerControllerSlot + : ILocalPlayerControllerSource, + ILocalPlayerMotionSource { + private PlayerMovementController? _preparingMotionOwner; + public PlayerMovementController? Controller { get; set; } + + /// + /// The motion owner visible to the local PartArray completion relay. + /// During player-mode construction this is the fully animation-bound + /// candidate controller; all other consumers continue to see only the + /// committed . + /// + MotionInterpreter? ILocalPlayerMotionSource.Motion => + _preparingMotionOwner?.Motion ?? Controller?.Motion; + + /// + /// Opens the narrow construction-time ownership seam required by retail's + /// CPhysicsObj/PartArray lifetime. Initial placement synchronously queues + /// and completes StopCompletely, so its MotionDone relay must reach the + /// candidate interpreter before the complete controller is published. + /// + public IDisposable BeginMotionPreparation(PlayerMovementController controller) + { + ArgumentNullException.ThrowIfNull(controller); + if (_preparingMotionOwner is not null) + { + throw new InvalidOperationException( + "A local player motion owner is already being prepared."); + } + + _preparingMotionOwner = controller; + return new MotionPreparation(this, controller); + } + + private void EndMotionPreparation(PlayerMovementController controller) + { + if (!ReferenceEquals(_preparingMotionOwner, controller)) + { + throw new InvalidOperationException( + "The local player motion preparation owner changed unexpectedly."); + } + + _preparingMotionOwner = null; + } + + private sealed class MotionPreparation( + LocalPlayerControllerSlot owner, + PlayerMovementController controller) : IDisposable + { + private LocalPlayerControllerSlot? _owner = owner; + + public void Dispose() + { + LocalPlayerControllerSlot? current = Interlocked.Exchange(ref _owner, null); + current?.EndMotionPreparation(controller); + } + } } internal interface ILocalPlayerPhysicsHostSource diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 382395ff..c4a3dfa5 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -386,6 +386,15 @@ internal sealed class PlayerModeController : new MotionTableDispatchSink(sequencer); } + // Retail CPhysicsObj owns CMotionInterp and CPartArray throughout + // construction. Our split owners preserve that lifetime with a + // narrow preparation lease: SetPosition's synchronous type-5 + // completion reaches this candidate MotionInterpreter, while the + // public controller slot remains unpublished until every other + // player-mode edge has prepared successfully. + using IDisposable motionPreparation = + _controllerSlot.BeginMotionPreparation(controller); + ResolveResult initial = _physics.Resolve( playerEntity.Position, initialCellId, diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 794cf8b8..860f276f 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -38,6 +38,7 @@ internal sealed class SelectionInteractionController uint DestinationContainerId, int Placement, ulong PendingPlacementToken, + ItemUseRequestReservation? UseReservation, PlayerApproachToken ApproachToken); public SelectionInteractionController( @@ -197,16 +198,23 @@ internal sealed class SelectionInteractionController } public void SendUse(uint serverGuid) + => RequestUse(serverGuid, reservation: null); + + public void RequestUse( + uint serverGuid, + ItemUseRequestReservation? reservation) { CancelPendingApproach(); if (!_transport.IsInWorld) { _toast?.Invoke("Not in world"); + reservation?.CancelBeforeDispatch(); return; } if (!_query.IsUseable(serverGuid) || !_query.TryGetApproach(serverGuid, out InteractionApproach approach)) { + reservation?.CancelBeforeDispatch(); return; } @@ -219,18 +227,28 @@ internal sealed class SelectionInteractionController DestinationContainerId: 0u, Placement: 0, PendingPlacementToken: 0u, + UseReservation: reservation, ApproachToken: default); bool started = _movement.BeginApproach( approach, token => _pendingPostArrival = pending with { ApproachToken = token }); if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid) CancelPendingApproach(); + else if (!started) + reservation?.CancelBeforeDispatch(); return; } _movement.BeginApproach(approach); if (_transport.TrySendUse(serverGuid, out uint sequence)) + { + reservation?.MarkDispatched(); Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); + } + else + { + reservation?.CancelBeforeDispatch(); + } } public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) @@ -272,6 +290,7 @@ internal sealed class SelectionInteractionController destinationContainerId, placement, pendingPlacementToken, + UseReservation: null, ApproachToken: default); bool started = _movement.BeginApproach( approach, @@ -327,6 +346,8 @@ internal sealed class SelectionInteractionController CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); + else + pending.UseReservation?.CancelBeforeDispatch(); return; } @@ -357,7 +378,10 @@ internal sealed class SelectionInteractionController } else { - _transport.TrySendUse(pending.ServerGuid, out _); + if (_transport.TrySendUse(pending.ServerGuid, out _)) + pending.UseReservation?.MarkDispatched(); + else + pending.UseReservation?.CancelBeforeDispatch(); } } @@ -484,6 +508,8 @@ internal sealed class SelectionInteractionController CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); + else + pending.UseReservation?.CancelBeforeDispatch(); } private void CancelPickupPresentation(uint itemGuid, ulong token = 0u) diff --git a/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs index ab75fc0d..38489081 100644 --- a/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs +++ b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs @@ -13,17 +13,17 @@ internal sealed class LiveAnimationPresentationContext { private readonly LiveEntityRuntime _runtime; private readonly ILocalPlayerIdentitySource _localPlayer; - private readonly ILocalPlayerControllerSource _playerController; + private readonly ILocalPlayerMotionSource _playerMotion; public LiveAnimationPresentationContext( LiveEntityRuntime runtime, ILocalPlayerIdentitySource localPlayer, - ILocalPlayerControllerSource playerController) + ILocalPlayerMotionSource playerMotion) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); - _playerController = playerController - ?? throw new ArgumentNullException(nameof(playerController)); + _playerMotion = playerMotion + ?? throw new ArgumentNullException(nameof(playerMotion)); } public uint LocalPlayerGuid => _localPlayer.ServerGuid; @@ -38,7 +38,7 @@ internal sealed class LiveAnimationPresentationContext } if (record.ServerGuid == _localPlayer.ServerGuid) - return _playerController.Controller?.Motion; + return _playerMotion.Motion; return record.RemoteMotionRuntime is RemoteMotion remote ? remote.Motion : null; diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 5d6c9bca..d2c872ac 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -37,6 +37,32 @@ public readonly record struct PendingInventoryRequest( ClientObject? ItemIdentity, bool Dispatched); +/// +/// Owns one retail UI busy reference while an accepted Use request crosses the +/// local approach boundary. Dispatch transfers that reference to the matching +/// authoritative UseDone; cancellation before dispatch releases it locally. +/// +public sealed class ItemUseRequestReservation +{ + private readonly Action _resolve; + private int _resolved; + + internal ItemUseRequestReservation(Action resolve) + => _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + + public void MarkDispatched() + { + if (Interlocked.Exchange(ref _resolved, 1) == 0) + _resolve(true); + } + + public void CancelBeforeDispatch() + { + if (Interlocked.Exchange(ref _resolved, 1) == 0) + _resolve(false); + } +} + /// /// Shared retail item interaction orchestrator. UI widgets route item clicks, /// target acquisition, and drag-out drops here instead of duplicating @@ -78,6 +104,7 @@ public sealed class ItemInteractionController : IDisposable private readonly Func _dragOnPlayerOpensSecureTrade; private readonly Action? _systemMessage; private readonly AutoWieldController _autoWield; + private readonly Action? _requestUse; private long _lastUseMs = long.MinValue / 2; private uint _consumedPrimaryClickTarget; @@ -86,6 +113,7 @@ public sealed class ItemInteractionController : IDisposable private ulong _nextInventoryRequestToken; private PendingBackpackPlacement? _pendingBackpackPlacement; private PendingInventoryRequest? _pendingInventoryRequest; + private ulong _useReservationGeneration; private bool _disposed; public ItemInteractionController( @@ -118,7 +146,8 @@ public sealed class ItemInteractionController : IDisposable Action? sendSplitToContainer = null, Action? requestExternalContainer = null, CombatState? combatState = null, - Action? sendChangeCombatMode = null) + Action? sendChangeCombatMode = null, + Action? requestUse = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -147,6 +176,7 @@ public sealed class ItemInteractionController : IDisposable _stackSplitQuantity = stackSplitQuantity; _dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true); _systemMessage = systemMessage; + _requestUse = requestUse; _interactionState = interactionState ?? new InteractionState(); _interactionState.Changed += OnInteractionModeChanged; _objects.ObjectMoved += OnInventoryObjectMoved; @@ -757,12 +787,28 @@ public sealed class ItemInteractionController : IDisposable /// public bool ExecuteConfirmedUse(uint objectId) { - if (objectId == 0u || _sendUse is null) + if (objectId == 0u || (_requestUse is null && _sendUse is null)) return false; if (!EnsureInventoryRequestReady()) return false; - _sendUse(objectId); - _busyCount++; + if (_requestUse is not null) + { + ItemUseRequestReservation reservation = BeginUseRequestReservation(); + try + { + _requestUse(objectId, reservation); + } + catch + { + reservation.CancelBeforeDispatch(); + throw; + } + } + else + { + _sendUse!(objectId); + _busyCount++; + } return true; } @@ -815,6 +861,7 @@ public sealed class ItemInteractionController : IDisposable { uint openedOrUsed = 0; bool acted = false; + bool busyOwnedByUseReservation = false; foreach (var action in actions) { switch (action.Kind) @@ -835,9 +882,27 @@ public sealed class ItemInteractionController : IDisposable // coalesce the pair to one packet. if (openedOrUsed != action.ObjectId) { - _sendUse?.Invoke(action.ObjectId); + if (_requestUse is not null) + { + ItemUseRequestReservation reservation = + BeginUseRequestReservation(); + try + { + _requestUse(action.ObjectId, reservation); + } + catch + { + reservation.CancelBeforeDispatch(); + throw; + } + busyOwnedByUseReservation = true; + } + else + { + _sendUse?.Invoke(action.ObjectId); + } openedOrUsed = action.ObjectId; - acted |= _sendUse is not null; + acted |= _requestUse is not null || _sendUse is not null; } break; case ItemPolicyActionKind.SendUseWithTarget: @@ -853,7 +918,14 @@ public sealed class ItemInteractionController : IDisposable acted = true; break; case ItemPolicyActionKind.IncrementBusy: - _busyCount++; + if (busyOwnedByUseReservation) + { + busyOwnedByUseReservation = false; + } + else + { + _busyCount++; + } break; case ItemPolicyActionKind.Reject: if (!string.IsNullOrWhiteSpace(action.Message)) @@ -872,6 +944,21 @@ public sealed class ItemInteractionController : IDisposable return acted; } + private ItemUseRequestReservation BeginUseRequestReservation() + { + ulong generation = _useReservationGeneration; + _busyCount++; + StateChanged?.Invoke(); + return new ItemUseRequestReservation(dispatched => + { + if (generation != _useReservationGeneration || dispatched) + return; + if (_busyCount > 0) + _busyCount--; + StateChanged?.Invoke(); + }); + } + private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList actions) { foreach (var action in actions) @@ -1120,6 +1207,7 @@ public sealed class ItemInteractionController : IDisposable public void ClearBusy() { if (_busyCount == 0) return; + _useReservationGeneration++; _busyCount = 0; StateChanged?.Invoke(); } @@ -1133,6 +1221,7 @@ public sealed class ItemInteractionController : IDisposable { PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement; _pendingBackpackPlacement = null; + _useReservationGeneration++; _busyCount = 0; _pendingInventoryRequest = null; _lastUseMs = long.MinValue / 2; diff --git a/src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs b/src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs new file mode 100644 index 00000000..b7ede09a --- /dev/null +++ b/src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs @@ -0,0 +1,22 @@ +namespace AcDream.App.World; + +/// +/// Strongly typed retail PartArray world-entry boundary. +/// +/// +/// CPhysicsObj::set_hidden @ 0x00514C60 invokes +/// CPartArray::HandleEnterWorld @ 0x00517D70 on both Hidden edges. +/// This dedicated port prevents that mandatory lifecycle callback from being +/// confused with neighboring presentation callbacks. +/// +public sealed class LiveEntityPartArrayEnterWorldPort +{ + private readonly Action _handleEnterWorld; + + public LiveEntityPartArrayEnterWorldPort(Action handleEnterWorld) => + _handleEnterWorld = handleEnterWorld + ?? throw new ArgumentNullException(nameof(handleEnterWorld)); + + public void HandleEnterWorld(uint localEntityId) => + _handleEnterWorld(localEntityId); +} diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs index ff0ee778..417ebca5 100644 --- a/src/AcDream.App/World/LiveEntityPresentationController.cs +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -27,7 +27,7 @@ public sealed class LiveEntityPresentationController : IDisposable private readonly Action _clearInvalidTarget; private readonly Func<(int X, int Y)> _liveCenter; private readonly Action? _onShadowRestored; - private readonly Action _handlePartArrayEnterWorld; + private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld; private readonly Dictionary _readyGenerationByGuid = new(); private readonly Dictionary _suspendedShadowGenerationByGuid = new(); private readonly Dictionary _activePlacementGenerationByGuid = new(); @@ -38,20 +38,21 @@ public sealed class LiveEntityPresentationController : IDisposable LiveEntityRuntime liveEntities, ShadowObjectRegistry shadows, Func playTyped, + LiveEntityPartArrayEnterWorldPort partArrayEnterWorld, Action? setDirectChildrenNoDraw = null, Action? clearInvalidTarget = null, Func<(int X, int Y)>? liveCenter = null, - Action? onShadowRestored = null, - Action? handlePartArrayEnterWorld = null) + Action? onShadowRestored = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); _playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped)); + _partArrayEnterWorld = partArrayEnterWorld + ?? throw new ArgumentNullException(nameof(partArrayEnterWorld)); _setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { }); _clearInvalidTarget = clearInvalidTarget ?? (_ => { }); _liveCenter = liveCenter ?? (() => (0, 0)); _onShadowRestored = onShadowRestored; - _handlePartArrayEnterWorld = handlePartArrayEnterWorld ?? (_ => { }); _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } @@ -262,7 +263,7 @@ public sealed class LiveEntityPresentationController : IDisposable // timeline boundary: it strips link animations and // aborts every pending completion through // MotionTableManager::HandleEnterWorld @ 0x0051BDD0. - _handlePartArrayEnterWorld(entity.Id); + _partArrayEnterWorld.HandleEnterWorld(entity.Id); if (!IsCurrent(record, entity)) return false; _clearInvalidTarget(record.ServerGuid); @@ -279,7 +280,7 @@ public sealed class LiveEntityPresentationController : IDisposable return false; // Retail invokes the same PartArray boundary before // CObjCell::unhide_object restores cell visibility. - _handlePartArrayEnterWorld(entity.Id); + _partArrayEnterWorld.HandleEnterWorld(entity.Id); if (!IsCurrent(record, entity)) return false; bool restored = !IsPlacementActive(record) diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs new file mode 100644 index 00000000..17a9eab0 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/LocalPlayerControllerSlotTests.cs @@ -0,0 +1,57 @@ +using AcDream.App.Input; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Input; + +public sealed class LocalPlayerControllerSlotTests +{ + [Fact] + public void MotionPreparation_RoutesCompletionWithoutPublishingController() + { + var slot = new LocalPlayerControllerSlot(); + ILocalPlayerMotionSource source = slot; + var controller = new PlayerMovementController(new PhysicsEngine()); + controller.Motion.AddToQueue(0, MotionCommand.Ready, 0); + + using (slot.BeginMotionPreparation(controller)) + { + Assert.Null(slot.Controller); + MotionInterpreter? owner = source.Motion; + Assert.Same(controller.Motion, owner); + + owner!.MotionDone(MotionCommand.Ready, success: true); + Assert.False(controller.Motion.MotionsPending()); + } + + Assert.Null(source.Motion); + } + + [Fact] + public void MotionPreparation_HandsOffToCommittedControllerOnDispose() + { + var slot = new LocalPlayerControllerSlot(); + ILocalPlayerMotionSource source = slot; + var controller = new PlayerMovementController(new PhysicsEngine()); + + using (slot.BeginMotionPreparation(controller)) + { + slot.Controller = controller; + Assert.Same(controller.Motion, source.Motion); + } + + Assert.Same(controller.Motion, source.Motion); + } + + [Fact] + public void MotionPreparation_RejectsConcurrentCandidate() + { + var slot = new LocalPlayerControllerSlot(); + var first = new PlayerMovementController(new PhysicsEngine()); + var second = new PlayerMovementController(new PhysicsEngine()); + + using IDisposable preparation = slot.BeginMotionPreparation(first); + + Assert.Throws( + () => slot.BeginMotionPreparation(second)); + } +} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 18d0379b..344c7bed 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -154,12 +154,14 @@ public sealed class SelectionInteractionControllerTests { ObjectId = Target, Name = "Target", - Type = ItemType.Misc, + Type = ItemType.Creature, + Useability = ItemUseability.Remote, + PublicWeenieBitfield = (uint)PublicWeenieFlags.Stuck, }); Items = new ItemInteractionController( Objects, () => Player, - sendUse: guid => controller!.SendUse(guid), + sendUse: null, sendUseWithTarget: null, sendWield: null, sendDrop: null, @@ -169,7 +171,9 @@ public sealed class SelectionInteractionControllerTests Examines.Add(guid); }, placeInBackpack: (item, container, placement) => - controller!.SendPickup(item, container, placement)); + controller!.SendPickup(item, container, placement), + requestUse: (guid, reservation) => + controller!.RequestUse(guid, reservation)); Controller = controller = new SelectionInteractionController( Selection, Query, @@ -248,6 +252,102 @@ public sealed class SelectionInteractionControllerTests Assert.Equal(new[] { Target }, h.Transport.Uses); } + [Fact] + public void CancelledDeferredUseReleasesBusyWithoutServerCompletion() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.UseSelected); + h.Controller.DrainOutbound(); + + Assert.Equal(1, h.Items.BusyCount); + Assert.Empty(h.Transport.Uses); + + h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled); + h.Controller.DrainOutbound(); + + Assert.Equal(0, h.Items.BusyCount); + Assert.True(h.Items.CanMakeInventoryRequest); + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void DispatchedDeferredUseRemainsBusyUntilAuthoritativeUseDone() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.UseSelected); + h.Controller.DrainOutbound(); + h.CompletionLifetime.PublishNaturalCompletion(); + h.Controller.DrainOutbound(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + Assert.Equal(1, h.Items.BusyCount); + + h.Items.CompleteUse(0u); + + Assert.Equal(0, h.Items.BusyCount); + Assert.True(h.Items.CanMakeInventoryRequest); + } + + [Fact] + public void FailedDeferredUseStartReleasesItsBusyReservation() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.Starts = false; + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.UseSelected); + h.Controller.DrainOutbound(); + + Assert.Equal(0, h.Items.BusyCount); + Assert.True(h.Items.CanMakeInventoryRequest); + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void StaleDeferredUseReleasesItsBusyReservation() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.UseSelected); + h.Controller.DrainOutbound(); + Assert.Equal(1, h.Items.BusyCount); + + h.Query.Current = false; + h.CompletionLifetime.PublishNaturalCompletion(); + h.Controller.DrainOutbound(); + + Assert.Equal(0, h.Items.BusyCount); + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void SynchronousTurnCompletionTransfersBusyOwnershipToUseDone() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete; + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.UseSelected); + h.Controller.DrainOutbound(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + Assert.Equal(1, h.Items.BusyCount); + + h.Items.CompleteUse(0u); + + Assert.Equal(0, h.Items.BusyCount); + } + [Fact] public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry() { diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 02b2e6a1..00fec7ae 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -857,6 +857,7 @@ public sealed class RemotePhysicsUpdaterTests Live, Engine.ShadowObjects, (_, _, _) => true, + new LiveEntityPartArrayEnterWorldPort(_ => { }), liveCenter: () => (0, 0)); RegisterShadow(Entity, Remote); Assert.True(_presentation.OnLiveEntityReady(Guid)); diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs index 441b7780..9f20475c 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs @@ -1424,6 +1424,7 @@ public sealed class RemoteTeleportControllerTests Live, Engine.ShadowObjects, (_, _, _) => true, + new LiveEntityPartArrayEnterWorldPort(_ => { }), liveCenter: () => (0xA9, 0xB4), onShadowRestored: _ => ShadowSyncs++); Assert.True(Presentation.OnLiveEntityReady(Guid)); diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 404e75c5..0684ce5b 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -37,7 +37,8 @@ public sealed class ItemInteractionControllerTests public uint GroundObject; public long Now = 1_000; - public Harness() + public Harness( + Action? requestUse = null) { Objects.AddOrUpdate(new ClientObject { @@ -57,7 +58,7 @@ public sealed class ItemInteractionControllerTests Controller = new ItemInteractionController( Objects, playerGuid: () => Player, - sendUse: Uses.Add, + sendUse: requestUse is null ? Uses.Add : null, sendExamine: Examines.Add, sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), sendWield: (item, mask) => Wields.Add((item, mask)), @@ -84,7 +85,8 @@ public sealed class ItemInteractionControllerTests ExternalRequests.Add(id); }, combatState: Combat, - sendChangeCombatMode: CombatModeRequests.Add); + sendChangeCombatMode: CombatModeRequests.Add, + requestUse: requestUse); } public ItemInteractionController Controller { get; } @@ -1446,6 +1448,66 @@ public sealed class ItemInteractionControllerTests Assert.Equal(2, h.Uses.Count); } + [Fact] + public void DeferredUseCancellationReleasesExactlyItsBusyReference() + { + ItemUseRequestReservation? pending = null; + var h = new Harness((_, reservation) => pending = reservation); + h.AddContained( + 0x50000A0Bu, + item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.ActivateItem(0x50000A0Bu)); + Assert.Equal(1, h.Controller.BusyCount); + + Assert.NotNull(pending); + pending.CancelBeforeDispatch(); + pending.CancelBeforeDispatch(); + + Assert.Equal(0, h.Controller.BusyCount); + Assert.True(h.Controller.CanMakeInventoryRequest); + } + + [Fact] + public void DispatchedUseReservationIsReleasedOnlyByUseDone() + { + ItemUseRequestReservation? pending = null; + var h = new Harness((_, reservation) => pending = reservation); + h.AddContained( + 0x50000A0Cu, + item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.ActivateItem(0x50000A0Cu)); + Assert.NotNull(pending); + pending.MarkDispatched(); + pending.CancelBeforeDispatch(); + + Assert.Equal(1, h.Controller.BusyCount); + + h.Controller.CompleteUse(0u); + + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void SessionResetInvalidatesLateUseReservationResolution() + { + ItemUseRequestReservation? stale = null; + var h = new Harness((_, reservation) => stale = reservation); + h.AddContained( + 0x50000A0Du, + item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.ActivateItem(0x50000A0Du)); + Assert.Equal(1, h.Controller.BusyCount); + + h.Controller.ResetSession(); + stale!.CancelBeforeDispatch(); + + Assert.Equal(0, h.Controller.BusyCount); + Assert.True(h.Controller.CanMakeInventoryRequest); + } + [Fact] public void KeyboardPickup_PublishesPendingDestinationBeforeRequest() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs index 1af6e7d3..7540111b 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -564,6 +564,7 @@ public sealed class LiveEntityLifecycleStressTests TypedScripts.Add(type); return true; }, + new LiveEntityPartArrayEnterWorldPort(_ => { }), liveCenter: () => (1, 1)); Assert.True(Presentation.OnLiveEntityReady(Guid)); diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index 9be041f9..5c272886 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -452,6 +452,7 @@ public sealed class LiveEntityPresentationControllerTests runtime, shadows, (_, _, _) => true, + new LiveEntityPartArrayEnterWorldPort(_ => { }), liveCenter: () => (2, 2)); Assert.True(controller.OnLiveEntityReady(Fixture.Guid)); @@ -560,6 +561,7 @@ public sealed class LiveEntityPresentationControllerTests runtime, shadows, (_, _, _) => true, + new LiveEntityPartArrayEnterWorldPort(_ => { }), liveCenter: () => (2, 2)); Assert.True(controller.OnLiveEntityReady(Fixture.Guid)); Assert.Equal(1, shadows.SuspendedRegistrationCount); @@ -696,6 +698,13 @@ public sealed class LiveEntityPresentationControllerTests OnTypedPlay?.Invoke(type); return true; }, + new LiveEntityPartArrayEnterWorldPort(localEntityId => + { + PartArrayEnterWorld.Add(localEntityId); + PartArrayShadowCounts.Add(Shadows.TotalRegistered); + PresentationOrder.Add("part-array"); + onPartArrayEnterWorld?.Invoke(localEntityId); + }), (parent, noDraw) => { ChildNoDraw.Add((parent, noDraw)); @@ -707,13 +716,7 @@ public sealed class LiveEntityPresentationControllerTests PresentationOrder.Add("target"); }, () => (1, 1), - handlePartArrayEnterWorld: localEntityId => - { - PartArrayEnterWorld.Add(localEntityId); - PartArrayShadowCounts.Add(Shadows.TotalRegistered); - PresentationOrder.Add("part-array"); - onPartArrayEnterWorld?.Invoke(localEntityId); - }); + onShadowRestored: null); } internal static WorldSession.EntitySpawn Spawn( diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs index 4e5080ac..42bbc4fd 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs @@ -214,7 +214,8 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests Presentation = new LiveEntityPresentationController( Live, Physics.ShadowObjects, - (_, _, _) => false); + (_, _, _) => false, + new LiveEntityPartArrayEnterWorldPort(_ => { })); lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle( record => Controller.LeaveWorld(record, Guid))); }