CultLib/src/GameCult.Mesh/CultMeshBodyPublications.cs
2026-07-19 13:57:06 +01:00

201 lines
10 KiB
C#

using System;
using System.Globalization;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
namespace GameCult.Mesh
{
public static class CultMeshBodyPublicationSchemaVersions
{
public const string Publication = "gamecult.mesh.body_publication.v2";
}
public sealed class CultMeshBodyGeneration
{
public string BodyId { get; set; } = string.Empty;
public string ProducerId { get; set; } = string.Empty;
public string SchemaId { get; set; } = string.Empty;
public int LayoutVersion { get; set; }
public int Capacity { get; set; }
public long ProducerEpoch { get; set; }
public long Sequence { get; set; }
public CultMeshBodySynchronization Synchronization { get; set; } = CultMeshBodySynchronization.ImmutableSequence;
public long LeaseExpiresAtUnixMs { get; set; }
}
[MessagePackObject]
[CultDocument("gamecult.mesh.body_publication", CultMeshBodyPublicationSchemaVersions.Publication)]
public sealed class CultMeshBodyPublicationDocument
{
[Key(0), CultName] public string BodyId { get; set; } = string.Empty;
[Key(1), CultIndex] public string ProducerId { get; set; } = string.Empty;
[Key(2)] public string SchemaId { get; set; } = string.Empty;
[Key(3)] public int LayoutVersion { get; set; }
[Key(4)] public long ByteSize { get; set; }
[Key(5)] public int Capacity { get; set; }
[Key(6)] public long ProducerEpoch { get; set; }
[Key(7)] public long Sequence { get; set; }
[Key(8)] public CultMeshBodySynchronization Synchronization { get; set; }
[Key(9)] public long LivenessExpiresAtUnixMs { get; set; }
[Key(10)] public CultMeshBodyDescriptor[] Representations { get; set; } = Array.Empty<CultMeshBodyDescriptor>();
public static CultRecordKey CreateRecordKey(string bodyId, long producerEpoch, long sequence)
{
if (string.IsNullOrWhiteSpace(bodyId))
throw new ArgumentException("Logical body identity is required.", nameof(bodyId));
if (producerEpoch < 0) throw new ArgumentOutOfRangeException(nameof(producerEpoch));
if (sequence < 0) throw new ArgumentOutOfRangeException(nameof(sequence));
using var stream = new MemoryStream();
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true))
{
writer.Write(bodyId);
writer.Write(producerEpoch);
writer.Write(sequence);
}
using var sha256 = SHA256.Create();
var generationId = string.Concat(sha256.ComputeHash(stream.ToArray())
.Select(value => value.ToString("x2", CultureInfo.InvariantCulture)));
return new CultRecordKey("mesh:body-generation:" + generationId);
}
public static CultRecordKey CreateLatestRecordKey(string bodyId)
{
if (string.IsNullOrWhiteSpace(bodyId))
throw new ArgumentException("Logical body identity is required.", nameof(bodyId));
return new CultRecordKey("mesh:body:" + bodyId);
}
[IgnoreMember]
public CultRecordKey RecordKey => CreateRecordKey(BodyId, ProducerEpoch, Sequence);
}
public sealed class CultMeshBodyPublicationHandle
{
public CultMeshBodyPublicationHandle(string bodyId, long producerEpoch, long sequence)
{
BodyId = string.IsNullOrWhiteSpace(bodyId)
? throw new ArgumentException("Logical body identity is required.", nameof(bodyId))
: bodyId;
ProducerEpoch = producerEpoch >= 0
? producerEpoch
: throw new ArgumentOutOfRangeException(nameof(producerEpoch));
Sequence = sequence >= 0
? sequence
: throw new ArgumentOutOfRangeException(nameof(sequence));
RecordKey = CultMeshBodyPublicationDocument.CreateRecordKey(bodyId, producerEpoch, sequence);
}
public string BodyId { get; }
public long ProducerEpoch { get; }
public long Sequence { get; }
public CultRecordKey RecordKey { get; }
public void Validate(CultMeshBodyPublicationDocument publication) =>
CultMeshBodyPublicationValidator.Validate(publication, BodyId, ProducerEpoch, Sequence, RecordKey);
}
public static class CultMeshBodyPublicationValidator
{
public static void Validate(
CultMeshBodyPublicationDocument publication,
string? expectedBodyId = null,
long? expectedProducerEpoch = null,
long? expectedSequence = null,
CultRecordKey? expectedRecordKey = null)
{
if (publication == null) throw new ArgumentNullException(nameof(publication));
if (string.IsNullOrWhiteSpace(publication.BodyId))
throw new InvalidOperationException("CultMesh body publication has no logical body identity.");
if (expectedBodyId != null && !string.Equals(publication.BodyId, expectedBodyId, StringComparison.Ordinal))
throw new InvalidOperationException("CultMesh body publication handle identity mismatch.");
if (expectedProducerEpoch.HasValue && publication.ProducerEpoch != expectedProducerEpoch.Value)
throw new InvalidOperationException("CultMesh body publication handle producer epoch mismatch.");
if (expectedSequence.HasValue && publication.Sequence != expectedSequence.Value)
throw new InvalidOperationException("CultMesh body publication handle sequence mismatch.");
if (string.IsNullOrWhiteSpace(publication.ProducerId))
throw new InvalidOperationException("CultMesh body publication has no producer identity.");
if (string.IsNullOrWhiteSpace(publication.SchemaId))
throw new InvalidOperationException("CultMesh body publication has no schema identity.");
if (publication.LayoutVersion < 0 || publication.ByteSize < 0 || publication.Capacity < 0 ||
publication.ProducerEpoch < 0 || publication.Sequence < 0)
throw new InvalidOperationException("CultMesh body publication generation values must be non-negative.");
if (expectedRecordKey.HasValue && !publication.RecordKey.Equals(expectedRecordKey.Value))
throw new InvalidOperationException("CultMesh body publication record key disagrees with its generation envelope.");
var representations = publication.Representations ?? Array.Empty<CultMeshBodyDescriptor>();
if (representations.Length == 0)
throw new InvalidOperationException("CultMesh body publication has no transport representations.");
if (representations.Select(value => value?.TransportKind).Distinct().Count() != representations.Length)
throw new InvalidOperationException("CultMesh body publication advertises the same transport representation more than once.");
foreach (var representation in representations)
ValidateDescriptor(publication, representation, representation?.TransportKind.ToString() ?? "missing");
}
private static void ValidateDescriptor(
CultMeshBodyPublicationDocument publication,
CultMeshBodyDescriptor descriptor,
string label)
{
if (descriptor == null)
throw new InvalidOperationException($"CultMesh body publication has no {label} descriptor.");
if (!string.Equals(descriptor.BodyId, publication.BodyId, StringComparison.Ordinal) ||
!string.Equals(descriptor.SchemaId, publication.SchemaId, StringComparison.Ordinal) ||
descriptor.LayoutVersion != publication.LayoutVersion ||
descriptor.ByteSize != publication.ByteSize ||
descriptor.Capacity != publication.Capacity ||
descriptor.ProducerEpoch != publication.ProducerEpoch ||
descriptor.Sequence != publication.Sequence ||
descriptor.Synchronization != publication.Synchronization ||
descriptor.LeaseExpiresAtUnixMs != publication.LivenessExpiresAtUnixMs)
throw new InvalidOperationException($"CultMesh {label} descriptor disagrees with its publication envelope.");
}
}
public sealed class CultMeshBodyPublicationResolver
{
private readonly CultMeshBodyTransportService _transport;
public CultMeshBodyPublicationResolver(CultMeshBodyTransportService transport) =>
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
/// <summary>Gets the body planes this resolver can actually open.</summary>
public IReadOnlyList<CultMeshBodyTransportKind> SupportedTransports => _transport.SupportedTransports;
public ICultMeshBodyReadLease ResolveReadOnly(
CultMeshBodyPublicationDocument publication,
CultMeshBodyValidationRequest request) =>
NegotiateReadOnly(publication, request).Lease;
public CultMeshBodyNegotiationResult NegotiateReadOnly(
CultMeshBodyPublicationDocument publication,
CultMeshBodyValidationRequest request)
{
CultMeshBodyPublicationValidator.Validate(publication);
if (request == null) throw new ArgumentNullException(nameof(request));
if (publication.LivenessExpiresAtUnixMs <= request.NowUtc.ToUnixTimeMilliseconds())
throw new InvalidOperationException("CultMesh body publication is no longer live.");
return _transport.NegotiateReadOnly(
publication.ProducerId,
publication.Representations,
request);
}
}
public static partial class CultMesh
{
public static CultNetDocumentRegistry CreateBodyPublicationDocumentRegistry(
CultDocumentRegistry? documents = null)
{
documents ??= CultDocumentRegistry.Shared;
return new CultNetDocumentRegistry(documents)
.Register(CultNetDocumentBinding.ForDocument<CultMeshBodyPublicationDocument>(documents))
.Register(CultNetDocumentBinding.ForDocument<CultMeshCdnArtifactManifest>(documents))
.Register(CultNetDocumentBinding.ForDocument<CultMeshCdnArtifactChunk>(documents));
}
}
}