CultLib/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs
2026-07-22 03:12:55 +01:00

408 lines
18 KiB
C#

using FluentAssertions;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Mesh;
using GameCult.Networking;
using CultMath;
using MessagePack;
using NUnit.Framework;
using R3;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
namespace GameCult.Geometry.Tests
{
public sealed class GeometryDocumentTests
{
[Test]
public void GeometryPrimitives_CanonicalizeAndIntersect()
{
var viewport = new CultRect(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 CultRect(7f, 3f, 10f, 5f)).Should().BeTrue();
viewport.Intersects(new CultRect(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 CultRect(-4f, -3f, 6f, 7f));
}
[Test]
public void GeometryPrimitives_RoundTripThroughMessagePack()
{
var rect = new CultRect(new float2(5f, -2f), new float2(-1f, 7f));
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(CultRect));
var payload = MessagePackSerializer.Serialize(rect, options);
var decoded = MessagePackSerializer.Deserialize<CultRect>(payload, options);
decoded.Should().Be(rect);
decoded.Min.Should().Be(new float2(-1f, -2f));
decoded.Max.Should().Be(new float2(5f, 7f));
}
[Test]
public void GeometryPrimitive_JsonInspectionProjection_UsesLowercaseComponents()
{
var value = new float3(1f, 2f, 3f);
var json = JsonSerializer.Serialize(new { value.x, value.y, value.z });
json.Should().Contain("\"x\":1");
json.Should().Contain("\"y\":2");
json.Should().Contain("\"z\":3");
json.Should().NotContain("\"X\"");
json.Should().NotContain("\"Y\"");
json.Should().NotContain("\"Z\"");
}
[Test]
public void GeometryDocuments_Register_AsCultCacheSchemas()
{
var cache = new CultCache();
cache.Registry.GetRequired<CultGeometryDomainDocument>().SchemaVersion
.Should().Be(CultGeometrySchemaVersions.Domain);
cache.Registry.GetRequired<CultGeometryBuildRequest>().SchemaVersion
.Should().Be(CultGeometrySchemaVersions.BuildRequest);
cache.Registry.GetRequired<CultGeometrySelectedCutManifest>().SchemaVersion
.Should().Be(CultGeometrySchemaVersions.SelectedCut);
cache.Registry.GetRequired<CultGeometryChunkArtifact>().SchemaVersion
.Should().Be(CultGeometrySchemaVersions.ChunkArtifact);
cache.Registry.GetRequired<CultGeometryDomainDocument>().SchemaId
.Should().Be("sha256:e3358436f8a07c84cc66b5196380cdfcd6920fa15f15d052d30b42b73762421d");
cache.Registry.GetRequired<CultGeometryBuildRequest>().SchemaId
.Should().Be("sha256:2de3162db128fc2c5cc2ea3dae46d0196110a9e5f969997002309e636a3721d3");
cache.Registry.GetRequired<CultGeometrySelectedCutManifest>().SchemaId
.Should().Be("sha256:864412d3cf0f6b079ceb0bbd3b36232f11380e63cdf0bfd64d505a710fb49ff8");
cache.Registry.GetRequired<CultGeometryChunkArtifact>().SchemaId
.Should().Be("sha256:2d28046b7c76244ced70ee6de09bcbb5c64d364aa578e5a4c903d397a7a56156");
}
[Test]
public void GeometryBuildRequest_UsesStableCultCacheKey()
{
var domain = SampleDomain();
var domainKey = CultGeometryDomainDocument.CreateRecordKey(domain);
var request = new CultGeometryBuildRequest
{
DomainKey = domainKey.Value,
WorkerGroup = "ragnarok-column-workers",
CameraPosition = new float3(36f, -42f, 30f),
FrustumMin = new float3(-75f, -75f, -30f),
FrustumMax = new float3(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:");
}
[Test]
public void ChunkArtifact_RoundTrips_ThroughCultCacheMessagePack()
{
var chunk = SampleChunk();
var payload = CultDocumentMessagePackSerialization.Serialize(chunk);
var decoded = CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload);
decoded.ChunkId.Should().Be(chunk.ChunkId);
decoded.RenderMesh.TriangleCount.Should().Be(1);
decoded.BoundsMin.Should().Be(new float3(-1f, -1f, 0f));
decoded.RenderMesh.Positions.Should().Equal(chunk.RenderMesh.Positions);
decoded.RenderMesh.Normals.Should().HaveCount(decoded.RenderMesh.Positions.Length);
decoded.RenderMesh.Uvs.Should().HaveCount(decoded.RenderMesh.Positions.Length);
decoded.RenderMesh.Indices.Length.Should().Be(decoded.RenderMesh.TriangleMaterials.Length * 3);
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 TypedV2Domain_RoundTripsAndPreservesTheV1RecordKey()
{
var domain = SampleDomain();
var payload = CultDocumentMessagePackSerialization.Serialize(domain);
var decoded = CultDocumentMessagePackSerialization.Deserialize<CultGeometryDomainDocument>(payload);
decoded.Root.Rotation.Should().Be(quaternion.identity);
decoded.Root.Children[0].Claims[0].SupportCenter.Should().Be(new float3(0f, 0f, 45f));
CultGeometryDomainDocument.CreateRecordKey(decoded).Value.Should().Be(
"geometry:domain:175899ea97548da0599e12bcaccd07fa1d6009ed450fadb3de229d47bab04431");
}
[Test]
public void V2Chunk_DoesNotAcceptTheLegacyFlatMeshPayload()
{
var payload = File.ReadAllBytes(FixturePath("ragnarok-first-chunk.msgpack"));
FluentActions.Invoking(() =>
CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload))
.Should().Throw<MessagePackSerializationException>();
}
[Test]
public async Task ChunkArtifact_Replicates_ThroughCultNetRawDocumentLane()
{
var sourceCache = new CultCache();
var targetCache = new CultCache();
var schemaId = sourceCache.Registry.GetRequired<CultGeometryChunkArtifact>().SchemaId;
var registry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<CultGeometryChunkArtifact>(sourceCache.Registry));
var chunk = SampleChunk();
var handle = new CultRecordHandle<CultGeometryChunkArtifact>(
CultGeometryChunkArtifact.CreateRecordKey(chunk));
await sourceCache.AddAsync(chunk, handle);
var put = registry.CreateRawDocumentPutMessage(
"geometry-chunk-put",
handle,
chunk,
new CultNetDocumentMessageOptions
{
SourceRuntimeId = "vg-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<CultGeometryChunkArtifact>(targetCache, put);
var replicated = targetCache.Get<CultGeometryChunkArtifact>(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<CultGeometryChunkArtifact>().SchemaId;
var registry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<CultGeometryChunkArtifact>(sourceCache.Registry));
var chunk = SampleChunk();
var handle = new CultRecordHandle<CultGeometryChunkArtifact>(
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<CultGeometryChunkArtifact>(targetCache, response);
var replicated = targetCache.Get<CultGeometryChunkArtifact>(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<CultGeometryChunkArtifact>(CultDocumentRegistry.Shared));
var chunk = SampleChunk();
var key = CultGeometryChunkArtifact.CreateRecordKey(chunk);
var changes = new List<CultNetDatabaseChange<CultGeometryChunkArtifact>>();
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<CultGeometryChunkArtifact>(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 = "unity-runtime",
DocumentRegistry = registry
}
}))
{
var persisted = await reopened.Database.GetAsync<CultGeometryChunkArtifact>(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 = "vg-csg",
CreatedAt = "2026-05-29T00:00:00.0000000Z",
Root = new CultGeometryDomainNode
{
Name = "ragnarok-column",
Kind = "Root",
Translation = float3.zero,
Rotation = quaternion.identity,
Seed = 0x5EED,
Claims = [],
Children =
[
new CultGeometryDomainNode
{
Name = "stellarator-column-00",
Kind = "Column",
Translation = float3.zero,
Rotation = quaternion.identity,
Seed = 0xC0110000,
Claims =
[
new CultGeometryFeatureClaim
{
Name = "column-support-shell",
Translation = float3.zero,
Rotation = quaternion.identity,
SupportCenter = new float3(0f, 0f, 45f),
SupportSize = new float3(18f, 18f, 96f),
Kind = "SupportShell",
Material = 10,
LoweringPolicy = "RenderAndCollider"
}
],
Children = []
}
]
}
};
}
private static CultGeometryChunkArtifact SampleChunk()
{
return new CultGeometryChunkArtifact
{
ChunkId = "chunk/ragnarok-column/column-00",
CutKey = "geometry:cut:test",
SelectedCutId = "cut-test",
BoundsMin = new float3(-1f, -1f, 0f),
BoundsMax = new float3(1f, 1f, 1f),
SourceDomainKeys = ["ragnarok-column/stellarator-column-00"],
SourceClaimKeys = ["ragnarok-column/stellarator-column-00/claim/support"],
RenderMesh = new CultGeometryTriangleMesh
{
Positions = [new float3(0f, 0f, 0f), new float3(1f, 0f, 0f), new float3(0f, 1f, 0f)],
Normals = [new float3(0f, 0f, 1f), new float3(0f, 0f, 1f), new float3(0f, 0f, 1f)],
Uvs = [new float2(0f, 0f), new float2(1f, 0f), new float2(0f, 1f)],
Indices = [0u, 1u, 2u],
TriangleMaterials = [20u]
},
ColliderMesh = null,
InputBrushes = 1,
StableClipSeed = 1234,
SupportsParentChildCoexistence = true
};
}
private static string FixturePath(string fileName)
{
return Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Fixtures",
"vg-csg-ragnarok",
fileName);
}
}
}