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 <codex@openai.com>
This commit is contained in:
parent
5c955c36ec
commit
f9736ece6c
26 changed files with 675 additions and 68 deletions
14
AGENTS.md
14
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
|
||||
|
|
|
|||
14
CLAUDE.md
14
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
117
docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md
Normal file
117
docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,13 +21,74 @@ internal interface ILocalPlayerControllerSource
|
|||
PlayerMovementController? Controller { get; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerMotionSource
|
||||
{
|
||||
MotionInterpreter? Motion { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
|
||||
/// assignment; update and presentation owners receive the read-only seam.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
|
||||
internal sealed class LocalPlayerControllerSlot
|
||||
: ILocalPlayerControllerSource,
|
||||
ILocalPlayerMotionSource
|
||||
{
|
||||
private PlayerMovementController? _preparingMotionOwner;
|
||||
|
||||
public PlayerMovementController? Controller { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Controller"/>.
|
||||
/// </summary>
|
||||
MotionInterpreter? ILocalPlayerMotionSource.Motion =>
|
||||
_preparingMotionOwner?.Motion ?? Controller?.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,32 @@ public readonly record struct PendingInventoryRequest(
|
|||
ClientObject? ItemIdentity,
|
||||
bool Dispatched);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ItemUseRequestReservation
|
||||
{
|
||||
private readonly Action<bool> _resolve;
|
||||
private int _resolved;
|
||||
|
||||
internal ItemUseRequestReservation(Action<bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<bool> _dragOnPlayerOpensSecureTrade;
|
||||
private readonly Action<string>? _systemMessage;
|
||||
private readonly AutoWieldController _autoWield;
|
||||
private readonly Action<uint, ItemUseRequestReservation>? _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<uint, uint, uint, uint>? sendSplitToContainer = null,
|
||||
Action<uint>? requestExternalContainer = null,
|
||||
CombatState? combatState = null,
|
||||
Action<CombatMode>? sendChangeCombatMode = null)
|
||||
Action<CombatMode>? sendChangeCombatMode = null,
|
||||
Action<uint, ItemUseRequestReservation>? 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
|
|||
/// </summary>
|
||||
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<ItemPolicyAction> 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;
|
||||
|
|
|
|||
22
src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs
Normal file
22
src/AcDream.App/World/LiveEntityPartArrayEnterWorldPort.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed retail PartArray world-entry boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>CPhysicsObj::set_hidden @ 0x00514C60</c> invokes
|
||||
/// <c>CPartArray::HandleEnterWorld @ 0x00517D70</c> on both Hidden edges.
|
||||
/// This dedicated port prevents that mandatory lifecycle callback from being
|
||||
/// confused with neighboring <see cref="Action{T}"/> presentation callbacks.
|
||||
/// </remarks>
|
||||
public sealed class LiveEntityPartArrayEnterWorldPort
|
||||
{
|
||||
private readonly Action<uint> _handleEnterWorld;
|
||||
|
||||
public LiveEntityPartArrayEnterWorldPort(Action<uint> handleEnterWorld) =>
|
||||
_handleEnterWorld = handleEnterWorld
|
||||
?? throw new ArgumentNullException(nameof(handleEnterWorld));
|
||||
|
||||
public void HandleEnterWorld(uint localEntityId) =>
|
||||
_handleEnterWorld(localEntityId);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
private readonly Action<uint> _clearInvalidTarget;
|
||||
private readonly Func<(int X, int Y)> _liveCenter;
|
||||
private readonly Action<uint>? _onShadowRestored;
|
||||
private readonly Action<uint> _handlePartArrayEnterWorld;
|
||||
private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
|
||||
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
|
||||
|
|
@ -38,20 +38,21 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
LiveEntityRuntime liveEntities,
|
||||
ShadowObjectRegistry shadows,
|
||||
Func<uint, uint, float, bool> playTyped,
|
||||
LiveEntityPartArrayEnterWorldPort partArrayEnterWorld,
|
||||
Action<uint, bool>? setDirectChildrenNoDraw = null,
|
||||
Action<uint>? clearInvalidTarget = null,
|
||||
Func<(int X, int Y)>? liveCenter = null,
|
||||
Action<uint>? onShadowRestored = null,
|
||||
Action<uint>? handlePartArrayEnterWorld = null)
|
||||
Action<uint>? 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)
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(
|
||||
() => slot.BeginMotionPreparation(second));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -1424,6 +1424,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
Live,
|
||||
Engine.ShadowObjects,
|
||||
(_, _, _) => true,
|
||||
new LiveEntityPartArrayEnterWorldPort(_ => { }),
|
||||
liveCenter: () => (0xA9, 0xB4),
|
||||
onShadowRestored: _ => ShadowSyncs++);
|
||||
Assert.True(Presentation.OnLiveEntityReady(Guid));
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ public sealed class ItemInteractionControllerTests
|
|||
public uint GroundObject;
|
||||
public long Now = 1_000;
|
||||
|
||||
public Harness()
|
||||
public Harness(
|
||||
Action<uint, ItemUseRequestReservation>? 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -564,6 +564,7 @@ public sealed class LiveEntityLifecycleStressTests
|
|||
TypedScripts.Add(type);
|
||||
return true;
|
||||
},
|
||||
new LiveEntityPartArrayEnterWorldPort(_ => { }),
|
||||
liveCenter: () => (1, 1));
|
||||
Assert.True(Presentation.OnLiveEntityReady(Guid));
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue