refactor(runtime): define the update-frame contract
This commit is contained in:
parent
a36a7015c4
commit
99a3e819c4
8 changed files with 646 additions and 26 deletions
435
tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
Normal file
435
tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
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.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue