fix(world): restore portal and distant use lifecycles

This commit is contained in:
Erik 2026-07-25 09:59:25 +02:00
parent c730632075
commit 1a14812c44
18 changed files with 401 additions and 196 deletions

View file

@ -42,6 +42,32 @@ public sealed class LocalPlayerControllerSlotTests
Assert.Same(controller.Motion, source.Motion);
}
[Fact]
public void MotionPreparation_DrainsPriorAnimationBeforePublishingCandidate()
{
var slot = new LocalPlayerControllerSlot();
ILocalPlayerMotionSource source = slot;
var prior = new PlayerMovementController(new PhysicsEngine());
var candidate = new PlayerMovementController(new PhysicsEngine());
slot.Controller = prior;
var order = new List<string>();
using (slot.BeginMotionPreparation(
candidate,
() =>
{
order.Add("drain");
Assert.Same(prior.Motion, source.Motion);
}))
{
order.Add("candidate");
Assert.Same(candidate.Motion, source.Motion);
}
Assert.Equal(["drain", "candidate"], order);
Assert.Same(prior.Motion, source.Motion);
}
[Fact]
public void MotionPreparation_RejectsConcurrentCandidate()
{

View file

@ -295,15 +295,15 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void CloseUseTurnsFirstThenSendsExactlyOnceOnNaturalCompletion()
public void CloseUseSendsImmediatelyWithoutSpeculativeMovement()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
Assert.Single(h.Movement.Approaches);
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Movement.Approaches);
Assert.Equal(new[] { Target }, h.Transport.Uses);
h.Controller.OnNaturalMoveToComplete();
h.Controller.OnNaturalMoveToComplete();
@ -312,7 +312,7 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void CancelledDeferredUseReleasesBusyWithoutServerCompletion()
public void AcceptedUseRemainsBusyUntilAuthoritativeUseDone()
{
var h = new Harness();
h.SetApproach(closeRange: true);
@ -322,18 +322,22 @@ public sealed class SelectionInteractionControllerTests
h.Controller.DrainOutbound();
Assert.Equal(1, h.Items.BusyCount);
Assert.Empty(h.Transport.Uses);
Assert.Equal(new[] { Target }, h.Transport.Uses);
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
h.Controller.DrainOutbound();
Assert.Equal(1, h.Items.BusyCount);
Assert.False(h.Items.CanMakeInventoryRequest);
h.Items.CompleteUse(0u);
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void DispatchedDeferredUseRemainsBusyUntilAuthoritativeUseDone()
public void DispatchedWorldUseRemainsBusyUntilAuthoritativeUseDone()
{
var h = new Harness();
h.SetApproach(closeRange: true);
@ -341,8 +345,6 @@ public sealed class SelectionInteractionControllerTests
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);
@ -354,7 +356,7 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void FailedDeferredUseStartReleasesItsBusyReservation()
public void WorldUseDoesNotDependOnLocalApproachInstallation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
@ -364,32 +366,27 @@ public sealed class SelectionInteractionControllerTests
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Equal(0, h.Items.BusyCount);
Assert.True(h.Items.CanMakeInventoryRequest);
Assert.Empty(h.Transport.Uses);
Assert.Equal(1, h.Items.BusyCount);
Assert.Equal(new[] { Target }, h.Transport.Uses);
Assert.Empty(h.Movement.Approaches);
}
[Fact]
public void StaleDeferredUseReleasesItsBusyReservation()
public void RejectedWorldUseReleasesItsBusyReservation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Query.Useable = false;
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()
public void SynchronousMovementCallbackCannotDuplicateWorldUse()
{
var h = new Harness();
h.SetApproach(closeRange: true);
@ -408,7 +405,7 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry()
public void FarUseSendsImmediatelyWithoutClientApproachAndDoesNotRetry()
{
var h = new Harness();
h.SetApproach(closeRange: false);
@ -416,7 +413,7 @@ public sealed class SelectionInteractionControllerTests
h.Controller.SendUse(Target);
h.Controller.OnNaturalMoveToComplete();
Assert.Single(h.Movement.Approaches);
Assert.Empty(h.Movement.Approaches);
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
@ -448,21 +445,21 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void HiddenOrStaleIncarnationCancelsDeferredAction()
public void HiddenOrStaleNotificationsCannotRetractOrDuplicateDispatchedUse()
{
var hidden = new Harness();
hidden.SetApproach(closeRange: true);
hidden.Controller.SendUse(Target);
hidden.Controller.OnEntityHidden(Target);
hidden.Controller.OnNaturalMoveToComplete();
Assert.Empty(hidden.Transport.Uses);
Assert.Equal(new[] { Target }, hidden.Transport.Uses);
var stale = new Harness();
stale.SetApproach(closeRange: true);
stale.Controller.SendUse(Target);
stale.Query.Current = false;
stale.Controller.OnNaturalMoveToComplete();
Assert.Empty(stale.Transport.Uses);
Assert.Equal(new[] { Target }, stale.Transport.Uses);
}
[Fact]
@ -487,7 +484,7 @@ public sealed class SelectionInteractionControllerTests
h.SetApproach(closeRange: true);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
h.Controller.SendUse(Target);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Items.InteractionState.EnterExamine();
h.Controller.ResetSession();
@ -501,12 +498,12 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void OldRemovalClearsCapturedActionButDoesNotClearReplacementSelection()
public void OldRemovalClearsCapturedPickupButDoesNotClearReplacementSelection()
{
var h = new Harness();
h.SetApproach(closeRange: true, localEntityId: 101u);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.SendUse(Target);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1));
oldRecord.WorldEntity = new WorldEntity
{
@ -522,11 +519,12 @@ public sealed class SelectionInteractionControllerTests
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(Target, h.Selection.SelectedObjectId);
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
}
[Fact]
public void SynchronousNaturalCompletionObservesTheArmedCloseAction()
public void SynchronousNaturalCompletionCannotDuplicateUse()
{
var h = new Harness();
h.SetApproach(closeRange: true);
@ -539,16 +537,16 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void SynchronousResetCannotResurrectTheCloseAction()
public void SynchronousResetCannotResurrectACancelledPickup()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.AfterArm = h.Controller.ResetSession;
h.Controller.SendUse(Target);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
}
[Fact]
@ -569,7 +567,7 @@ public sealed class SelectionInteractionControllerTests
}
[Fact]
public void ErrorCompletionClearsCloseActionBeforeALaterSuccess()
public void ErrorCompletionCannotRetractOrDuplicateDispatchedUse()
{
var h = new Harness();
h.SetApproach(closeRange: true);
@ -578,48 +576,45 @@ public sealed class SelectionInteractionControllerTests
h.Controller.OnMoveToCancelled(WeenieError.NoObject);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DeferredCompletionFromPriorApproachCannotAffectReplacement(
bool priorCompletedNaturally)
[Fact]
public void CompletionFromPriorPickupCannotAffectReplacement()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
if (priorCompletedNaturally)
h.CompletionLifetime.PublishNaturalCompletion();
else
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
h.Controller.SendUse(Target);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.UseSelected);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Equal(new[] { Target }, h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
h.CompletionLifetime.PublishNaturalCompletion();
h.Controller.DrainOutbound();
Assert.Equal(new[] { Target }, h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
}
[Fact]
public void RetiringControllerLifetimeInvalidatesQueuedArrival()
public void RetiringControllerLifetimeInvalidatesQueuedPickupArrival()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
h.CompletionLifetime.PublishNaturalCompletion();
h.Completions.RetireControllerLifetime(h.CompletionLifetime);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
}
[Fact]

