From 511782ca4210e304be2650320c3c990886791aec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 13 Jul 2026 16:53:21 +0100 Subject: [PATCH] Read legacy Eve surface contracts --- .../Runtime/EveSurfaceDocument.cs | 126 ++++++++++++++++- .../Runtime/GameCult.Eve.Surface.asmdef | 3 +- .../.gitignore | 1 + ...eSurfaceSerializationCompatibilityTests.cs | 129 ++++++++++++++++++ ...ult.Eve.Surface.Serialization.Tests.csproj | 20 +++ 5 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore create mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs create mode 100644 tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs index c4eb051..9eed51f 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs @@ -4,6 +4,7 @@ using System.Linq; using GameCult.Caching; using GameCult.Mesh; using MessagePack; +using MessagePack.Formatters; #nullable enable @@ -11,6 +12,7 @@ namespace GameCult.Eve.Surface { [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] [MessagePackObject] + [MessagePackFormatter(typeof(EveSurfaceDocumentCompatibilityFormatter))] public sealed class EveSurfaceDocument { public const string DefaultType = "surface-state"; @@ -289,6 +291,7 @@ namespace GameCult.Eve.Surface } [MessagePackObject] + [MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))] public sealed class EveCommandTemplate { public EveCommandTemplate(CultMeshOperationBindingDescriptor operation) @@ -318,7 +321,7 @@ namespace GameCult.Eve.Surface public string Transport => Operation.RouteHint.Description ?? ""; } - [CultDocument("gamecult.eve.command", SchemaId)] + [CultDocument("gamecult.eve.command_invocation", SchemaId)] [MessagePackObject] public sealed class EveSurfaceCommandRequest { @@ -411,4 +414,125 @@ namespace GameCult.Eve.Surface [IgnoreMember] public string CommandId => Operation.IdempotencyKey ?? ""; } + + /// + /// Reads the current Eve surface envelope and the original seven-field v1 envelope. + /// + public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter + { + private const int LegacyFieldCount = 7; + private const int CurrentFieldCount = 9; + + public void Serialize(ref MessagePackWriter writer, EveSurfaceDocument? value, MessagePackSerializerOptions options) + { + if (value == null) { writer.WriteNil(); return; } + writer.WriteArrayHeader(CurrentFieldCount); + writer.Write(value.Type); + writer.Write(value.Schema); + writer.Write(value.ProviderId); + writer.Write(value.ProviderKind); + writer.Write(value.Title); + writer.Write(value.Version); + writer.Write(value.UpdatedAtUtc); + Formatter(options).Serialize(ref writer, value.Surface, options); + Formatter>(options).Serialize(ref writer, value.Commands, options); + } + + public EveSurfaceDocument? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType != MessagePackType.Array) + throw new MessagePackSerializationException("Eve surface document must be an array."); + + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadArrayHeader(); + if (fields == LegacyFieldCount) + { + return new EveSurfaceDocument( + ReadString(ref reader), ReadString(ref reader), ReadString(ref reader), + reader.ReadInt64(), ReadString(ref reader), + Formatter(options).Deserialize(ref reader, options)!, + Formatter>(options).Deserialize(ref reader, options) + ?? Array.Empty()); + } + + var type = ReadString(ref reader, fields, 0); + var schema = ReadString(ref reader, fields, 1); + var providerId = ReadString(ref reader, fields, 2); + var providerKind = ReadString(ref reader, fields, 3); + var title = ReadString(ref reader, fields, 4); + var version = fields > 5 ? reader.ReadInt64() : 0; + var updatedAtUtc = ReadString(ref reader, fields, 6); + var surface = fields > 7 ? Formatter(options).Deserialize(ref reader, options) : null; + var commands = fields > 8 ? Formatter>(options).Deserialize(ref reader, options) : null; + for (var index = CurrentFieldCount; index < fields; index++) reader.Skip(); + return new EveSurfaceDocument( + type, schema, providerId, providerKind, title, version, updatedAtUtc, + surface ?? throw new MessagePackSerializationException("Eve surface tree is required."), + commands ?? Array.Empty()); + } + finally { reader.Depth--; } + } + + private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => + options.Resolver.GetFormatter() + ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); + + private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; + private static string ReadString(ref MessagePackReader reader, int fields, int index) => + index < fields ? ReadString(ref reader) : ""; + } + + /// + /// Reads both direct legacy command fields and the current wrapped binding record. + /// + public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, EveCommandTemplate? value, MessagePackSerializerOptions options) + { + if (value == null) { writer.WriteNil(); return; } + writer.WriteArrayHeader(1); + BindingFormatter(options).Serialize(ref writer, value.OperationRecord, options); + } + + public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType != MessagePackType.Array) + throw new MessagePackSerializationException("Eve command template must be an array."); + + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadArrayHeader(); + if (fields == 1 && reader.NextMessagePackType == MessagePackType.Array) + { + return new EveCommandTemplate( + BindingFormatter(options).Deserialize(ref reader, options) + ?? new CultMeshOperationBindingRecord()); + } + + var operationId = ReadString(ref reader, fields, 0); + var label = ReadString(ref reader, fields, 1); + var schemaId = ReadString(ref reader, fields, 2); + var routeKind = ReadString(ref reader, fields, 3); + var routeDescription = ReadString(ref reader, fields, 4); + for (var index = 5; index < fields; index++) reader.Skip(); + return new EveCommandTemplate(new CultMeshOperationBindingRecord( + operationId, label, schemaId, routeKind, routeDescription)); + } + finally { reader.Depth--; } + } + + private static IMessagePackFormatter BindingFormatter( + MessagePackSerializerOptions options) => + options.Resolver.GetFormatter() + ?? throw new MessagePackSerializationException("No CultMesh operation binding formatter is registered."); + + private static string ReadString(ref MessagePackReader reader, int fields, int index) => + index < fields ? reader.ReadString() ?? "" : ""; + } + } diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef index 8041c88..7f19662 100644 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef +++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef @@ -11,7 +11,8 @@ "precompiledReferences": [ "GameCult.Caching.dll", "GameCult.Mesh.dll", - "MessagePack.Annotations.dll" + "MessagePack.Annotations.dll", + "MessagePack.dll" ], "autoReferenced": true, "defineConstraints": [], diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore new file mode 100644 index 0000000..296b87c --- /dev/null +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore @@ -0,0 +1 @@ +!GameCult.Eve.Surface.Serialization.Tests.csproj diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs new file mode 100644 index 0000000..b198b2e --- /dev/null +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -0,0 +1,129 @@ +using System.Buffers; +using GameCult.Eve.Surface; +using GameCult.Mesh; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Eve.Surface.Tests; + +[TestFixture] +public sealed class EveSurfaceSerializationCompatibilityTests +{ + private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard; + + [Test] + public void LegacySevenFieldSurfaceDocumentDeserializes() + { + var bytes = WriteLegacySurfaceDocument(); + + var document = MessagePackSerializer.Deserialize(bytes, Options); + + Assert.Multiple(() => + { + Assert.That(document.Type, Is.EqualTo(EveSurfaceDocument.DefaultType)); + Assert.That(document.Schema, Is.EqualTo(EveSurfaceDocument.SchemaId)); + Assert.That(document.ProviderId, Is.EqualTo("legacy-provider")); + Assert.That(document.ProviderKind, Is.EqualTo("legacy-kind")); + Assert.That(document.Title, Is.EqualTo("Legacy surface")); + Assert.That(document.Version, Is.EqualTo(17)); + Assert.That(document.UpdatedAtUtc, Is.EqualTo("2026-07-13T12:00:00Z")); + Assert.That(document.Surface.Id, Is.EqualTo("legacy-surface")); + Assert.That(document.Commands, Has.Count.EqualTo(1)); + Assert.That(document.Commands[0].Command, Is.EqualTo("legacy.execute")); + }); + } + + [Test] + public void LegacyDirectFiveStringCommandTemplateDeserializes() + { + var bytes = WriteLegacyCommandTemplate(); + + var command = MessagePackSerializer.Deserialize(bytes, Options); + + Assert.Multiple(() => + { + Assert.That(command.Command, Is.EqualTo("legacy.execute")); + Assert.That(command.Label, Is.EqualTo("Execute legacy command")); + Assert.That(command.Operation.SchemaId, Is.EqualTo("gamecult.legacy.command.v1")); + Assert.That(command.OperationRecord.RouteKind, Is.EqualTo("cultmesh")); + Assert.That(command.OperationRecord.RouteDescription, Is.EqualTo("legacy.commands")); + Assert.That(command.Transport, Is.EqualTo("legacy.commands")); + }); + } + + [Test] + public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding() + { + var document = CreateCurrentDocument(); + + var bytes = MessagePackSerializer.Serialize(document, Options); + var reader = new MessagePackReader(bytes); + + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9)); + for (var field = 0; field < 8; field++) + reader.Skip(); + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "commands collection"); + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "command template wrapper"); + Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Array), "structured operation binding"); + + var roundTrip = MessagePackSerializer.Deserialize(bytes, Options); + Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute")); + } + + private static EveSurfaceDocument CreateCurrentDocument() => new( + "current-provider", + "current-kind", + "Current surface", + 23, + "2026-07-13T13:00:00Z", + CreateSurfaceTree("current-surface"), + new[] + { + new EveCommandTemplate(new CultMeshOperationBindingRecord( + "current.execute", + "Execute current command", + "gamecult.current.command.v1", + "cultmesh", + "current.commands")) + }); + + private static EveSurfaceTree CreateSurfaceTree(string id) => new( + id, + new EveSurfaceComponent( + "root", + "column", + new Dictionary(), + Array.Empty()), + Array.Empty()); + + private static byte[] WriteLegacySurfaceDocument() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(7); + writer.Write("legacy-provider"); + writer.Write("legacy-kind"); + writer.Write("Legacy surface"); + writer.Write(17L); + writer.Write("2026-07-13T12:00:00Z"); + writer.WriteRaw(MessagePackSerializer.Serialize(CreateSurfaceTree("legacy-surface"), Options)); + writer.WriteArrayHeader(1); + writer.WriteRaw(WriteLegacyCommandTemplate()); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static byte[] WriteLegacyCommandTemplate() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(5); + writer.Write("legacy.execute"); + writer.Write("Execute legacy command"); + writer.Write("gamecult.legacy.command.v1"); + writer.Write("cultmesh"); + writer.Write("legacy.commands"); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } +} diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj new file mode 100644 index 0000000..e3c947a --- /dev/null +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + false + true + obj\bin\ + + + + + + + + + + + +