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:
Erik 2026-07-23 05:51:51 +02:00
parent 5c955c36ec
commit f9736ece6c
26 changed files with 675 additions and 68 deletions

View file

@ -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));
}
}

View file

@ -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()
{

View file

@ -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));

View file

@ -1424,6 +1424,7 @@ public sealed class RemoteTeleportControllerTests
Live,
Engine.ShadowObjects,
(_, _, _) => true,
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (0xA9, 0xB4),
onShadowRestored: _ => ShadowSyncs++);
Assert.True(Presentation.OnLiveEntityReady(Guid));

View file

@ -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()
{

View file

@ -564,6 +564,7 @@ public sealed class LiveEntityLifecycleStressTests
TypedScripts.Add(type);
return true;
},
new LiveEntityPartArrayEnterWorldPort(_ => { }),
liveCenter: () => (1, 1));
Assert.True(Presentation.OnLiveEntityReady(Guid));

View file

@ -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(

View file

@ -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)));
}