View file

@ -219,6 +219,26 @@ public sealed class LiveEntityAnimationPresenterTests
Assert.Same(fixture.Record, context.LastRecord);
}
[Fact]
public void MotionDone_CurrentLogicalOwnerResolvesWhileSpatialProjectionIsWithdrawn()
{
var fixture = Build(partCount: 1, withSequencer: true);
var interpreter = new MotionInterpreter(new PhysicsBody());
interpreter.AddToQueue(0, MotionCommand.Ready, 0);
var context = new Context { Motion = interpreter };
var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), context);
Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid));
Assert.False(fixture.Record.IsSpatiallyProjected);
presenter.PrepareAnimation(fixture.Record, fixture.State);
Assert.NotNull(fixture.State.Sequencer!.MotionDoneTarget);
fixture.State.Sequencer!.MotionDoneTarget!(MotionCommand.Ready, true);
Assert.Equal(1, context.ResolveCount);
Assert.Same(fixture.Record, context.LastRecord);
Assert.False(interpreter.MotionsPending());
}
[Fact]
public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation()
{
@ -535,13 +555,14 @@ public sealed class LiveEntityAnimationPresenterTests
private sealed class Context : ILiveAnimationPresentationContext
{
public uint LocalPlayerGuid => 0x50000001u;
public MotionInterpreter? Motion { get; init; }
public int ResolveCount { get; private set; }
public LiveEntityRecord? LastRecord { get; private set; }
public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record)
{
ResolveCount++;
LastRecord = record;
return null;
return Motion;
}
}

View file

@ -1,4 +1,5 @@
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Selection;
namespace AcDream.App.Tests.Streaming;
@ -155,7 +156,7 @@ public sealed class WorldRevealCoordinatorTests
}
[Fact]
public void PortalViewportReveal_ReopensWorldWithoutCompletingProtocolTail()
public void Materialization_ReopensWorldBeforePortalViewportRelease()
{
var availability = new WorldGenerationAvailabilityState();
var world = new GpuWorldState(availability: availability);
@ -180,6 +181,16 @@ public sealed class WorldRevealCoordinatorTests
WorldRevealKind.Portal,
0x3032001Cu);
Assert.False(availability.IsWorldAvailable);
coordinator.ObserveMaterialized();
Assert.True(availability.IsWorldAvailable);
Assert.False(coordinator.Snapshot.WorldViewportObserved);
Assert.False(coordinator.Snapshot.Completed);
Assert.Equal(1, audio.ResumeCalls);
Assert.Empty(scheduler.Ends);
coordinator.RevealWorldViewport();
coordinator.RevealWorldViewport();
@ -195,6 +206,53 @@ public sealed class WorldRevealCoordinatorTests
Assert.Equal([generation], scheduler.Ends);
}
[Fact]
public void TunnelContinue_TicksDestinationObjectsBehindPortalViewport()
{
var availability = new WorldGenerationAvailabilityState();
var world = new GpuWorldState(availability: availability);
var quiescence = new WorldGenerationQuiescence(
availability,
new SelectionState(),
world,
audio: null);
var scheduler = new RecordingDestinationScheduler();
var coordinator = new WorldRevealCoordinator(
isRenderNeighborhoodReady: (_, _) => true,
isSpawnCellReady: _ => true,
isTerrainNeighborhoodReady: (_, _) => true,
areCompositeTexturesReady: () => true,
prepareCompositeTextures: (_, _) => { },
invalidateCompositeTextures: () => { },
isSpawnClaimUnhydratable: _ => false,
quiescence: quiescence,
streaming: scheduler);
var calls = new List<string>();
var frame = new RetailLiveFrameCoordinator(
new global::AcDream.App.Tests.TestLiveObjectFramePhase(
_ => calls.Add("objects")),
world,
new global::AcDream.App.Tests.TestLiveSessionFramePhase(
() => calls.Add("network")),
new global::AcDream.App.Tests.TestPostNetworkCommandFramePhase(
() => calls.Add("commands")),
new global::AcDream.App.Tests.TestLiveSpatialReconcilePhase(
() => calls.Add("spatial")),
availability);
coordinator.Begin(WorldRevealKind.Portal, 0x3032001Cu);
frame.Tick(1f / 60f);
Assert.Equal(["network", "commands"], calls);
calls.Clear();
coordinator.ObserveMaterialized();
frame.Tick(1f / 60f);
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
Assert.Empty(scheduler.Ends);
Assert.False(coordinator.Snapshot.WorldViewportObserved);
}
[Fact]
public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion()
{