using FluentAssertions; using CultMath; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Mesh; using GameCult.Networking; using MessagePack; using MessagePack.Resolvers; using NUnit.Framework; using R3; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; namespace GameCult.Geometry.Tests { public sealed class GeometryDocumentTests { [Test] public void GeometryPrimitives_CanonicalizeAndIntersect() { var viewport = new rect(8f, 4f, -8f, -4f); viewport.min.Should().Be(new float2(-8f, -4f)); viewport.max.Should().Be(new float2(8f, 4f)); viewport.center.Should().Be(float2.zero); viewport.Contains(new float2(2f, 3f)).Should().BeTrue(); viewport.Intersects(new rect(7f, 3f, 10f, 5f)).Should().BeTrue(); viewport.Intersects(new rect(9f, 5f, 10f, 6f)).Should().BeFalse(); var gravityBrush = new CultCircle(new float2(10f, 0f), 3f); gravityBrush.Intersects(viewport).Should().BeTrue(); gravityBrush.Contains(new float2(12f, 0f)).Should().BeTrue(); gravityBrush.Contains(new float2(14f, 0f)).Should().BeFalse(); } [Test] public void GeometryPrimitives_PreserveWholeVectorsForSoaAndPhysicsQueries() { var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); sphere.Contains(new float3(1f, 6f, 3f)).Should().BeTrue(); sphere.Contains(new float3(1f, 8f, 3f)).Should().BeFalse(); sphere.Center.xy.Should().Be(new float2(1f, 2f)); sphere.Center.xz.Should().Be(new float2(1f, 3f)); sphere.XyCircle.Bounds.Should().Be(new rect(-4f, -3f, 6f, 7f)); } [Test] public void GeometryPrimitives_JsonOmitsComputedConvenienceProperties() { var options = new JsonSerializerOptions().AddCultMathConverters(); var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); var circle = new CultCircle(new float2(1f, 2f), 3f); var sphereJson = JsonSerializer.Serialize(sphere, options); var circleJson = JsonSerializer.Serialize(circle, options); sphereJson.Should().Be("""{"Center":{"x":1,"y":2,"z":3},"Radius":5}"""); circleJson.Should().Be("""{"Center":{"x":1,"y":2},"Radius":3}"""); JsonSerializer.Deserialize(sphereJson, options).Should().Be(sphere); JsonSerializer.Deserialize(circleJson, options).Should().Be(circle); } // The bytes CultCircle and CultSphere produced when they held CultVec2/CultVec3 (probed at 65311c9). [Test] public void GeometryPrimitives_KeepTheirPreCollapseBytes() { var options = CultDocumentMessagePackSerialization.Options; var circle = new CultCircle(new float2(1f, 2f), 3f); var sphere = new CultSphere(new float3(1f, 2f, 3f), 5f); Convert.ToHexString(MessagePackSerializer.Serialize(circle, options)) .Should().Be("9292CA3F800000CA40000000CA40400000"); Convert.ToHexString(MessagePackSerializer.Serialize(sphere, options)) .Should().Be("9293CA3F800000CA40000000CA40400000CA40A00000"); MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(circle, options), options) .Should().Be(circle); MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(sphere, options), options) .Should().Be(sphere); } [Test] public void GeometryDocuments_Register_AsCultCacheSchemas() { var cache = new CultCache(); cache.Registry.GetRequired().SchemaVersion .Should().Be(CultGeometrySchemaVersions.Domain); cache.Registry.GetRequired().SchemaVersion .Should().Be(CultGeometrySchemaVersions.BuildRequest); cache.Registry.GetRequired().SchemaVersion .Should().Be(CultGeometrySchemaVersions.SelectedCut); cache.Registry.GetRequired().SchemaVersion .Should().Be(CultGeometrySchemaVersions.ChunkArtifact); } [Test] public void GeometryBuildRequest_UsesStableCultCacheKey() { var domain = SampleDomain(); var domainKey = CultGeometryDomainDocument.CreateRecordKey(domain); var request = new CultGeometryBuildRequest { DomainKey = new CultRecordRef(domainKey), WorkerGroup = "ragnarok-column-workers", CameraPosition = [36f, -42f, 30f], FrustumMin = [-75f, -75f, -30f], FrustumMax = [75f, 75f, 120f], ViewportHeightPixels = 1080f, VerticalFovRadians = 1.0471976f, TargetError = 0.01f, TriangleBudget = 10_000, ColliderBudget = 10_000, SemanticFilter = [], RequestedChunkKeys = [], DirtyDomainKeys = [] }; var first = CultGeometryBuildRequest.CreateRecordKey(request); var second = CultGeometryBuildRequest.CreateRecordKey(request); request.TriangleBudget++; var changed = CultGeometryBuildRequest.CreateRecordKey(request); second.Should().Be(first); changed.Should().NotBe(first); first.Value.Should().StartWith("geometry:request:"); } // The geometry documents reference each other by record key. The reference members are // CultRecordRef so the cache can walk them, and they stay a bare msgpack string on the wire. [Test] public void GeometryReferences_AreWalkableByTheCache_AndStayStringsOnTheWire() { var cache = new CultCache(); var descriptor = cache.Registry.GetRequired(); descriptor.DeclaredMembers.Should().Contain(m => m.MemberName == "DomainKey" && m.IsReference && !m.IsMany); var chunk = SampleChunk(); var references = cache.Registry.GetRequired().ReferencesOf(chunk, "CutKey").ToArray(); references.Should().ContainSingle().Which.Target.Value.Should().Be("geometry:cut:test"); var payload = CultDocumentMessagePackSerialization.SerializeUntyped(chunk, typeof(CultGeometryChunkArtifact)); var reader = new MessagePackReader(payload); reader.ReadArrayHeader(); reader.ReadString().Should().Be(chunk.ChunkId); reader.ReadString().Should().Be("geometry:cut:test"); } [Test] public void ChunkArtifact_RoundTrips_ThroughCultCacheMessagePack() { var chunk = SampleChunk(); var payload = CultDocumentMessagePackSerialization.SerializeUntyped(chunk, typeof(CultGeometryChunkArtifact)); var decoded = (CultGeometryChunkArtifact)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryChunkArtifact), payload); decoded.ChunkId.Should().Be(chunk.ChunkId); decoded.RenderMesh.TriangleCount.Should().Be(1); CultGeometryChunkArtifact.CreateRecordKey(decoded) .Should().Be(CultGeometryChunkArtifact.CreateRecordKey(chunk)); } [Test] public void FeatureClaim_StableFingerprint_UsesRustCanonicalOrder() { var claim = SampleDomain().Root.Children[0].Claims[0]; claim.StableFingerprint().Should().Be(string.Join('\u001e', "column-support-shell", "00000000,00000000,00000000", "00000000,00000000,00000000,3f800000", "00000000,00000000,42340000", "41900000,41900000,42c00000", "SupportShell", "10", "RenderAndCollider")); } [Test] public void RustExportedDomainDocument_DecodesWithStableCultCacheKey() { var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack")); var domain = (CultGeometryDomainDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryDomainDocument), payload); domain.DomainId.Should().Be("ragnarok-column"); domain.Root.Children.Should().NotBeEmpty(); domain.Root.Children[0].Name.Should().Be("stellarator-column-00"); CultGeometryDomainDocument.CreateRecordKey(domain).Value .Should().Be("geometry:domain:02c9b5810977406b0c206f3a3494327a423abb9448192be1b9a1863cd0f2ed95"); } // A source-generated formatter is of no known family and passes the element-id door (ruling c2a-source-generated-formatters-defaulted). [Test] public void AGeneratedFormatterPassesTheElementIdDoor() { StandardResolver.Instance.GetFormatter().Should() .NotBeSameAs(DynamicObjectResolver.Instance.GetFormatter(), "the fixture must stay source-generated"); var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack")); var domain = (CultGeometryDomainDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryDomainDocument), payload); var again = (CultGeometryDomainDocument)CultDocumentMessagePackSerialization.DeserializeUntyped( typeof(CultGeometryDomainDocument), CultDocumentMessagePackSerialization.SerializeUntyped(domain, typeof(CultGeometryDomainDocument))); CultGeometryDomainDocument.CreateRecordKey(again).Value.Should().Be(CultGeometryDomainDocument.CreateRecordKey(domain).Value); } [Test] public void RustExportedChunkArtifact_DecodesWithStableCultCacheKey() { var payload = File.ReadAllBytes(FixturePath("ragnarok-first-chunk.msgpack")); var chunk = (CultGeometryChunkArtifact)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryChunkArtifact), payload); chunk.ChunkId.Should().StartWith("chunk/"); chunk.RenderMesh.TriangleCount.Should().BeGreaterThan(0); chunk.SourceDomainKeys.Should().NotBeEmpty(); CultGeometryChunkArtifact.CreateRecordKey(chunk).Value .Should().Be("geometry:chunk:b94dcd7d6776df15d0fa5fcf7f4b24f1384876b13b2d38cc57c4468136521af5"); } [Test] public async Task ChunkArtifact_Replicates_ThroughCultNetRawDocumentLane() { var sourceCache = new CultCache(); var targetCache = new CultCache(); var schemaId = sourceCache.Registry.GetRequired().SchemaId; var registry = new CultNetDocumentRegistry(sourceCache.Registry) .Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); var chunk = SampleChunk(); var handle = new CultRecordHandle( CultGeometryChunkArtifact.CreateRecordKey(chunk)); await sourceCache.AddAsync(chunk, handle); var put = registry.CreateRawDocumentPutMessage( "geometry-chunk-put", handle, chunk, new CultNetDocumentMessageOptions { SourceRuntimeId = "gamecult.geometry.csg", SourceRole = "geometry-worker", Tags = ["ragnarok", "chunk"] }); put.Document.SchemaId.Should().Be(schemaId); put.Document.PayloadEncoding.Should().Be("messagepack"); put.Document.Payload.Should().NotBeEmpty(); put.Document.RecordKey.Should().Be(handle.Key.Value); var applied = await registry.ApplyRawDocumentPutMessageAsync(targetCache, put); var replicated = targetCache.Get(handle.Key); applied.ChunkId.Should().Be(chunk.ChunkId); replicated.Should().NotBeNull(); replicated!.RenderMesh.TriangleCount.Should().Be(1); CultGeometryChunkArtifact.CreateRecordKey(replicated) .Should().Be(handle.Key); } [Test] public async Task ChunkArtifact_Replicates_ThroughCultNetRawSnapshot() { var sourceCache = new CultCache(); var targetCache = new CultCache(); var schemaId = sourceCache.Registry.GetRequired().SchemaId; var registry = new CultNetDocumentRegistry(sourceCache.Registry) .Register(CultNetDocumentBinding.ForDocument(sourceCache.Registry)); var chunk = SampleChunk(); var handle = new CultRecordHandle( CultGeometryChunkArtifact.CreateRecordKey(chunk)); await sourceCache.AddAsync(chunk, handle); var request = registry.CreateSnapshotRequest( "geometry-snapshot-request", schemaIds: [schemaId], recordKeys: [handle.Key.Value]); var response = registry.CreateRawSnapshotResponse(sourceCache, "geometry-snapshot", request); response.Documents.Should().ContainSingle(); response.Documents[0].SchemaId.Should().Be(schemaId); response.Documents[0].RecordKey.Should().Be(handle.Key.Value); var applied = await registry.ApplyRawSnapshotResponseAsync(targetCache, response); var replicated = targetCache.Get(handle.Key); applied.Should().ContainSingle(); replicated.Should().NotBeNull(); replicated!.ChunkId.Should().Be(chunk.ChunkId); replicated.RenderMesh.Indices.Should().Equal(chunk.RenderMesh.Indices); } [Test] public async Task ChunkArtifact_IsTypedSharedState_InLocalCultMeshNode() { var cachePath = Path.Combine( TestContext.CurrentContext.WorkDirectory, "geometry-node-" + Guid.NewGuid().ToString("N") + ".ccmp"); var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared) .Register(CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared)); var chunk = SampleChunk(); var key = CultGeometryChunkArtifact.CreateRecordKey(chunk); var changes = new List>(); try { using (var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions { StartServer = false, DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "geometry-runtime", DocumentRegistry = registry } })) { using var subscription = node.Database .WatchRecord(key) .Subscribe(change => changes.Add(change)); await node.Database.PutAsync(key, chunk); await node.FlushAsync(); } using (var reopened = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions { StartServer = false, DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "godot-runtime", DocumentRegistry = registry } })) { var persisted = await reopened.Database.GetAsync(key); persisted.Should().NotBeNull(); persisted!.ChunkId.Should().Be(chunk.ChunkId); persisted.RenderMesh.Positions.Should().Equal(chunk.RenderMesh.Positions); } } finally { if (File.Exists(cachePath)) { File.Delete(cachePath); } } changes.Should().ContainSingle(); changes[0].Kind.Should().Be(CultNetDatabaseChangeKind.Added); changes[0].Document.Should().NotBeNull(); changes[0].Document!.ChunkId.Should().Be(chunk.ChunkId); } private static CultGeometryDomainDocument SampleDomain() { return new CultGeometryDomainDocument { DomainId = "ragnarok-column", RootKey = "ragnarok-column", SourceRuntime = "gamecult.geometry.csg", CreatedAt = "2026-05-29T00:00:00.0000000Z", Root = new CultGeometryDomainNode { Name = "ragnarok-column", Kind = "Root", Translation = [0f, 0f, 0f], RotationXyzw = [0f, 0f, 0f, 1f], Seed = 0x5EED, Claims = [], Children = [ new CultGeometryDomainNode { Name = "stellarator-column-00", Kind = "Column", Translation = [0f, 0f, 0f], RotationXyzw = [0f, 0f, 0f, 1f], Seed = 0xC0110000, Claims = [ new CultGeometryFeatureClaim { Name = "column-support-shell", Translation = [0f, 0f, 0f], RotationXyzw = [0f, 0f, 0f, 1f], SupportCenter = [0f, 0f, 45f], SupportSize = [18f, 18f, 96f], Kind = "SupportShell", Material = 10, LoweringPolicy = "RenderAndCollider" } ], Children = [] } ] } }; } private static string FixturePath(string fileName) { return Path.Combine( TestContext.CurrentContext.TestDirectory, "Fixtures", // Immutable interoperability fixtures emitted before the CSG package rename. "vg-csg-ragnarok", fileName); } private static CultGeometryChunkArtifact SampleChunk() { return new CultGeometryChunkArtifact { ChunkId = "chunk/ragnarok-column/column-00", CutKey = new CultRecordRef(new CultRecordKey("geometry:cut:test")), SelectedCutId = "cut-test", BoundsMin = [-1f, -1f, 0f], BoundsMax = [1f, 1f, 1f], SourceDomainKeys = ["ragnarok-column/stellarator-column-00"], SourceClaimKeys = ["ragnarok-column/stellarator-column-00/claim/support"], RenderMesh = new CultGeometryTriangleMesh { Positions = [0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f, 0f], Normals = [0f, 0f, 1f, 0f, 0f, 1f, 0f, 0f, 1f], Uvs = [0f, 0f, 1f, 0f, 0f, 1f], Indices = [0u, 1u, 2u], TriangleMaterials = [20u] }, ColliderMesh = null, InputBrushes = 1, StableClipSeed = 1234, SupportsParentChildCoexistence = true }; } } }