refactor(runtime): extract local teleport and player mode
Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
c557038353
commit
eeb0f6b45c
29 changed files with 3311 additions and 1073 deletions
|
|
@ -0,0 +1,93 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.Types;
|
||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class PlayerMovementPlacementTransactionTests
|
||||
{
|
||||
private const uint PlayerGuid = 0x5000_0001u;
|
||||
private const uint TargetGuid = 0x7000_0001u;
|
||||
private const uint PriorCell = 0x0101_0101u;
|
||||
private const uint DestinationCell = 0x0101_0102u;
|
||||
|
||||
[Fact]
|
||||
public void PreparedPosition_DoesNotPublishRenderRootOrUnstickUntilCommit()
|
||||
{
|
||||
PhysicsEngine physics = PhysicsWithCells(PriorCell, DestinationCell);
|
||||
physics.UpdatePlayerCurrCell(PriorCell);
|
||||
var controller = new PlayerMovementController(physics);
|
||||
var hosts = new Dictionary<uint, IPhysicsObjHost>();
|
||||
IPhysicsObjHost? Resolve(uint id) => hosts.GetValueOrDefault(id);
|
||||
EntityPhysicsHost player = Host(PlayerGuid, Resolve);
|
||||
EntityPhysicsHost target = Host(TargetGuid, Resolve);
|
||||
hosts.Add(PlayerGuid, player);
|
||||
hosts.Add(TargetGuid, target);
|
||||
controller.PositionManager = player.PositionManager;
|
||||
player.PositionManager.StickTo(TargetGuid, 0.5f, 1.8f);
|
||||
|
||||
controller.PreparePositionForCommit(
|
||||
new Vector3(2f, 3f, 4f),
|
||||
DestinationCell,
|
||||
new Vector3(2f, 3f, 4f));
|
||||
|
||||
Assert.Equal(DestinationCell, controller.CellId);
|
||||
Assert.Equal(PriorCell, physics.DataCache!.CellGraph.CurrCell!.Id);
|
||||
Assert.Equal(TargetGuid, player.PositionManager.GetStickyObjectId());
|
||||
|
||||
controller.CommitPreparedPosition();
|
||||
|
||||
Assert.Equal(DestinationCell, physics.DataCache.CellGraph.CurrCell!.Id);
|
||||
Assert.Equal(0u, player.PositionManager.GetStickyObjectId());
|
||||
}
|
||||
|
||||
private static PhysicsEngine PhysicsWithCells(params uint[] cellIds)
|
||||
{
|
||||
var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
foreach (uint cellId in cellIds)
|
||||
{
|
||||
var cellStruct = new CellStruct
|
||||
{
|
||||
VertexArray = new VertexArray
|
||||
{
|
||||
Vertices = new Dictionary<ushort, SWVertex>(),
|
||||
},
|
||||
Polygons = new Dictionary<ushort, Polygon>(),
|
||||
};
|
||||
var envCell = new DatEnvCell
|
||||
{
|
||||
CellPortals = [],
|
||||
VisibleCells = [],
|
||||
};
|
||||
physics.DataCache.CacheCellStruct(
|
||||
cellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
Matrix4x4.Identity);
|
||||
}
|
||||
return physics;
|
||||
}
|
||||
|
||||
private static EntityPhysicsHost Host(
|
||||
uint guid,
|
||||
Func<uint, IPhysicsObjHost?> resolve) =>
|
||||
new(
|
||||
guid,
|
||||
getPosition: () => new AcDream.Core.Physics.Position(
|
||||
PriorCell,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity),
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getRadius: () => 0.5f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => 1f,
|
||||
curTime: () => 0d,
|
||||
physicsTimerTime: () => 0d,
|
||||
getObjectA: resolve,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
using AcDream.App.Interaction;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.Interaction;
|
||||
|
||||
public sealed class PlayerApproachCompletionStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompletionMailbox_PreservesPublicationOrder()
|
||||
{
|
||||
var state = new PlayerApproachCompletionState();
|
||||
IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
|
||||
|
||||
Assert.True(state.TryBeginApproach(out PlayerApproachToken token));
|
||||
lifetime.PublishNaturalCompletion();
|
||||
lifetime.PublishCancellation(WeenieError.NoObject);
|
||||
|
||||
Assert.True(state.TryTake(out PlayerApproachCompletion natural));
|
||||
Assert.Equal(token, natural.Token);
|
||||
Assert.True(natural.IsNatural);
|
||||
Assert.Equal(WeenieError.None, natural.Error);
|
||||
Assert.True(state.TryTake(out PlayerApproachCompletion cancellation));
|
||||
Assert.False(cancellation.IsNatural);
|
||||
Assert.Equal(WeenieError.NoObject, cancellation.Error);
|
||||
Assert.False(state.TryTake(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_DiscardsEveryPendingCompletion()
|
||||
{
|
||||
var state = new PlayerApproachCompletionState();
|
||||
IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
|
||||
Assert.True(state.TryBeginApproach(out _));
|
||||
lifetime.PublishNaturalCompletion();
|
||||
lifetime.PublishCancellation(WeenieError.ActionCancelled);
|
||||
|
||||
state.Clear();
|
||||
|
||||
Assert.False(state.TryTake(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetiredLifetime_CannotPublishIntoReplacementLifetime()
|
||||
{
|
||||
var state = new PlayerApproachCompletionState();
|
||||
IPlayerApproachCompletionSink stale = state.BeginControllerLifetime();
|
||||
Assert.True(state.TryBeginApproach(out PlayerApproachToken staleToken));
|
||||
state.RetireControllerLifetime(stale);
|
||||
Assert.True(state.TryTake(out PlayerApproachCompletion retirement));
|
||||
Assert.Equal(staleToken, retirement.Token);
|
||||
Assert.False(retirement.IsNatural);
|
||||
IPlayerApproachCompletionSink current = state.BeginControllerLifetime();
|
||||
Assert.True(state.TryBeginApproach(out PlayerApproachToken currentToken));
|
||||
|
||||
stale.PublishNaturalCompletion();
|
||||
current.PublishNaturalCompletion();
|
||||
|
||||
Assert.True(state.TryTake(out PlayerApproachCompletion completion));
|
||||
Assert.Equal(currentToken, completion.Token);
|
||||
Assert.False(state.TryTake(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retirement_ReplacesQueuedSuccessWithMatchingCancellation()
|
||||
{
|
||||
var state = new PlayerApproachCompletionState();
|
||||
IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime();
|
||||
Assert.True(state.TryBeginApproach(out PlayerApproachToken token));
|
||||
lifetime.PublishNaturalCompletion();
|
||||
|
||||
state.RetireControllerLifetime(lifetime);
|
||||
|
||||
Assert.True(state.TryTake(out PlayerApproachCompletion completion));
|
||||
Assert.Equal(token, completion.Token);
|
||||
Assert.False(completion.IsNatural);
|
||||
Assert.Equal(WeenieError.ActionCancelled, completion.Error);
|
||||
Assert.False(state.TryTake(out _));
|
||||
}
|
||||
}
|
||||
|
|
@ -16,10 +16,11 @@ public sealed class PlayerInteractionMovementSinkTests
|
|||
[Fact]
|
||||
public void MissingPlayerDoesNotArmTheIntent()
|
||||
{
|
||||
var sink = new PlayerInteractionMovementSink(() => null);
|
||||
var completions = new PlayerApproachCompletionState();
|
||||
var sink = new PlayerInteractionMovementSink(() => null, completions);
|
||||
bool armed = false;
|
||||
|
||||
Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true));
|
||||
Assert.False(sink.BeginApproach(Approach(closeRange: true), _ => armed = true));
|
||||
Assert.False(armed);
|
||||
}
|
||||
|
||||
|
|
@ -66,11 +67,15 @@ public sealed class PlayerInteractionMovementSinkTests
|
|||
new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity),
|
||||
new MovementParameters { UseSpheres = false });
|
||||
bool armedAfterCancellation = false;
|
||||
var sink = new PlayerInteractionMovementSink(() => controller);
|
||||
var completions = new PlayerApproachCompletionState();
|
||||
_ = completions.BeginControllerLifetime();
|
||||
var sink = new PlayerInteractionMovementSink(
|
||||
() => controller,
|
||||
completions);
|
||||
|
||||
Assert.True(sink.BeginApproach(
|
||||
Approach(closeRange),
|
||||
() => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
|
||||
_ => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
|
||||
|
||||
Assert.True(armedAfterCancellation);
|
||||
Assert.True(nonAutonomousAtTargetInstall);
|
||||
|
|
|
|||
|
|
@ -104,17 +104,20 @@ public sealed class SelectionInteractionControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class Movement : IPlayerInteractionMovementSink
|
||||
private sealed class Movement(IPlayerApproachTokenSource approachTokens)
|
||||
: IPlayerInteractionMovementSink
|
||||
{
|
||||
public List<InteractionApproach> Approaches { get; } = new();
|
||||
public bool Starts { get; set; } = true;
|
||||
public Action? AfterArm { get; set; }
|
||||
public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
|
||||
public bool BeginApproach(
|
||||
InteractionApproach approach,
|
||||
Action<PlayerApproachToken>? armAfterCancel = null)
|
||||
{
|
||||
Approaches.Add(approach);
|
||||
if (!Starts)
|
||||
if (!Starts || !approachTokens.TryBeginApproach(out PlayerApproachToken token))
|
||||
return false;
|
||||
armAfterCancel?.Invoke();
|
||||
armAfterCancel?.Invoke(token);
|
||||
AfterArm?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -124,7 +127,9 @@ public sealed class SelectionInteractionControllerTests
|
|||
{
|
||||
public readonly Query Query = new();
|
||||
public readonly Transport Transport = new();
|
||||
public readonly Movement Movement = new();
|
||||
public readonly PlayerApproachCompletionState Completions = new();
|
||||
public readonly IPlayerApproachCompletionSink CompletionLifetime;
|
||||
public readonly Movement Movement;
|
||||
public readonly SelectionState Selection = new();
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
public readonly List<string> Toasts = new();
|
||||
|
|
@ -136,6 +141,8 @@ public sealed class SelectionInteractionControllerTests
|
|||
|
||||
public Harness()
|
||||
{
|
||||
CompletionLifetime = Completions.BeginControllerLifetime();
|
||||
Movement = new Movement(Completions);
|
||||
SelectionInteractionController? controller = null;
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
|
|
@ -168,7 +175,8 @@ public sealed class SelectionInteractionControllerTests
|
|||
Items,
|
||||
Transport,
|
||||
Movement,
|
||||
Toasts.Add);
|
||||
Toasts.Add,
|
||||
Completions);
|
||||
Items.PendingBackpackPlacementRequested += PendingPlacements.Add;
|
||||
Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add;
|
||||
}
|
||||
|
|
@ -386,6 +394,47 @@ public sealed class SelectionInteractionControllerTests
|
|||
Assert.Empty(h.Transport.Uses);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void DeferredCompletionFromPriorApproachCannotAffectReplacement(
|
||||
bool priorCompletedNaturally)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: true);
|
||||
h.Controller.SendUse(Target);
|
||||
if (priorCompletedNaturally)
|
||||
h.CompletionLifetime.PublishNaturalCompletion();
|
||||
else
|
||||
h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled);
|
||||
|
||||
h.Controller.SendUse(Target);
|
||||
h.Controller.DrainOutbound();
|
||||
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
|
||||
h.CompletionLifetime.PublishNaturalCompletion();
|
||||
h.Controller.DrainOutbound();
|
||||
|
||||
Assert.Equal(new[] { Target }, h.Transport.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetiringControllerLifetimeInvalidatesQueuedArrival()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: true);
|
||||
h.Controller.SendUse(Target);
|
||||
h.CompletionLifetime.PublishNaturalCompletion();
|
||||
|
||||
h.Completions.RetireControllerLifetime(h.CompletionLifetime);
|
||||
h.Controller.DrainOutbound();
|
||||
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedClosePickupStartWithdrawsPresentation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -121,4 +121,30 @@ public class CameraControllerTests
|
|||
Assert.Null(ctl.Chase);
|
||||
Assert.Null(ctl.RetailChase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreState_ReestablishesPriorCameraAfterNotificationFailure()
|
||||
{
|
||||
var orbit = new OrbitCamera();
|
||||
var ctl = new CameraController(orbit, new FlyCamera());
|
||||
CameraController.CameraState prior = ctl.CaptureState();
|
||||
int notifications = 0;
|
||||
ctl.ModeChanged += _ =>
|
||||
{
|
||||
if (++notifications == 1)
|
||||
throw new InvalidOperationException("injected camera observer failure");
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ctl.EnterChaseMode(new ChaseCamera(), new RetailChaseCamera()));
|
||||
|
||||
ctl.RestoreState(prior);
|
||||
|
||||
Assert.Same(orbit, ctl.Active);
|
||||
Assert.False(ctl.IsFlyMode);
|
||||
Assert.False(ctl.IsChaseMode);
|
||||
Assert.Null(ctl.Chase);
|
||||
Assert.Null(ctl.RetailChase);
|
||||
Assert.Equal(2, notifications);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,747 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
public sealed class LocalPlayerTeleportControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates()
|
||||
{
|
||||
var harness = new Harness();
|
||||
WorldSession.EntityPositionUpdate destination = Position(
|
||||
cellId: 0x20210001u,
|
||||
teleportSequence: 7,
|
||||
x: 12f,
|
||||
y: 34f,
|
||||
z: 5f);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: true);
|
||||
Assert.Empty(harness.Streaming.PriorityWrites);
|
||||
|
||||
harness.Controller.OnTeleportStarted(7);
|
||||
|
||||
Assert.Equal(1, harness.Input.EndCount);
|
||||
Assert.Equal(1, harness.Mode.EnterPortalCount);
|
||||
Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection);
|
||||
Assert.Equal((0x2021FFFFu, LocalPlayerTeleportController.NearRingRadius),
|
||||
Assert.Single(harness.Streaming.PriorityWrites));
|
||||
Assert.True(harness.Reveal.Snapshot.IsActive);
|
||||
Assert.Equal(WorldRevealKind.Portal, harness.Reveal.Snapshot.Kind);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
destination,
|
||||
teleportTimestampAdvanced: false);
|
||||
Assert.Single(harness.Streaming.PriorityWrites);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingPlayerController_KeepsStartAndDestinationPendingUntilProjectionExists()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Mode.Controller = null;
|
||||
|
||||
harness.Controller.OnTeleportStarted(2);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 2, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
Assert.False(harness.Controller.IsActive);
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
Assert.Null(harness.Presentation.BeginProjection);
|
||||
|
||||
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
|
||||
harness.Mode.Controller.SetPosition(
|
||||
Vector3.Zero,
|
||||
0x20210001u,
|
||||
Vector3.Zero);
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
|
||||
Assert.NotNull(harness.Presentation.BeginProjection);
|
||||
Assert.Single(harness.Streaming.PriorityWrites);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControllerWithdrawnAfterActivation_KeepsAcceptedDestinationUntilItReturns()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(14);
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
|
||||
harness.Mode.Controller = null;
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 14, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
Assert.Empty(harness.Streaming.PriorityWrites);
|
||||
|
||||
harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine());
|
||||
harness.Mode.Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell);
|
||||
Assert.Single(harness.Streaming.PriorityWrites);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControllerWithdrawnAfterAim_HoldsPresentationUntilModeRebuildsIt()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(15);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 15, 7f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Mode.Controller = null;
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.Empty(harness.Presentation.WorldReadyValues);
|
||||
|
||||
harness.Mode.RebuildOnEnter = () =>
|
||||
{
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
|
||||
return controller;
|
||||
};
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.NotNull(harness.Mode.Controller);
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition()
|
||||
{
|
||||
var harness = new Harness(centerX: 0x20, centerY: 0x21);
|
||||
harness.Controller.OnTeleportStarted(3);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210123u, 3, 20f, 30f, 4f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.Empty(harness.Streaming.Recenters);
|
||||
Assert.Equal(new Vector3(20f, 30f, 4f), harness.Placement.Position);
|
||||
Assert.Equal(0x20210123u, harness.Placement.CellId);
|
||||
Assert.False(harness.Placement.Forced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossLandblockDestination_RecentersBeforeItCanBecomeReady()
|
||||
{
|
||||
var harness = new Harness(centerX: 0x20, centerY: 0x21);
|
||||
harness.Mode.Controller!.SetPosition(
|
||||
Vector3.Zero,
|
||||
0x20210001u,
|
||||
Vector3.Zero);
|
||||
harness.Controller.OnTeleportStarted(4);
|
||||
harness.Streaming.RecenterPending = true;
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x30310100u, 4, -2f, 8f, 9f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
||||
harness.Controller.Tick(1f);
|
||||
|
||||
Assert.Equal((0x30, 0x31, true), Assert.Single(harness.Streaming.Recenters));
|
||||
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
|
||||
harness.Streaming.RecenterPending = false;
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(new Vector3(-2f, 8f, 9f), harness.Placement.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadinessHold_UsesWallClockAndForcesExactlyAtTenSeconds()
|
||||
{
|
||||
var harness = new Harness(worldReady: false);
|
||||
harness.Controller.OnTeleportStarted(5);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 5, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.EmitPlaceWhenReady = true;
|
||||
|
||||
harness.Controller.Tick(9.5f);
|
||||
Assert.False(Assert.Single(harness.Presentation.WorldReadyValues));
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
|
||||
harness.Controller.Tick(0.5f);
|
||||
|
||||
Assert.True(harness.Presentation.WorldReadyValues[^1]);
|
||||
Assert.True(harness.Placement.Forced);
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), harness.Placement.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Place_ReconcilesInsidePlacementBeforeRevealMaterialized()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(8);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 8, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
order.Clear();
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.True(Index(order, "placement") < Index(order, "[world-reveal] event=materialized"));
|
||||
Assert.True(Index(order, "[world-reveal] event=materialized") < Index(order, "priority-clear"));
|
||||
Assert.Equal(1, harness.Reveal.PortalMaterializationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoginComplete_EntersWorldThenSendsThenCompletesAndResets()
|
||||
{
|
||||
var order = new List<string>();
|
||||
var harness = new Harness(order: order);
|
||||
harness.Controller.OnTeleportStarted(9);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 9, 4f, 5f, 6f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
harness.Controller.Tick(0.016f);
|
||||
order.Clear();
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.True(Index(order, "enter-world") < Index(order, "login-complete"));
|
||||
Assert.True(Index(order, "login-complete") < Index(order, "[world-reveal] event=complete"));
|
||||
Assert.True(Index(order, "[world-reveal] event=complete") < Index(order, "presentation-reset"));
|
||||
Assert.False(harness.Controller.IsActive);
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewerStart_ReplacesOldDestinationWithoutReusingIt()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(10);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 10, 1f, 1f, 1f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
harness.Controller.OnTeleportStarted(11);
|
||||
harness.Controller.Tick(LocalPlayerTeleportController.MaximumWorldHoldSeconds);
|
||||
|
||||
Assert.Equal(default, harness.Placement.Position);
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 11, 2f, 2f, 2f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
harness.Controller.Tick(0.016f);
|
||||
Assert.Equal(new Vector3(2f), harness.Placement.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionReset_ClearsTransitCancelsRevealAndRequiresRecenterConvergence()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(12);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 12, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
|
||||
harness.Controller.ResetSession();
|
||||
|
||||
Assert.False(harness.Controller.IsActive);
|
||||
Assert.True(harness.Reveal.Snapshot.Cancelled);
|
||||
Assert.True(harness.Streaming.LastResetWasSessionEnding);
|
||||
|
||||
var failed = new Harness();
|
||||
failed.Streaming.ResetResult = false;
|
||||
Assert.Throws<InvalidOperationException>(() => failed.Controller.ResetSession());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleStart_DoesNotMutateInputModeOrPresentation()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Authority.IsFresh = false;
|
||||
|
||||
harness.Controller.OnTeleportStarted(13);
|
||||
|
||||
Assert.Equal(0, harness.Input.EndCount);
|
||||
Assert.Equal(0, harness.Mode.EnterPortalCount);
|
||||
Assert.Null(harness.Presentation.BeginProjection);
|
||||
Assert.False(harness.Controller.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantNewStartDuringPlacement_CannotBeCompletedByOldTail()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(20);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 20, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Streaming.PriorityClearCount = 0;
|
||||
harness.Placement.OnPlace = () => harness.Controller.OnTeleportStarted(21);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.Place);
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
Assert.Equal(0, harness.Reveal.PortalMaterializationCount);
|
||||
Assert.Equal(1, harness.Streaming.PriorityClearCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantNewStartDuringLoginComplete_PreservesReplacementLifetime()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Controller.OnTeleportStarted(30);
|
||||
harness.Controller.OfferDestination(
|
||||
Position(0x20210001u, 30, 1f, 2f, 3f),
|
||||
teleportTimestampAdvanced: true);
|
||||
harness.Session.OnSend = () => harness.Controller.OnTeleportStarted(31);
|
||||
harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete);
|
||||
|
||||
harness.Controller.Tick(0.016f);
|
||||
|
||||
Assert.True(harness.Controller.IsActive);
|
||||
Assert.Equal(0u, harness.Controller.ActiveDestinationCell);
|
||||
Assert.False(harness.Reveal.Snapshot.Completed);
|
||||
Assert.Equal(1, harness.Session.LoginCompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeFailure_CanBeRetriedWithoutDoubleDisposingAfterSuccess()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Presentation.DisposeFailuresRemaining = 1;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => harness.Controller.Dispose());
|
||||
harness.Controller.Dispose();
|
||||
harness.Controller.Dispose();
|
||||
|
||||
Assert.Equal(2, harness.Presentation.DisposeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcretePlacement_CommitsEntityControllerAndOneSpatialReconcile()
|
||||
{
|
||||
const uint guid = 0x50000001u;
|
||||
const uint cell = 0x20210001u;
|
||||
var world = new GpuWorldState();
|
||||
world.AddLandblock(new LoadedLandblock(
|
||||
0x2021FFFFu,
|
||||
new DatReaderWriter.DBObjs.LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
var runtime = new LiveEntityRuntime(world, new NullResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, cell));
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
cell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
})!;
|
||||
var identity = new LocalPlayerIdentityState { ServerGuid = guid };
|
||||
var controllerSlot = new LocalPlayerControllerSlot
|
||||
{
|
||||
Controller = new PlayerMovementController(new PhysicsEngine()),
|
||||
};
|
||||
controllerSlot.Controller.SetPosition(Vector3.Zero, cell, Vector3.Zero);
|
||||
var cameras = new ChaseCameraInputState
|
||||
{
|
||||
Legacy = new ChaseCamera(),
|
||||
Retail = new RetailChaseCamera(),
|
||||
};
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(0x20, 0x21);
|
||||
var spatial = new FakeSpatialReconcile(() => new PlacementSnapshot(
|
||||
entity.Position,
|
||||
entity.ParentCellId ?? 0u,
|
||||
entity.Rotation,
|
||||
controllerSlot.Controller.Position,
|
||||
controllerSlot.Controller.CellId,
|
||||
controllerSlot.Controller.BodyOrientation));
|
||||
var placement = new LocalPlayerTeleportPlacement(
|
||||
new PhysicsEngine(),
|
||||
runtime,
|
||||
identity,
|
||||
controllerSlot,
|
||||
new LocalPlayerPhysicsHostSlot(),
|
||||
cameras,
|
||||
origin,
|
||||
spatial);
|
||||
var position = new Vector3(12f, 24f, 6f);
|
||||
Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
|
||||
|
||||
placement.Place(position, cell, rotation, forced: false);
|
||||
|
||||
Assert.Equal(entity.Position, controllerSlot.Controller.Position);
|
||||
Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId);
|
||||
Assert.Equal(cell & 0xFFFF0000u, entity.ParentCellId & 0xFFFF0000u);
|
||||
Assert.Equal(rotation, entity.Rotation);
|
||||
Assert.Equal(rotation, controllerSlot.Controller.BodyOrientation);
|
||||
Assert.Equal(1, spatial.Count);
|
||||
Assert.Equal(entity.Position, spatial.Snapshot.EntityPosition);
|
||||
Assert.Equal(entity.ParentCellId, spatial.Snapshot.EntityCell);
|
||||
Assert.Equal(entity.Rotation, spatial.Snapshot.EntityRotation);
|
||||
Assert.Equal(
|
||||
controllerSlot.Controller.Position,
|
||||
spatial.Snapshot.ControllerPosition);
|
||||
Assert.Equal(
|
||||
controllerSlot.Controller.CellId,
|
||||
spatial.Snapshot.ControllerCell);
|
||||
Assert.Equal(
|
||||
controllerSlot.Controller.BodyOrientation,
|
||||
spatial.Snapshot.ControllerRotation);
|
||||
}
|
||||
|
||||
private static int Index(IReadOnlyList<string> events, string prefix)
|
||||
{
|
||||
for (int i = 0; i < events.Count; i++)
|
||||
{
|
||||
if (events[i].StartsWith(prefix, StringComparison.Ordinal))
|
||||
return i;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate Position(
|
||||
uint cellId,
|
||||
ushort teleportSequence,
|
||||
float x,
|
||||
float y,
|
||||
float z) => new(
|
||||
0x50000001u,
|
||||
new CreateObject.ServerPosition(
|
||||
cellId,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
teleportSequence,
|
||||
1);
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new(
|
||||
Guid: guid,
|
||||
Position: new CreateObject.ServerPosition(
|
||||
cell,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
1f,
|
||||
0f,
|
||||
0f,
|
||||
0f),
|
||||
SetupTableId: 0x02000001u,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "player",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
private sealed class NullResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity) { }
|
||||
public void Unregister(WorldEntity entity) { }
|
||||
}
|
||||
|
||||
private sealed class FakeSpatialReconcile : ILiveSpatialReconcilePhase
|
||||
{
|
||||
private readonly Func<PlacementSnapshot> _capture;
|
||||
|
||||
public FakeSpatialReconcile(Func<PlacementSnapshot> capture) =>
|
||||
_capture = capture;
|
||||
|
||||
public int Count;
|
||||
public PlacementSnapshot Snapshot;
|
||||
|
||||
public void Reconcile()
|
||||
{
|
||||
Snapshot = _capture();
|
||||
Count++;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct PlacementSnapshot(
|
||||
Vector3 EntityPosition,
|
||||
uint EntityCell,
|
||||
Quaternion EntityRotation,
|
||||
Vector3 ControllerPosition,
|
||||
uint ControllerCell,
|
||||
Quaternion ControllerRotation);
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly FakeAuthority Authority = new();
|
||||
public readonly FakeInput Input = new();
|
||||
public readonly FakeMode Mode;
|
||||
public readonly FakeStreaming Streaming;
|
||||
public readonly FakePlacement Placement;
|
||||
public readonly FakeSession Session;
|
||||
public readonly FakePresentation Presentation;
|
||||
public readonly WorldRevealCoordinator Reveal;
|
||||
public readonly LocalPlayerTeleportController Controller;
|
||||
|
||||
public Harness(
|
||||
int centerX = 0x20,
|
||||
int centerY = 0x21,
|
||||
bool worldReady = true,
|
||||
List<string>? order = null)
|
||||
{
|
||||
order ??= new List<string>();
|
||||
Mode = new FakeMode(order);
|
||||
Streaming = new FakeStreaming(centerX, centerY, order);
|
||||
Placement = new FakePlacement(order);
|
||||
Session = new FakeSession(order);
|
||||
Presentation = new FakePresentation(order);
|
||||
Reveal = new WorldRevealCoordinator(
|
||||
isRenderNeighborhoodReady: (_, _) => worldReady,
|
||||
isSpawnCellReady: _ => worldReady,
|
||||
isTerrainNeighborhoodReady: (_, _) => worldReady,
|
||||
areCompositeTexturesReady: () => worldReady,
|
||||
prepareCompositeTextures: (_, _) => { },
|
||||
invalidateCompositeTextures: () => { },
|
||||
isSpawnClaimUnhydratable: _ => false,
|
||||
log: order.Add);
|
||||
Controller = new LocalPlayerTeleportController(
|
||||
Authority,
|
||||
Input,
|
||||
Mode,
|
||||
Streaming,
|
||||
Reveal,
|
||||
Placement,
|
||||
Session,
|
||||
Presentation);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeAuthority : ILocalPlayerTeleportAuthority
|
||||
{
|
||||
public bool IsFresh = true;
|
||||
public bool IsFreshStart(ushort sequence) => IsFresh;
|
||||
}
|
||||
|
||||
private sealed class FakeInput : ILocalPlayerTeleportInputLifetime
|
||||
{
|
||||
public int EndCount;
|
||||
public void EndMouseLook() => EndCount++;
|
||||
}
|
||||
|
||||
private sealed class FakeMode : ILocalPlayerTeleportModeOperations
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
||||
public FakeMode(List<string> order)
|
||||
{
|
||||
_order = order;
|
||||
Controller = new PlayerMovementController(new PhysicsEngine());
|
||||
Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero);
|
||||
}
|
||||
|
||||
public PlayerMovementController? Controller { get; set; }
|
||||
public Matrix4x4 Projection => Matrix4x4.Identity;
|
||||
public int EnterPortalCount;
|
||||
public Func<PlayerMovementController?>? RebuildOnEnter;
|
||||
|
||||
public bool TryEnterPortalSpace()
|
||||
{
|
||||
EnterPortalCount++;
|
||||
Controller ??= RebuildOnEnter?.Invoke();
|
||||
if (Controller is null)
|
||||
return false;
|
||||
Controller.State = PlayerState.PortalSpace;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnterWorld()
|
||||
{
|
||||
_order.Add("enter-world");
|
||||
if (Controller is { } controller)
|
||||
controller.State = PlayerState.InWorld;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeStreaming : ILocalPlayerTeleportStreamingOperations
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
||||
public FakeStreaming(int centerX, int centerY, List<string> order)
|
||||
{
|
||||
CenterX = centerX;
|
||||
CenterY = centerY;
|
||||
_order = order;
|
||||
}
|
||||
|
||||
public int CenterX { get; }
|
||||
public int CenterY { get; }
|
||||
public bool IsRecenterPending => RecenterPending;
|
||||
public bool RecenterPending;
|
||||
public bool ResetResult = true;
|
||||
public bool LastResetWasSessionEnding;
|
||||
public int PriorityClearCount;
|
||||
public readonly List<(int X, int Y, bool Sealed)> Recenters = new();
|
||||
public readonly List<(uint Landblock, int Radius)> PriorityWrites = new();
|
||||
|
||||
public bool BeginRecenter(int x, int y, bool isSealedDungeon)
|
||||
{
|
||||
Recenters.Add((x, y, isSealedDungeon));
|
||||
return !RecenterPending;
|
||||
}
|
||||
|
||||
public bool ResetRecenter(bool sessionEnding)
|
||||
{
|
||||
LastResetWasSessionEnding = sessionEnding;
|
||||
return ResetResult;
|
||||
}
|
||||
|
||||
public bool IsSealedDungeon(uint cellId) =>
|
||||
(cellId & 0xFFFFu) >= 0x0100u;
|
||||
|
||||
public void SetPriority(uint landblockId, int radius) =>
|
||||
PriorityWrites.Add((landblockId, radius));
|
||||
|
||||
public void ClearPriority()
|
||||
{
|
||||
PriorityClearCount++;
|
||||
_order.Add("priority-clear");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakePlacement : ILocalPlayerTeleportPlacement
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
||||
public FakePlacement(List<string> order) => _order = order;
|
||||
|
||||
public Vector3 Position;
|
||||
public uint CellId;
|
||||
public Quaternion Rotation;
|
||||
public bool Forced;
|
||||
public Action? OnPlace;
|
||||
|
||||
public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced)
|
||||
{
|
||||
_order.Add("placement");
|
||||
Position = position;
|
||||
CellId = cellId;
|
||||
Rotation = rotation;
|
||||
Forced = forced;
|
||||
OnPlace?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSession : ILocalPlayerTeleportSession
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
|
||||
public FakeSession(List<string> order) => _order = order;
|
||||
|
||||
public int LoginCompleteCount;
|
||||
public Action? OnSend;
|
||||
|
||||
public void SendLoginComplete()
|
||||
{
|
||||
_order.Add("login-complete");
|
||||
LoginCompleteCount++;
|
||||
OnSend?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakePresentation : ILocalPlayerTeleportPresentation
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
private readonly Queue<IReadOnlyList<TeleportAnimEvent>> _events = new();
|
||||
|
||||
public FakePresentation(List<string> order) => _order = order;
|
||||
|
||||
public bool IsPortalViewportVisible { get; private set; }
|
||||
public int CurrentTunnelFrame => 72;
|
||||
public Matrix4x4? BeginProjection;
|
||||
public bool EmitPlaceWhenReady;
|
||||
public readonly List<bool> WorldReadyValues = new();
|
||||
public int DisposeFailuresRemaining;
|
||||
public int DisposeCount;
|
||||
|
||||
public void Enqueue(params TeleportAnimEvent[] events) => _events.Enqueue(events);
|
||||
|
||||
public void Begin(Matrix4x4 projection)
|
||||
{
|
||||
BeginProjection = projection;
|
||||
_order.Add("presentation-begin");
|
||||
}
|
||||
|
||||
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> Events)
|
||||
Tick(float deltaSeconds, bool worldReady)
|
||||
{
|
||||
WorldReadyValues.Add(worldReady);
|
||||
if (_events.Count > 0)
|
||||
return (default, _events.Dequeue());
|
||||
if (EmitPlaceWhenReady && worldReady)
|
||||
{
|
||||
EmitPlaceWhenReady = false;
|
||||
return (default, new[] { TeleportAnimEvent.Place });
|
||||
}
|
||||
|
||||
return (default, Array.Empty<TeleportAnimEvent>());
|
||||
}
|
||||
|
||||
public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick");
|
||||
public void EnterTunnel() => IsPortalViewportVisible = true;
|
||||
public void ExitTunnel() => IsPortalViewportVisible = false;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_order.Add("presentation-reset");
|
||||
IsPortalViewportVisible = false;
|
||||
BeginProjection = null;
|
||||
_events.Clear();
|
||||
}
|
||||
|
||||
public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => projection;
|
||||
public ICamera ApplyViewPlane(ICamera camera) => camera;
|
||||
public void DrawPortalViewport(int width, int height, Matrix4x4 projection) { }
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
if (DisposeFailuresRemaining-- > 0)
|
||||
throw new InvalidOperationException("injected disposal failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,7 +175,16 @@ public sealed class GameWindowLiveEntityCompositionTests
|
|||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs"));
|
||||
|
||||
Assert.Contains("_localPlayerMode.ResetSession();", source, StringComparison.Ordinal);
|
||||
Assert.Contains("_playerModeController?.ResetSession();", source,
|
||||
StringComparison.Ordinal);
|
||||
string playerModeSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Input",
|
||||
"PlayerModeController.cs"));
|
||||
Assert.Contains("_mode.ResetSession();", playerModeSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_liveEntityNetworkUpdates?.ResetSessionState();",
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,40 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
Assert.Equal(12f, watcher.Position.Frame.Origin.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreparedRebind_DoesNotPublishDelegatesUntilCommit()
|
||||
{
|
||||
const uint guid = 0x70000025u;
|
||||
var runtime = Runtime();
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
|
||||
EntityPhysicsHost original = Host(
|
||||
guid,
|
||||
position: new Vector3(1f, 0f, 0f));
|
||||
runtime.InstallPhysicsHost(record, original);
|
||||
EntityPhysicsHost configuration = Host(
|
||||
guid,
|
||||
position: new Vector3(9f, 0f, 0f));
|
||||
|
||||
EntityPhysicsHost stable = EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
runtime,
|
||||
record,
|
||||
configuration);
|
||||
|
||||
// A late DAT/physics/camera preparation failure occurs here in the
|
||||
// player-mode transaction. The live record must still expose its
|
||||
// previous delegates and manager identity.
|
||||
Assert.Same(original, stable);
|
||||
Assert.Same(original, record.PhysicsHost);
|
||||
Assert.Equal(1f, original.Position.Frame.Origin.X);
|
||||
|
||||
EntityPhysicsHost committed = EntityPhysicsHost.InstallOrRebind(
|
||||
runtime,
|
||||
record,
|
||||
configuration);
|
||||
Assert.Same(original, committed);
|
||||
Assert.Equal(9f, committed.Position.Frame.Origin.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public sealed class LiveEntityWorldOriginCoordinatorTests
|
|||
world,
|
||||
Reveal(),
|
||||
() => playerGuid,
|
||||
_ => false);
|
||||
new FakeSealedDungeonCellClassifier());
|
||||
|
||||
LiveEntityOriginInitialization ignored = coordinator.TryInitialize(
|
||||
Spawn(0x50000002u, 0x09090001u));
|
||||
|
|
@ -59,7 +59,7 @@ public sealed class LiveEntityWorldOriginCoordinatorTests
|
|||
world,
|
||||
Reveal(),
|
||||
() => playerGuid,
|
||||
_ => false);
|
||||
new FakeSealedDungeonCellClassifier());
|
||||
|
||||
Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x03040001u)).IsKnown);
|
||||
origin.Reset();
|
||||
|
|
@ -114,4 +114,10 @@ public sealed class LiveEntityWorldOriginCoordinatorTests
|
|||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
private sealed class FakeSealedDungeonCellClassifier
|
||||
: ISealedDungeonCellClassifier
|
||||
{
|
||||
public bool IsSealedDungeon(uint cellId) => false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -414,18 +414,32 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("wantCaptureMouse: ()", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(5, CountOccurrences(
|
||||
Assert.Equal(2, CountOccurrences(
|
||||
source,
|
||||
"_gameplayInputFrame?.EndMouseLook();"));
|
||||
Assert.Contains(
|
||||
"new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
string teleportSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Streaming",
|
||||
"LocalPlayerTeleportController.cs"));
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_teleportTransit.CanBegin(teleportSequence)",
|
||||
"_gameplayInputFrame?.EndMouseLook();",
|
||||
"_teleportTransit.QueueStart(teleportSequence)");
|
||||
teleportSource,
|
||||
"_transit.CanBegin(teleportSequence)",
|
||||
"_input.EndMouseLook();",
|
||||
"_transit.QueueStart(teleportSequence)");
|
||||
string playerModeSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Input",
|
||||
"PlayerModeController.cs"));
|
||||
Assert.Contains("_input.EndMouseLook();", playerModeSource,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"private void OnFocusChanged(bool focused)",
|
||||
|
|
@ -433,6 +447,50 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"_gameplayInputFrame?.EndMouseLook();");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindow_DelegatesTheCompleteLocalTeleportLifetime()
|
||||
{
|
||||
string root = FindRepoRoot();
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
string networkSource = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Physics",
|
||||
"LiveEntityNetworkUpdateController.cs"));
|
||||
|
||||
Assert.Contains(
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(1, CountOccurrences(
|
||||
source,
|
||||
"_localPlayerTeleport!.Tick(frameDelta);"));
|
||||
Assert.DoesNotContain("_teleportTransit", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_teleportAnim", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_teleportViewPlane", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_pendingTeleport", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("AimTeleportDestination", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("ResetTeleportTransitState", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PlaceTeleportArrival", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TryActivatePendingTeleportPresentation", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Action<WorldSession.EntityPositionUpdate>", networkSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("ILocalPlayerTeleportNetworkSink", networkSource,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_liveEntityLiveness?.Tick(ClientTimerNow());",
|
||||
"_localPlayerTeleport!.Tick(frameDelta);",
|
||||
"_playerModeAutoEntry?.TryEnter();");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameplayInputOwnersUseTypedSeamsWithoutGameWindowBackReferences()
|
||||
{
|
||||
|
|
@ -498,6 +556,45 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
BindingFlags.Instance | BindingFlags.NonPublic),
|
||||
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||
|
||||
FieldInfo approachCompletions = Assert.Single(
|
||||
typeof(AcDream.App.Input.PlayerModeController).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic),
|
||||
field => field.Name == "_approachCompletions");
|
||||
Assert.Equal(
|
||||
typeof(AcDream.App.Interaction.IPlayerApproachCompletionLifetimeOwner),
|
||||
approachCompletions.FieldType);
|
||||
Assert.DoesNotContain(
|
||||
typeof(AcDream.App.Input.PlayerModeController).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic),
|
||||
field => field.FieldType
|
||||
== typeof(AcDream.App.Interaction.SelectionInteractionController));
|
||||
Assert.DoesNotContain(
|
||||
typeof(AcDream.App.Interaction.PlayerApproachCompletionState).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic),
|
||||
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||
|
||||
string playerModeSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Input",
|
||||
"PlayerModeController.cs"));
|
||||
AssertAppearsInOrder(
|
||||
playerModeSource,
|
||||
"controller.PreparePositionForCommit(",
|
||||
"_camera.EnterChaseMode(legacyCamera, retailCamera);",
|
||||
"EntityPhysicsHost.SelectStableHostWithoutRebind(",
|
||||
"_shadow.SyncPose(",
|
||||
"EntityPhysicsHost.InstallOrRebind(",
|
||||
"playerEntity.SetPosition(initial.Position);",
|
||||
"controller.CommitPreparedPosition();",
|
||||
"_controllerSlot.Controller = controller;",
|
||||
"_mode.IsPlayerMode = true;");
|
||||
Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("_camera.RestoreState(priorCamera);", playerModeSource,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
string mouseLookSource = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public sealed class AutoEnterPlayerModeTests
|
|||
// Defaults TRUE: the world-reveal hold is not under test in the
|
||||
// original K.2 cases (see WorldNotReady test for it).
|
||||
public bool WorldReady = true;
|
||||
public bool PlayerModeActive;
|
||||
public int EnteredCount;
|
||||
|
||||
public PlayerModeAutoEntry Build() =>
|
||||
|
|
@ -35,7 +36,8 @@ public sealed class AutoEnterPlayerModeTests
|
|||
isPlayerEntityPresent: () => PlayerEntityPresent,
|
||||
isPlayerControllerReady: () => PlayerControllerReady,
|
||||
isWorldReady: () => WorldReady,
|
||||
enterPlayerMode: () => EnteredCount++);
|
||||
enterPlayerMode: () => EnteredCount++,
|
||||
isPlayerModeActive: () => PlayerModeActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -180,4 +182,25 @@ public sealed class AutoEnterPlayerModeTests
|
|||
Assert.True(guard.TryEnter());
|
||||
Assert.Equal(1, s.EnteredCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalEntryThatAlreadyActivatedPlayerMode_RetiresArmedGuard()
|
||||
{
|
||||
var s = new State
|
||||
{
|
||||
LiveInWorld = true,
|
||||
PlayerEntityPresent = true,
|
||||
PlayerControllerReady = true,
|
||||
};
|
||||
var guard = s.Build();
|
||||
guard.Arm();
|
||||
|
||||
// F751/player-mode activation occurs before the auto-entry phase in
|
||||
// the update graph. The guard must not rebuild that PortalSpace owner.
|
||||
s.PlayerModeActive = true;
|
||||
|
||||
Assert.False(guard.TryEnter());
|
||||
Assert.False(guard.IsArmed);
|
||||
Assert.Equal(0, s.EnteredCount);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue