acdream/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
Erik 75acae02d6 fix(world): complete recall before teleport hide
Match retail's update ordering so object animation, particles, and scripts advance before inbound teleport state is applied. Separate input-originated movement from post-network autonomous position output, and reconcile presentation without a second physics tick so recall cannot resume after arrival.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-16 06:37:29 +02:00

151 lines
5.5 KiB
C#

using System.Numerics;
using AcDream.App.Input;
using AcDream.App.World;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Input;
public sealed class RetailLocalPlayerFrameControllerTests
{
[Fact]
public void F751BetweenPhases_CannotRelocateOrReplayCapturedOutboundMovement()
{
PlayerMovementController controller = CreateController();
var calls = new List<string>();
var preNetwork = new List<(PlayerState State, MovementResult Movement)>();
var postNetwork = new List<PlayerState>();
var local = new RetailLocalPlayerFrameController(
canPresentPlayer: () => true,
getController: () => controller,
captureInput: () => new MovementInput(Forward: true),
resolveLocalEntityId: () => 7u,
handleTargeting: () => calls.Add("target"),
isHidden: () => false,
project: (_, _, _) => calls.Add("project"),
sendPreNetwork: (owner, movement, _) =>
{
calls.Add("pre-outbound");
preNetwork.Add((owner.State, movement));
},
sendPostNetwork: (owner, _) =>
{
calls.Add("post-position");
postNetwork.Add(owner.State);
});
var live = new RetailLiveFrameCoordinator(
dt =>
{
calls.Add("objects");
local.AdvanceBeforeNetwork(dt);
},
() =>
{
calls.Add("f751");
controller.State = PlayerState.PortalSpace;
},
() =>
{
calls.Add("commands");
local.RunPostNetworkCommandPhase();
},
() => calls.Add("spatial"));
live.Tick(1f / 60f);
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
Assert.Equal(
[
"objects", "target", "project", "pre-outbound", "f751",
"commands", "project", "post-position", "spatial",
],
calls);
Assert.Single(preNetwork);
Assert.Equal(PlayerState.InWorld, preNetwork[0].State);
Assert.True(preNetwork[0].Movement.ShouldSendMovementEvent);
Assert.Equal([PlayerState.PortalSpace], postNetwork);
Assert.Equal(PlayerState.PortalSpace, controller.State);
Assert.True(presentation.AdvancedBeforeNetwork);
}
[Fact]
public void OwnerCreatedByInboundPass_IsProjectedWithoutPhysicsOrOutboundTick()
{
PlayerMovementController? controller = null;
int projections = 0;
int preNetwork = 0;
int postNetwork = 0;
var local = new RetailLocalPlayerFrameController(
canPresentPlayer: () => controller is not null,
getController: () => controller,
captureInput: () => new MovementInput(Forward: true),
resolveLocalEntityId: () => 9u,
handleTargeting: () => { },
isHidden: () => false,
project: (_, _, _) => projections++,
sendPreNetwork: (_, _, _) => preNetwork++,
sendPostNetwork: (_, _) => postNetwork++);
local.AdvanceBeforeNetwork(1f / 60f);
controller = CreateController();
float timeBefore = controller.SimTimeSeconds;
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
Assert.False(presentation.AdvancedBeforeNetwork);
Assert.Equal(timeBefore, controller.SimTimeSeconds);
Assert.Equal(1, projections);
Assert.Equal(0, preNetwork);
Assert.Equal(0, postNetwork);
Assert.False(presentation.Movement.ShouldSendMovementEvent);
}
[Fact]
public void OrdinaryInboundRootMutation_IsReconciledWithoutSecondPhysicsTick()
{
PlayerMovementController controller = CreateController();
Vector3 projectedRoot = default;
var local = new RetailLocalPlayerFrameController(
canPresentPlayer: () => true,
getController: () => controller,
captureInput: () => new MovementInput(),
resolveLocalEntityId: () => 7u,
handleTargeting: () => { },
isHidden: () => false,
project: (_, movement, _) => projectedRoot = movement.RenderPosition,
sendPreNetwork: (_, _, _) => { },
sendPostNetwork: (_, _) => { });
var live = new RetailLiveFrameCoordinator(
local.AdvanceBeforeNetwork,
() => projectedRoot = new Vector3(999f, 999f, 999f),
local.RunPostNetworkCommandPhase,
() => { });
live.Tick(1f / 60f);
float timeAfterOneTick = controller.SimTimeSeconds;
Assert.Equal(controller.RenderPosition, projectedRoot);
Assert.Equal(1f / 60f, timeAfterOneTick);
}
private static PlayerMovementController CreateController()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = i;
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
return controller;
}
}