435 lines
14 KiB
C#
435 lines
14 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));
|
|
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);
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|