Move fly/chase publication, combat target tracking, and local player projection behind typed runtime owners. Preserve the inbound-created projection/reconcile barrier while removing GameWindow callbacks and duplicate shadow helpers.
853 lines
32 KiB
C#
853 lines
32 KiB
C#
using System.Reflection;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Update;
|
|
using AcDream.App.World;
|
|
|
|
namespace AcDream.App.Tests.World;
|
|
|
|
public sealed class UpdateFrameOrchestratorTests
|
|
{
|
|
[Fact]
|
|
public void Tick_PreservesTheCompleteAcceptedPhaseGraph()
|
|
{
|
|
var calls = new List<string>();
|
|
UpdateFrameOrchestrator frame = Create(calls);
|
|
|
|
frame.Tick(new UpdateFrameInput(1.0 / 60.0));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"teardown",
|
|
"clock",
|
|
"streaming",
|
|
"input",
|
|
"objects",
|
|
"network",
|
|
"commands",
|
|
"ordinary-reconcile",
|
|
"liveness",
|
|
"teleport",
|
|
"auto-entry",
|
|
"camera",
|
|
],
|
|
calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConditionalReconciles_RemainAtTheirOwningEdges()
|
|
{
|
|
// Checkpoint A freezes only the outer ownership contract. Checkpoints
|
|
// E and F must replace these probes with production teleport/camera
|
|
// owner tests before the conditional edges can be claimed guarded.
|
|
var calls = new List<string>();
|
|
UpdateFrameOrchestrator frame = Create(
|
|
calls,
|
|
teleportPlace: true,
|
|
inboundCreatedPlayer: true);
|
|
|
|
frame.Tick(new UpdateFrameInput(1.0 / 60.0));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"teardown",
|
|
"clock",
|
|
"streaming",
|
|
"input",
|
|
"objects",
|
|
"network",
|
|
"commands",
|
|
"ordinary-reconcile",
|
|
"liveness",
|
|
"teleport-place",
|
|
"teleport-reconcile",
|
|
"teleport-reveal",
|
|
"auto-entry",
|
|
"inbound-player-projection",
|
|
"inbound-player-reconcile",
|
|
"camera",
|
|
],
|
|
calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(double.NaN)]
|
|
[InlineData(double.NegativeInfinity)]
|
|
[InlineData(double.PositiveInfinity)]
|
|
[InlineData(-1.0)]
|
|
[InlineData(0.0)]
|
|
[InlineData(double.MaxValue)]
|
|
public void InvalidSimulationDelta_BecomesZeroWithoutSuppressingOuterPhases(
|
|
double hostDelta)
|
|
{
|
|
var calls = new List<string>();
|
|
var observed = new FrameObservations();
|
|
UpdateFrameOrchestrator frame = Create(calls, observed: observed);
|
|
|
|
frame.Tick(new UpdateFrameInput(hostDelta));
|
|
|
|
Assert.Equal(
|
|
[
|
|
"teardown", "clock", "streaming", "input", "objects",
|
|
"network", "commands", "ordinary-reconcile", "liveness",
|
|
"teleport", "auto-entry", "camera",
|
|
],
|
|
calls);
|
|
Assert.Equal([0.0], observed.PublishedTimes);
|
|
Assert.Equal([0.0], observed.Input.Select(value => value.SimulationDeltaSeconds));
|
|
Assert.Equal([0f], observed.LiveDeltas);
|
|
Assert.Equal([0f], observed.TeleportDeltas);
|
|
Assert.Equal([0.0], observed.Camera.Select(value => value.SimulationDeltaSeconds));
|
|
Assert.All(
|
|
observed.Input.Concat(observed.Camera),
|
|
timing => Assert.Equal(0.0, timing.ScriptTime));
|
|
}
|
|
|
|
[Fact]
|
|
public void Clock_AdvancesOnceAndPublishesTheSameTimeToEveryConsumer()
|
|
{
|
|
var calls = new List<string>();
|
|
var observed = new FrameObservations();
|
|
UpdateFrameOrchestrator frame = Create(calls, observed: observed);
|
|
|
|
frame.Tick(new UpdateFrameInput(0.25));
|
|
frame.Tick(new UpdateFrameInput(double.NaN));
|
|
frame.Tick(new UpdateFrameInput(0.5));
|
|
|
|
Assert.Equal([0.25, 0.25, 0.75], observed.PublishedTimes);
|
|
Assert.Equal(
|
|
[0.25, 0.25, 0.75],
|
|
observed.Input.Select(value => value.ScriptTime));
|
|
Assert.Equal(
|
|
[0.25, 0.25, 0.75],
|
|
observed.Camera.Select(value => value.ScriptTime));
|
|
Assert.Equal([0.25f, 0f, 0.5f], observed.LiveDeltas);
|
|
Assert.Equal([0.25f, 0f, 0.5f], observed.TeleportDeltas);
|
|
Assert.Equal(
|
|
[0.25, 0.0, 0.5],
|
|
observed.Input.Select(value => value.SimulationDeltaSeconds));
|
|
Assert.Equal(
|
|
[0.25, 0.0, 0.5],
|
|
observed.Camera.Select(value => value.SimulationDeltaSeconds));
|
|
}
|
|
|
|
[Fact]
|
|
public void Clock_RejectsFiniteDeltaAboveTheSinglePrecisionConsumerRange()
|
|
{
|
|
double tooLarge = (double)float.MaxValue * 2.0;
|
|
|
|
Assert.True(double.IsFinite(tooLarge));
|
|
Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(tooLarge));
|
|
}
|
|
|
|
[Fact]
|
|
public void AggregateTeardownFailure_IsReportedAndRetriedNextFrame()
|
|
{
|
|
var calls = new List<string>();
|
|
var teardown = new RecordingTeardown(calls)
|
|
{
|
|
Failure = new AggregateException(new InvalidOperationException("transient")),
|
|
};
|
|
var failures = new RecordingFailureSink();
|
|
UpdateFrameOrchestrator frame = Create(
|
|
calls,
|
|
teardown: teardown,
|
|
failureSink: failures);
|
|
|
|
frame.Tick(new UpdateFrameInput(0.1));
|
|
teardown.Failure = null;
|
|
frame.Tick(new UpdateFrameInput(0.1));
|
|
|
|
Assert.Equal(2, teardown.Attempts);
|
|
Assert.Single(failures.Errors);
|
|
string[] oneFrame =
|
|
[
|
|
"teardown", "clock", "streaming", "input", "objects", "network",
|
|
"commands", "ordinary-reconcile", "liveness", "teleport",
|
|
"auto-entry", "camera",
|
|
];
|
|
Assert.Equal(oneFrame.Concat(oneFrame), calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonAggregateTeardownFailure_PropagatesBeforeTimeOrLaterPhases()
|
|
{
|
|
var calls = new List<string>();
|
|
var teardown = new RecordingTeardown(calls)
|
|
{
|
|
Failure = new InvalidOperationException("fatal"),
|
|
};
|
|
UpdateFrameOrchestrator frame = Create(calls, teardown: teardown);
|
|
|
|
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
|
|
() => frame.Tick(new UpdateFrameInput(0.1)));
|
|
|
|
Assert.Equal("fatal", error.Message);
|
|
Assert.Equal(["teardown"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrchestrationTypes_UseOnlyTheExplicitTypedOwnerGraph()
|
|
{
|
|
Type[] expectedFieldTypes =
|
|
[
|
|
typeof(IUpdateFrameTeardownPhase),
|
|
typeof(IUpdateFrameFailureSink),
|
|
typeof(UpdateFrameClock),
|
|
typeof(IUpdateFrameScriptClockPublisher),
|
|
typeof(IStreamingFramePhase),
|
|
typeof(IGameplayInputFramePhase),
|
|
typeof(IRetailLiveFramePhase),
|
|
typeof(ILiveEntityLivenessFramePhase),
|
|
typeof(ILocalPlayerTeleportFramePhase),
|
|
typeof(IPlayerModeAutoEntryFramePhase),
|
|
typeof(ICameraFramePhase),
|
|
];
|
|
|
|
FieldInfo[] fields = typeof(UpdateFrameOrchestrator).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.Equal(
|
|
expectedFieldTypes.OrderBy(type => type.FullName),
|
|
fields.Select(field => field.FieldType).OrderBy(type => type.FullName));
|
|
Assert.All(fields, field =>
|
|
{
|
|
Assert.NotEqual(typeof(GameWindow), field.FieldType);
|
|
Assert.False(typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
});
|
|
|
|
Type[] phaseInterfaces = expectedFieldTypes.Where(type => type.IsInterface).ToArray();
|
|
Assert.DoesNotContain(
|
|
phaseInterfaces,
|
|
phaseInterface => phaseInterface.IsAssignableFrom(typeof(GameWindow)));
|
|
|
|
Type[] productionPhaseOwners = typeof(GameWindow).Assembly.GetTypes()
|
|
.Where(type => !type.IsAbstract && !type.IsInterface)
|
|
.Where(type => phaseInterfaces.Any(contract => contract.IsAssignableFrom(type)))
|
|
.ToArray();
|
|
foreach (Type owner in productionPhaseOwners)
|
|
{
|
|
FieldInfo[] ownerFields = owner.GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow));
|
|
if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController))
|
|
{
|
|
// Checkpoint D replaced input capture with its typed owner.
|
|
// Checkpoint F still owns the remaining player presentation
|
|
// callback composition. No other phase owner may add one.
|
|
Assert.Contains(
|
|
ownerFields,
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
Assert.DoesNotContain(
|
|
ownerFields,
|
|
field => field.FieldType == typeof(Func<AcDream.App.Input.MovementInput>));
|
|
continue;
|
|
}
|
|
Assert.DoesNotContain(
|
|
ownerFields,
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
}
|
|
|
|
Assert.DoesNotContain(
|
|
typeof(GameWindow).GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.Name == "_physicsScriptGameTime");
|
|
}
|
|
|
|
[Fact]
|
|
public void PhysicsScriptClockConsumers_RetainTheTypedSourceNotGameWindowClosures()
|
|
{
|
|
Type[] consumers =
|
|
[
|
|
typeof(DatLiveEntityProjectionMaterializer),
|
|
typeof(AcDream.App.Physics.LiveEntityNetworkUpdateController),
|
|
];
|
|
|
|
foreach (Type consumer in consumers)
|
|
{
|
|
FieldInfo clock = Assert.Single(
|
|
consumer.GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.Name == "_gameTime");
|
|
Assert.Equal(typeof(IPhysicsScriptTimeSource), clock.FieldType);
|
|
Assert.DoesNotContain(
|
|
consumer.GetFields(BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.FieldType == typeof(GameWindow));
|
|
}
|
|
|
|
Assert.False(typeof(IPhysicsScriptTimeSource).IsAssignableFrom(typeof(GameWindow)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ProductionFrame_PublishesPhysicsScriptTimeExactlyOnce()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
|
|
Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractedLiveObjectSource_PinsRetailAndRegisteredAdaptationOrder()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Update",
|
|
"LiveObjectFrameController.cs"));
|
|
|
|
AssertAppearsInOrder(
|
|
source,
|
|
"_localPlayerFrame.AdvanceBeforeNetwork",
|
|
"_selectionInteractions?.DrainOutbound",
|
|
"_animations.Tick",
|
|
"_staticAnimations.Tick",
|
|
"_animationPresenter.Present",
|
|
"_equippedChildren.Tick",
|
|
"_staticAnimations.ProcessHooks",
|
|
"_effects.Tick");
|
|
AssertAppearsInOrder(
|
|
source,
|
|
"_translucencyFades.AdvanceAll",
|
|
"_animationHooks.Drain",
|
|
"_entityEffects.RefreshLiveOwnerPoses",
|
|
"_particleSink.RefreshAttachedEmitters",
|
|
"_lights.Refresh",
|
|
"_particleVisibility.Apply",
|
|
"_particles.Tick",
|
|
"_scripts.Tick");
|
|
AssertAppearsInOrder(
|
|
source,
|
|
"public void Reconcile()",
|
|
"_entityEffects.RefreshLiveOwnerPoses",
|
|
"_equippedChildren.Tick",
|
|
"_particleSink.RefreshAttachedEmitters",
|
|
"_lights.Refresh");
|
|
|
|
Assert.Equal(1, CountOccurrences(source, "_staticAnimations.Tick("));
|
|
Assert.Equal(1, CountOccurrences(source, "_staticAnimations.ProcessHooks("));
|
|
Assert.Equal(1, CountOccurrences(source, "_effects.Tick("));
|
|
Assert.Equal(1, CountOccurrences(source, "_particles.Tick("));
|
|
Assert.Equal(1, CountOccurrences(source, "_scripts.Tick("));
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
|
|
Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source);
|
|
Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source);
|
|
Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source);
|
|
Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("ILiveAnimationPresentationContext", source,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindow_DelegatesTheCompleteStreamingFrameBody()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
|
|
Assert.Contains("new AcDream.App.Streaming.StreamingFrameController(", source);
|
|
Assert.Equal(1, CountOccurrences(source, "_streamingFrame.Tick();"));
|
|
AssertAppearsInOrder(
|
|
source,
|
|
"_streamingFrame.Tick();",
|
|
"_gameplayInputFrame!.Tick(frameTiming);",
|
|
"_liveFrameCoordinator.Tick(frameDelta);");
|
|
Assert.DoesNotContain("_streamingController.Tick(observerCx", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("DungeonStreamingGate.Compute", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("DrainRescued()", source, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindow_DelegatesTheCompleteGameplayInputFrameBody()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
|
|
Assert.Contains("new AcDream.App.Input.GameplayInputFrameController(", source);
|
|
Assert.Equal(1, CountOccurrences(source, "_gameplayInputFrame!.Tick(frameTiming);"));
|
|
Assert.DoesNotContain("_inputDispatcher?.Tick()", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("TryTakeRawSample", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("_combatAttackController?.Tick()", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("CaptureMovementInput", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("EndMouseLookAndRestoreCursor", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("HideCursorForMouseLook", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("RestoreCursorAfterMouseLook", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("CanStartLiveCombatAttack", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("SendLiveCombatAttack", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("PreparePlayerForAttackRequest", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("DumpMovementTruthOutbound", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("DumpMovementTruthServerEcho", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("wantCaptureMouse: ()", source,
|
|
StringComparison.Ordinal);
|
|
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(
|
|
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)",
|
|
"if (!focused)",
|
|
"_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()
|
|
{
|
|
Type[] owners =
|
|
[
|
|
typeof(AcDream.App.Input.GameplayInputFrameController),
|
|
typeof(AcDream.App.Input.DispatcherMovementInputSource),
|
|
typeof(AcDream.App.Input.MouseLookController),
|
|
];
|
|
|
|
foreach (Type owner in owners)
|
|
{
|
|
FieldInfo[] fields = owner.GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
|
|
Assert.DoesNotContain(
|
|
fields,
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
}
|
|
|
|
Assert.DoesNotContain(
|
|
typeof(AcDream.App.Input.MouseLookController).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.FieldType == typeof(Silk.NET.Input.IMouse));
|
|
|
|
FieldInfo combatOperations = Assert.Single(
|
|
typeof(AcDream.App.Combat.CombatAttackController).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.Name == "_operations");
|
|
Assert.Equal(
|
|
typeof(AcDream.App.Combat.ICombatAttackOperations),
|
|
combatOperations.FieldType);
|
|
Assert.DoesNotContain(
|
|
typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
FieldInfo combatTargets = Assert.Single(
|
|
typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.Name == "_targets");
|
|
Assert.Equal(
|
|
typeof(AcDream.App.Combat.ICombatAttackTargetSource),
|
|
combatTargets.FieldType);
|
|
Assert.DoesNotContain(
|
|
typeof(AcDream.App.Combat.CombatAttackTargetSource).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
Assert.DoesNotContain(
|
|
typeof(AcDream.App.Combat.CombatAttackTargetSource).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.FieldType
|
|
== typeof(AcDream.App.Interaction.SelectionInteractionController));
|
|
|
|
FieldInfo outboundDiagnostics = Assert.Single(
|
|
typeof(AcDream.App.Input.LocalPlayerOutboundController).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic),
|
|
field => field.Name == "_diagnostic");
|
|
Assert.Equal(
|
|
typeof(AcDream.App.Input.IMovementTruthDiagnosticSink),
|
|
outboundDiagnostics.FieldType);
|
|
Assert.DoesNotContain(
|
|
typeof(AcDream.App.Input.MovementTruthDiagnosticController).GetFields(
|
|
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",
|
|
"AcDream.App",
|
|
"Input",
|
|
"MouseLookController.cs"));
|
|
AssertAppearsInOrder(
|
|
mouseLookSource,
|
|
"controller?.StopMouseDrift",
|
|
"retail.FilterMouseDelta",
|
|
"_state.ApplyDelta");
|
|
|
|
string localPlayerSource = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Input",
|
|
"RetailLocalPlayerFrameController.cs"));
|
|
Assert.DoesNotContain("Func<MovementInput>", localPlayerSource,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("IMovementInputSource", localPlayerSource,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void CameraAndLocalPlayerFrameOwnersUseTypedSeamsWithoutWindowCallbacks()
|
|
{
|
|
Type[] owners =
|
|
[
|
|
typeof(AcDream.App.Rendering.CameraFrameController),
|
|
typeof(AcDream.App.Input.RetailLocalPlayerFrameController),
|
|
typeof(AcDream.App.Input.LocalPlayerProjectionController),
|
|
typeof(AcDream.App.Input.LiveLocalPlayerFrameRuntime),
|
|
typeof(AcDream.App.Input.LiveLocalPlayerProjectionRuntime),
|
|
typeof(AcDream.App.Combat.CombatCameraTargetSource),
|
|
];
|
|
|
|
foreach (Type owner in owners)
|
|
{
|
|
FieldInfo[] fields = owner.GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
|
|
Assert.DoesNotContain(
|
|
fields,
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
}
|
|
|
|
string root = FindRepoRoot();
|
|
string source = File.ReadAllText(Path.Combine(
|
|
root,
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
Assert.Equal(1, CountOccurrences(source, "_cameraFrame.Tick(frameTiming);"));
|
|
Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("GetCombatCameraTargetPoint()", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("_cameraController.Fly.Update(", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("_localPlayerFrame.TryGetPresentationAfterNetwork", source,
|
|
StringComparison.Ordinal);
|
|
AssertAppearsInOrder(
|
|
source,
|
|
"_localPlayerTeleport!.Tick(frameDelta);",
|
|
"_playerModeAutoEntry?.TryEnter();",
|
|
"_cameraFrame.Tick(frameTiming);");
|
|
|
|
string cameraSource = File.ReadAllText(Path.Combine(
|
|
root,
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"CameraFrameController.cs"));
|
|
AssertAppearsInOrder(
|
|
cameraSource,
|
|
"_localFrame.TryGetPresentationAfterNetwork",
|
|
"_spatialReconciler.Reconcile();",
|
|
"legacy.Update(",
|
|
"retail?.Update(");
|
|
}
|
|
|
|
private static UpdateFrameOrchestrator Create(
|
|
List<string> calls,
|
|
RecordingTeardown? teardown = null,
|
|
RecordingFailureSink? failureSink = null,
|
|
FrameObservations? observed = null,
|
|
bool teleportPlace = false,
|
|
bool inboundCreatedPlayer = false)
|
|
{
|
|
teardown ??= new RecordingTeardown(calls);
|
|
failureSink ??= new RecordingFailureSink();
|
|
return new UpdateFrameOrchestrator(
|
|
teardown,
|
|
failureSink,
|
|
new UpdateFrameClock(),
|
|
new RecordingClockPublisher(calls, observed),
|
|
new RecordingStreaming(calls),
|
|
new RecordingInput(calls, observed),
|
|
new RecordingLiveFrame(calls, observed),
|
|
new RecordingLiveness(calls),
|
|
new RecordingTeleport(calls, observed, teleportPlace),
|
|
new RecordingAutoEntry(calls),
|
|
new RecordingCamera(calls, observed, inboundCreatedPlayer));
|
|
}
|
|
|
|
private sealed class RecordingTeardown(List<string> calls)
|
|
: IUpdateFrameTeardownPhase
|
|
{
|
|
public Exception? Failure { get; set; }
|
|
public int Attempts { get; private set; }
|
|
|
|
public void RetryPendingTeardowns()
|
|
{
|
|
calls.Add("teardown");
|
|
Attempts++;
|
|
if (Failure is not null)
|
|
throw Failure;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingFailureSink : IUpdateFrameFailureSink
|
|
{
|
|
public List<AggregateException> Errors { get; } = [];
|
|
public void ReportTeardownFailure(AggregateException error) => Errors.Add(error);
|
|
}
|
|
|
|
private sealed class RecordingClockPublisher(
|
|
List<string> calls,
|
|
FrameObservations? observed) : IUpdateFrameScriptClockPublisher
|
|
{
|
|
public void PublishTime(double scriptTime)
|
|
{
|
|
calls.Add("clock");
|
|
observed?.PublishedTimes.Add(scriptTime);
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingStreaming(List<string> calls) : IStreamingFramePhase
|
|
{
|
|
public void Tick() => calls.Add("streaming");
|
|
}
|
|
|
|
private sealed class RecordingInput(
|
|
List<string> calls,
|
|
FrameObservations? observed) : IGameplayInputFramePhase
|
|
{
|
|
public void Tick(UpdateFrameTiming timing)
|
|
{
|
|
calls.Add("input");
|
|
observed?.Input.Add(timing);
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingLiveFrame(
|
|
List<string> calls,
|
|
FrameObservations? observed) : IRetailLiveFramePhase
|
|
{
|
|
public void Tick(float deltaSeconds)
|
|
{
|
|
observed?.LiveDeltas.Add(deltaSeconds);
|
|
calls.Add("objects");
|
|
calls.Add("network");
|
|
calls.Add("commands");
|
|
calls.Add("ordinary-reconcile");
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingLiveness(List<string> calls)
|
|
: ILiveEntityLivenessFramePhase
|
|
{
|
|
public void Tick() => calls.Add("liveness");
|
|
}
|
|
|
|
private sealed class RecordingTeleport(
|
|
List<string> calls,
|
|
FrameObservations? observed,
|
|
bool place)
|
|
: ILocalPlayerTeleportFramePhase
|
|
{
|
|
public void Tick(float deltaSeconds)
|
|
{
|
|
observed?.TeleportDeltas.Add(deltaSeconds);
|
|
if (!place)
|
|
{
|
|
calls.Add("teleport");
|
|
return;
|
|
}
|
|
|
|
calls.Add("teleport-place");
|
|
calls.Add("teleport-reconcile");
|
|
calls.Add("teleport-reveal");
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingAutoEntry(List<string> calls)
|
|
: IPlayerModeAutoEntryFramePhase
|
|
{
|
|
public void TryEnter() => calls.Add("auto-entry");
|
|
}
|
|
|
|
private sealed class RecordingCamera(
|
|
List<string> calls,
|
|
FrameObservations? observed,
|
|
bool inboundCreatedPlayer) : ICameraFramePhase
|
|
{
|
|
public void Tick(UpdateFrameTiming timing)
|
|
{
|
|
if (inboundCreatedPlayer)
|
|
{
|
|
calls.Add("inbound-player-projection");
|
|
calls.Add("inbound-player-reconcile");
|
|
}
|
|
|
|
calls.Add("camera");
|
|
observed?.Camera.Add(timing);
|
|
}
|
|
}
|
|
|
|
private sealed class FrameObservations
|
|
{
|
|
public List<double> PublishedTimes { get; } = [];
|
|
public List<UpdateFrameTiming> Input { get; } = [];
|
|
public List<float> LiveDeltas { get; } = [];
|
|
public List<float> TeleportDeltas { get; } = [];
|
|
public List<UpdateFrameTiming> Camera { get; } = [];
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
return directory.FullName;
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
|
}
|
|
|
|
private static void AssertAppearsInOrder(string source, params string[] markers)
|
|
{
|
|
int previous = -1;
|
|
foreach (string marker in markers)
|
|
{
|
|
int current = source.IndexOf(marker, previous + 1, StringComparison.Ordinal);
|
|
Assert.True(current >= 0, $"Missing source marker: {marker}");
|
|
Assert.True(current > previous, $"Out-of-order source marker: {marker}");
|
|
previous = current;
|
|
}
|
|
}
|
|
|
|
private static int CountOccurrences(string source, string marker) =>
|
|
source.Split(marker, StringSplitOptions.None).Length - 1;
|
|
}
|