Ymir/tests/Ymir.Tests/YmirSessionTests.cs
2026-07-22 00:01:47 +01:00

485 lines
23 KiB
C#

using Ymir.Core;
namespace Ymir.Tests;
public sealed class YmirSessionTests
{
[Fact]
public void RetainedBodyAdvancesWithoutSnapshotResubmission()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"retained-motion",
[Body("ship", positionX: 0.0f, velocityX: 2.0f)]));
var first = session.Step(Step("step-1", 0, 0.25f));
var second = session.Step(Step("step-2", 1, 0.25f));
Assert.Equal(YmirCommandOutcome.Accepted, first.Receipt.Outcome);
Assert.Equal(1, first.Revision);
Assert.Equal(2, second.Revision);
Assert.Equal(2, second.StepIndex);
Assert.True(second.World.Bodies.Single().Position.X > first.World.Bodies.Single().Position.X);
Assert.Equal(2.0f, second.World.Bodies.Single().Velocity.X, 4);
}
[Fact]
public void ForceReceiptIsIdempotentAndForceIsConsumedByOneStep()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest("force-once", [Body("ship")]));
var command = new YmirApplyForceCommand(Header("force", 0), "ship", new Vec2(10.0f, 0.0f));
var accepted = session.ApplyForce(command);
var replay = session.ApplyForce(command);
var conflict = session.ApplyForce(command with { Force = new Vec2(20.0f, 0.0f) });
var revisionConflict = session.ApplyForce(command with { Header = Header("force", 1) });
var forced = session.Step(Step("step-forced", 1, 0.1f));
var unforced = session.Step(Step("step-unforced", 2, 0.1f));
Assert.Same(accepted, replay);
Assert.Equal(1, accepted.AfterRevision);
Assert.Equal(2, session.Info.StepIndex);
Assert.Equal(YmirCommandError.CommandIdConflict, conflict.Error);
Assert.Equal(YmirCommandOutcome.Rejected, conflict.Outcome);
Assert.NotEqual(accepted.ReceiptId, conflict.ReceiptId);
Assert.Equal(YmirCommandError.CommandIdConflict, revisionConflict.Error);
Assert.Equal(forced.World.Bodies.Single().Velocity.X, unforced.World.Bodies.Single().Velocity.X, 4);
}
[Fact]
public void OmissionCannotRemoveBodyAndRemoveIsExplicit()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"explicit-remove",
[Body("a"), Body("b", positionX: 10.0f)]));
var velocity = session.SetVelocity(new YmirSetBodyVelocityCommand(
Header("set-a", 0), "a", new Vec2(1.0f, 0.0f), 0.0f));
var stepped = session.Step(Step("step", 1, 0.1f));
var removed = session.Remove(new YmirRemoveBodyCommand(Header("remove-b", 2), "b"));
Assert.Equal(YmirCommandOutcome.Accepted, velocity.Outcome);
Assert.Equal(2, stepped.World.Bodies.Count);
Assert.Equal(YmirCommandOutcome.Accepted, removed.Outcome);
Assert.Equal(["a"], session.Snapshot().Bodies.Select(body => body.Id).ToArray());
}
[Fact]
public void ContactEpisodeEmitsBeginThenEndThenNewBegin()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"contact-lifecycle",
[
Body("a", positionX: 0.0f, isStatic: true),
Body("b", positionX: 1.5f)
]));
var began = session.Step(Step("begin", 0, 0.01f));
var persistent = session.Step(Step("persistent", 1, 0.01f));
session.Teleport(new YmirTeleportBodyCommand(
Header("separate", 2), "b", new Vec2(10.0f, 0.0f), new Vec2(0.0f, 1.0f)));
var ended = session.Step(Step("end", 3, 0.01f));
session.Teleport(new YmirTeleportBodyCommand(
Header("return", 4), "b", new Vec2(1.5f, 0.0f), new Vec2(0.0f, 1.0f)));
var beganAgain = session.Step(Step("begin-again", 5, 0.01f));
var firstBegin = Assert.Single(began.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
Assert.DoesNotContain(persistent.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
var end = Assert.Single(ended.ContactFacts, fact => fact.Kind == YmirContactFactKind.End);
var secondBegin = Assert.Single(beganAgain.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
Assert.Equal(firstBegin.ContactId, end.ContactId);
Assert.NotEqual(firstBegin.ContactId, secondBegin.ContactId);
Assert.Equal("a", firstBegin.BodyA);
Assert.Equal("b", firstBegin.BodyB);
}
[Fact]
public void ReplayCheckpointPreservesContactLineageAndCommandLedger()
{
using var original = YmirSession.Create(new YmirSessionCreateRequest(
"checkpoint-contact",
[
Body("ship", positionX: 0.0f, isStatic: true),
Body("pickup", positionX: 1.5f)
]));
var began = original.Step(Step("begin", 0, 0.01f));
var firstBegin = Assert.Single(began.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
var checkpoint = original.Checkpoint();
var payload = YmirSessionCheckpointCodec.Encode(checkpoint);
checkpoint = YmirSessionCheckpointCodec.Decode(payload);
using var restored = YmirSession.Restore(checkpoint);
var duplicate = restored.Step(Step("begin", 0, 0.01f));
Assert.Equal(began.Receipt.ReceiptId, duplicate.Receipt.ReceiptId);
Assert.Equal(began.Receipt.Outcome, duplicate.Receipt.Outcome);
Assert.Equal(began.Receipt.BeforeRevision, duplicate.Receipt.BeforeRevision);
Assert.Equal(began.Receipt.AfterRevision, duplicate.Receipt.AfterRevision);
Assert.Equal(began.Receipt.ProducedFactIds, duplicate.Receipt.ProducedFactIds);
Assert.Equal(began.ContactFacts.Select(value => value.FactId), duplicate.ContactFacts.Select(value => value.FactId));
Assert.Equal(checkpoint.SessionGeneration, restored.Info.SessionGeneration);
var conflict = restored.Step(Step("begin", 0, 0.02f));
Assert.Equal(YmirCommandError.CommandIdConflict, conflict.Receipt.Error);
var persistent = restored.Step(Step("persistent", 1, 0.01f));
Assert.DoesNotContain(persistent.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
restored.Teleport(new YmirTeleportBodyCommand(
Header("separate", 2), "pickup", new Vec2(10.0f, 0.0f), new Vec2(0.0f, 1.0f)));
var ended = restored.Step(Step("end", 3, 0.01f));
var end = Assert.Single(ended.ContactFacts, fact => fact.Kind == YmirContactFactKind.End);
Assert.Equal(firstBegin.ContactId, end.ContactId);
restored.Teleport(new YmirTeleportBodyCommand(
Header("return", 4), "pickup", new Vec2(1.5f, 0.0f), new Vec2(0.0f, 1.0f)));
var beganAgain = restored.Step(Step("begin-again", 5, 0.01f));
var secondBegin = Assert.Single(beganAgain.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
Assert.NotEqual(firstBegin.ContactId, secondBegin.ContactId);
}
[Fact]
public void ReplayCheckpointRestoresPendingForceAndRejectsCorruptJournal()
{
using var original = YmirSession.Create(new YmirSessionCreateRequest(
"checkpoint-force", [Body("ship")]));
original.ApplyForce(new YmirApplyForceCommand(Header("force", 0), "ship", new Vec2(10.0f, 0.0f)));
var checkpoint = original.Checkpoint();
using var restored = YmirSession.Restore(checkpoint);
var stepped = restored.Step(Step("step", 1, 0.1f));
Assert.True(stepped.World.Bodies.Single().Velocity.X > 0.0f);
var corrupt = checkpoint with { Journal = [] };
Assert.Throws<InvalidOperationException>(() => YmirSession.Restore(corrupt));
var wrongRuntime = checkpoint with { RuntimeFingerprint = "some-other-solver" };
Assert.Throws<InvalidOperationException>(() => YmirSession.Restore(wrongRuntime));
var corruptIdentity = YmirSessionCheckpointCodec.Encode(checkpoint);
corruptIdentity[32] ^= 0xff;
Assert.Throws<InvalidDataException>(() => YmirSessionCheckpointCodec.Decode(corruptIdentity));
Assert.Throws<InvalidDataException>(() =>
YmirSessionCheckpointCodec.Decode(YmirSessionCheckpointCodec.Encode(checkpoint).Concat(new byte[] { 1 }).ToArray()));
}
[Fact]
public void IncrementalPersistenceWritesOnlyNewJournalEntriesAndRestoresBoundary()
{
using var original = YmirSession.Create(new YmirSessionCreateRequest(
"incremental-contact",
[Body("ship", positionX: 0.0f, isStatic: true), Body("pickup", positionX: 1.5f)]));
var began = original.Step(Step("begin", 0, 0.01f));
var begin = Assert.Single(began.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
var first = original.CapturePersistence(0);
var firstChunk = Assert.IsType<YmirSessionJournalChunk>(first.JournalChunk);
Assert.Equal(0, firstChunk.FirstEntryIndex);
Assert.Single(firstChunk.Entries);
var firstChunkBytes = YmirSessionCheckpointCodec.Encode(firstChunk);
var firstDescriptorBytes = YmirSessionCheckpointCodec.Encode(first.ResumeDescriptor);
var persistent = original.Step(Step("persistent", 1, 0.01f));
Assert.DoesNotContain(persistent.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
var second = original.CapturePersistence(first.ResumeDescriptor.JournalEntryCount);
var secondChunk = Assert.IsType<YmirSessionJournalChunk>(second.JournalChunk);
Assert.Equal(1, secondChunk.FirstEntryIndex);
Assert.Single(secondChunk.Entries);
var chunks = new[]
{
YmirSessionCheckpointCodec.DecodeJournalChunk(firstChunkBytes),
YmirSessionCheckpointCodec.DecodeJournalChunk(YmirSessionCheckpointCodec.Encode(secondChunk))
};
var descriptor = YmirSessionCheckpointCodec.DecodeResumeDescriptor(
YmirSessionCheckpointCodec.Encode(second.ResumeDescriptor));
using var restored = YmirSession.Restore(descriptor, chunks);
restored.Teleport(new YmirTeleportBodyCommand(
Header("separate", 2), "pickup", new Vec2(10.0f, 0.0f), new Vec2(0.0f, 1.0f)));
var ended = restored.Step(Step("end", 3, 0.01f));
Assert.Equal(begin.ContactId, Assert.Single(ended.ContactFacts, fact => fact.Kind == YmirContactFactKind.End).ContactId);
Assert.Throws<InvalidOperationException>(() => YmirSession.Restore(descriptor, [chunks[1]]));
var corruptDescriptor = firstDescriptorBytes.ToArray();
corruptDescriptor[24] ^= 0xff;
Assert.Throws<InvalidDataException>(() => YmirSessionCheckpointCodec.DecodeResumeDescriptor(corruptDescriptor));
}
[Fact]
public void QuiescentSessionCanRollToAnEmptyPersistenceGeneration()
{
using var original = YmirSession.Create(new YmirSessionCreateRequest(
"compact-quiescent", [Body("ship", velocityX: 2.0f)]));
original.Step(Step("step-1", 0, 0.1f));
original.Step(Step("step-2", 1, 0.1f));
var expectedWorld = original.Snapshot();
Assert.True(original.TryReplaceWithCompactedPersistenceBaseline(out var compacted));
using (compacted!)
{
var baseline = compacted!;
Assert.NotEqual(original.Info.SessionGeneration, baseline.Info.SessionGeneration);
Assert.True(original.Info.IsDisposed);
Assert.Equal(0, baseline.Info.Revision);
Assert.Equal(0, baseline.PersistenceJournalEntryCount);
AssertWorldEqual(expectedWorld, baseline.Snapshot());
var capture = baseline.CapturePersistence(0);
Assert.Null(capture.JournalChunk);
Assert.Equal(0, capture.ResumeDescriptor.JournalEntryCount);
using var restored = YmirSession.Restore(capture.ResumeDescriptor, []);
AssertWorldEqual(baseline.Snapshot(), restored.Snapshot());
}
}
[Fact]
public void PersistenceRolloverRefusesActiveContactsAndUnsteppedMutations()
{
using var touching = YmirSession.Create(new YmirSessionCreateRequest(
"compact-contact",
[Body("ship", positionX: 0.0f, isStatic: true), Body("pickup", positionX: 1.5f)]));
touching.Step(Step("begin", 0, 0.01f));
Assert.False(touching.TryReplaceWithCompactedPersistenceBaseline(out var contactBaseline));
Assert.Null(contactBaseline);
using var pendingForce = YmirSession.Create(new YmirSessionCreateRequest(
"compact-force", [Body("ship")]));
pendingForce.ApplyForce(new YmirApplyForceCommand(
Header("force", 0), "ship", new Vec2(10.0f, 0.0f)));
Assert.False(pendingForce.TryReplaceWithCompactedPersistenceBaseline(out var forceBaseline));
Assert.Null(forceBaseline);
}
[Fact]
public void RemoveAndRecreateChangesBodyGeneration()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"body-generation",
[Body("wall", isStatic: true), Body("pickup", positionX: 1.5f)]));
var first = session.Step(Step("begin-1", 0, 0.01f));
var firstFact = Assert.Single(first.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
session.Remove(new YmirRemoveBodyCommand(Header("remove", 1), "pickup"));
var removed = session.Step(Step("removed", 2, 0.01f));
session.Spawn(new YmirSpawnBodyCommand(Header("spawn", 3), Body("pickup", positionX: 1.5f)));
var second = session.Step(Step("begin-2", 4, 0.01f));
var secondFact = Assert.Single(second.ContactFacts, fact =>
fact.Kind == YmirContactFactKind.Begin &&
(fact.BodyA == "pickup" || fact.BodyB == "pickup"));
var firstGeneration = BodyGeneration(firstFact, "pickup");
Assert.Contains(removed.ContactFacts, fact =>
fact.Kind == YmirContactFactKind.End && fact.ContactId == firstFact.ContactId);
Assert.True(BodyGeneration(secondFact, "pickup") > firstGeneration);
Assert.NotEqual(firstFact.ContactId, secondFact.ContactId);
}
[Fact]
public void SnapshotImmediatelyReflectsCanonicalNativeMutationState()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"canonical-snapshot",
[Body("ship") with { Direction = new Vec2(0.0f, 2.0f) }]));
Assert.Equal(new Vec2(0.0f, 1.0f), session.Snapshot().Bodies.Single().Direction);
session.Teleport(new YmirTeleportBodyCommand(
Header("teleport", 0), "ship", new Vec2(3.0f, 4.0f), new Vec2(2.0f, 0.0f)));
var teleported = session.Snapshot().Bodies.Single();
Assert.Equal(1.0f, teleported.Direction!.Value.X, 5);
Assert.Equal(0.0f, teleported.Direction.Value.Y, 5);
Assert.Equal(new Vec2(3.0f, 4.0f), teleported.Position);
session.Configure(new YmirConfigureBodyCommand(
Header("static", 1), "ship", 2.0f, 1.0f, true, 0.5f));
var configured = session.Snapshot().Bodies.Single();
Assert.True(configured.IsStatic);
Assert.Equal(2.0f, configured.Radius);
Assert.Equal(0.5f, configured.Restitution);
}
[Fact]
public void StaleAndDisposedCommandsFailClosedWithoutMutation()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest("stale", [Body("ship")]));
var accepted = session.SetVelocity(new YmirSetBodyVelocityCommand(
Header("accepted", 0), "ship", new Vec2(1.0f, 0.0f), 0.0f));
var stale = session.SetVelocity(new YmirSetBodyVelocityCommand(
Header("stale", 0), "ship", new Vec2(100.0f, 0.0f), 0.0f));
var disposed = session.Dispose(new YmirDisposeSessionCommand(Header("dispose", 1)));
var afterDispose = session.Remove(new YmirRemoveBodyCommand(Header("late", 2), "ship"));
Assert.Equal(YmirCommandOutcome.Accepted, accepted.Outcome);
Assert.Equal(YmirCommandError.StaleRevision, stale.Error);
Assert.Equal(YmirCommandOutcome.Accepted, disposed.Outcome);
Assert.Equal(YmirCommandError.SessionDisposed, afterDispose.Error);
Assert.Equal(2, afterDispose.BeforeRevision);
Assert.Equal(afterDispose.BeforeRevision, afterDispose.AfterRevision);
}
[Fact]
public void TypedFingerprintRejectsDelimiterCollisionsAndNullPayloads()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest("fingerprints", []));
var accepted = session.Spawn(new YmirSpawnBodyCommand(
Header("spawn", 0), Body("body|with|delimiters")));
var conflict = session.Spawn(new YmirSpawnBodyCommand(
Header("spawn", 0), Body("body") with { Position = new Vec2(1.0f, 2.0f) }));
var nullBody = session.Spawn(new YmirSpawnBodyCommand(Header("null-body", 1), null!));
var nullFields = session.Step(new YmirStepSessionCommand(Header("null-fields", 1), 0.1f, null!));
Assert.Equal(YmirCommandOutcome.Accepted, accepted.Outcome);
Assert.Equal(YmirCommandError.CommandIdConflict, conflict.Error);
Assert.Equal(YmirCommandError.InvalidCommand, nullBody.Error);
Assert.Equal(YmirCommandError.InvalidCommand, nullFields.Receipt.Error);
Assert.Equal(1, session.Info.Revision);
}
[Fact]
public void StaticBodyVelocityCommandIsRejectedWithoutARevisionLie()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"static-velocity", [Body("wall", isStatic: true)]));
var receipt = session.SetVelocity(new YmirSetBodyVelocityCommand(
Header("move-wall", 0), "wall", new Vec2(10.0f, 0.0f), 2.0f));
Assert.Equal(YmirCommandOutcome.Rejected, receipt.Outcome);
Assert.Equal(YmirCommandError.StaticBody, receipt.Error);
Assert.Equal(0, session.Info.Revision);
Assert.Equal(Vec2.Zero, session.Snapshot().Bodies.Single().Velocity);
}
[Fact]
public void CollisionProfilesExcludeOwnGroupAndPayloadPeers()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"collision-profile",
[
Body("source") with
{
CollisionCategoryBits = 1,
CollisionMaskBits = 2,
CollisionGroupIndex = -7
},
Body("own-payload") with
{
CollisionCategoryBits = 2,
CollisionMaskBits = 1,
CollisionGroupIndex = -7
},
Body("other-payload") with
{
CollisionCategoryBits = 2,
CollisionMaskBits = 1,
CollisionGroupIndex = -8
}
]));
var result = session.Step(Step("profile-step", 0, 1.0f / 60.0f));
var begin = Assert.Single(result.ContactFacts, fact => fact.Kind == YmirContactFactKind.Begin);
Assert.Equal(new HashSet<string> { "source", "other-payload" }, new HashSet<string> { begin.BodyA, begin.BodyB });
}
[Fact]
public void BodyCanOptOutOfRadialFields()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"field-profile",
[Body("affected", positionX: 1.0f), Body("ignored", positionX: -5.0f) with { ParticipatesInFields = false }]));
var result = session.Step(new YmirStepSessionCommand(
Header("field-step", 0),
0.1f,
[new RadialField("gravity", Vec2.Zero, 10.0f, 10.0f)]));
Assert.NotEqual(0.0f, result.World.Bodies.Single(body => body.Id == "affected").Velocity.X);
Assert.Equal(0.0f, result.World.Bodies.Single(body => body.Id == "ignored").Velocity.X, 5);
}
[Fact]
public void KinematicTargetDoesNotAcceptBulletImpulse()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"bullet-kinematic",
[
Body("bullet", positionX: -10.0f, velocityX: 100.0f) with { IsBullet = true },
Body("target") with { IsKinematic = true }
]));
var ccdStep = session.Step(Step("ccd", 0, 0.2f));
var eventStep = session.Step(Step("event", 1, 1.0f / 60.0f));
Assert.Empty(ccdStep.ContactFacts);
Assert.Contains(eventStep.ContactFacts, fact => fact.Kind is YmirContactFactKind.Begin or YmirContactFactKind.Hit);
var target = eventStep.World.Bodies.Single(body => body.Id == "target");
Assert.Equal(Vec2.Zero, target.Velocity);
Assert.Equal(Vec2.Zero, target.Position);
}
[Fact]
public void RetainedCastObservesNamedRevisionAndExplicitCandidates()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"retained-cast",
[Body("source", positionX: -2.0f), Body("target", positionX: 2.0f), Body("ignored", positionX: 0.0f)]));
var result = session.CastCircle(new YmirSessionCircleCastQuery(
"payload-sweep",
0,
new Vec2(-5.0f, 0.0f),
new Vec2(1.0f, 0.0f),
10.0f,
0.25f,
["target"]));
Assert.Equal(YmirQueryError.None, result.Error);
Assert.Equal(0, result.ObservedRevision);
Assert.Equal(0, result.ObservedStepIndex);
Assert.Equal("target", Assert.Single(result.Hits).BodyId);
Assert.Equal(0, session.Info.Revision);
}
[Fact]
public void RetainedQueriesFailClosedForStaleUnknownAndDisposedSessions()
{
var session = YmirSession.Create(new YmirSessionCreateRequest("query-failures", [Body("target")]));
var stale = session.OverlapCircle(new YmirSessionCircleOverlapQuery(
"stale", 1, Vec2.Zero, 1.0f, ["target"]));
var unknown = session.OverlapCircle(new YmirSessionCircleOverlapQuery(
"unknown", 0, Vec2.Zero, 1.0f, ["missing"]));
session.Dispose();
var disposed = session.OverlapCircle(new YmirSessionCircleOverlapQuery(
"disposed", 0, Vec2.Zero, 1.0f, ["target"]));
Assert.Equal(YmirQueryError.StaleRevision, stale.Error);
Assert.Equal(YmirQueryError.UnknownBody, unknown.Error);
Assert.Equal(YmirQueryError.SessionDisposed, disposed.Error);
Assert.Empty(stale.Hits);
Assert.Empty(unknown.Hits);
Assert.Empty(disposed.Hits);
}
private static YmirCommandHeader Header(string id, long revision) => new(id, revision);
private static long BodyGeneration(YmirContactFact fact, string bodyId) =>
fact.BodyA == bodyId ? fact.BodyAGeneration : fact.BodyBGeneration;
private static YmirStepSessionCommand Step(string id, long revision, float deltaTime) =>
new(Header(id, revision), deltaTime, []);
private static PhysicsBody Body(
string id,
float positionX = 0.0f,
float velocityX = 0.0f,
bool isStatic = false) =>
new(
id,
new Vec2(positionX, 0.0f),
new Vec2(velocityX, 0.0f),
1.0f,
1.0f,
isStatic,
0.0f,
new Vec2(0.0f, 1.0f));
private static void AssertWorldEqual(YmirWorld expected, YmirWorld actual)
{
Assert.Equal(expected.Time, actual.Time);
Assert.Equal(expected.Bodies, actual.Bodies);
Assert.Equal(expected.Fields, actual.Fields);
}
}