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