diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs index dc7440a..052f05e 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -349,6 +349,7 @@ namespace GameCult.Eve.Surface [CultDocument("gamecult.eve.command_receipt", SchemaId)] [MessagePackObject] + [MessagePackFormatter(typeof(EveCommandReceiptCompatibilityFormatter))] public sealed class EveCommandReceiptDocument { public const string SchemaId = "gamecult.eve.command_receipt.v1"; @@ -416,6 +417,206 @@ namespace GameCult.Eve.Surface [Key(12)] public EveSurfaceNavigationTarget? Navigation { get; } } + /// + /// Writes the public command-receipt contract as a string-keyed map while retaining + /// read compatibility with the original positional C# representation. + /// + public sealed class EveCommandReceiptCompatibilityFormatter : + IMessagePackFormatter + { + private const int LegacyFieldCount = 13; + private const int RequiredAndScalarFieldCount = 12; + + public void Serialize( + ref MessagePackWriter writer, + EveCommandReceiptDocument? value, + MessagePackSerializerOptions options) + { + if (value == null) { writer.WriteNil(); return; } + + writer.WriteMapHeader(value.Navigation == null + ? RequiredAndScalarFieldCount + : RequiredAndScalarFieldCount + 1); + Write(ref writer, "schema", value.Schema); + Write(ref writer, "receiptId", value.ReceiptId); + Write(ref writer, "commandId", value.CommandId); + Write(ref writer, "command", value.Command); + Write(ref writer, "state", value.State); + Write(ref writer, "ownerRepo", value.OwnerRepo); + Write(ref writer, "authority", value.Authority); + Write(ref writer, "providerId", value.ProviderId); + Write(ref writer, "surfaceId", value.SurfaceId); + Write(ref writer, "message", value.Message); + Write(ref writer, "issuedAtUtc", value.IssuedAtUtc); + writer.Write("sourceVersion"); writer.Write(value.SourceVersion); + if (value.Navigation != null) + { + writer.Write("navigation"); + WriteNavigation(ref writer, value.Navigation); + } + } + + public EveCommandReceiptDocument? Deserialize( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType == MessagePackType.Map) + return ReadMap(ref reader, options); + if (reader.NextMessagePackType != MessagePackType.Array) + throw new MessagePackSerializationException("Eve command receipt must be a map or legacy array."); + + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadArrayHeader(); + var schema = ReadString(ref reader, fields, 0, EveCommandReceiptDocument.SchemaId); + var receiptId = ReadString(ref reader, fields, 1); + var commandId = ReadString(ref reader, fields, 2); + var command = ReadString(ref reader, fields, 3); + var state = ReadString(ref reader, fields, 4); + var ownerRepo = ReadString(ref reader, fields, 5); + var authority = ReadString(ref reader, fields, 6); + var providerId = ReadString(ref reader, fields, 7); + var surfaceId = ReadString(ref reader, fields, 8); + var message = ReadString(ref reader, fields, 9); + var issuedAtUtc = ReadString(ref reader, fields, 10); + var sourceVersion = fields > 11 ? reader.ReadInt64() : 0; + var navigation = fields > 12 ? ReadNavigation(ref reader, options) : null; + for (var index = LegacyFieldCount; index < fields; index++) reader.Skip(); + return new EveCommandReceiptDocument( + schema, receiptId, commandId, command, state, ownerRepo, authority, + providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation); + } + finally { reader.Depth--; } + } + + private static EveCommandReceiptDocument ReadMap( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + options.Security.DepthStep(ref reader); + try + { + var schema = EveCommandReceiptDocument.SchemaId; + var receiptId = ""; + var commandId = ""; + var command = ""; + var state = ""; + var ownerRepo = ""; + var authority = ""; + var providerId = ""; + var surfaceId = ""; + var message = ""; + var issuedAtUtc = ""; + long sourceVersion = 0; + EveSurfaceNavigationTarget? navigation = null; + var fields = reader.ReadMapHeader(); + for (var index = 0; index < fields; index++) + { + switch (reader.ReadString()) + { + case "schema": schema = ReadString(ref reader); break; + case "receiptId": receiptId = ReadString(ref reader); break; + case "commandId": commandId = ReadString(ref reader); break; + case "command": command = ReadString(ref reader); break; + case "state": state = ReadString(ref reader); break; + case "ownerRepo": ownerRepo = ReadString(ref reader); break; + case "authority": authority = ReadString(ref reader); break; + case "providerId": providerId = ReadString(ref reader); break; + case "surfaceId": surfaceId = ReadString(ref reader); break; + case "message": message = ReadString(ref reader); break; + case "issuedAtUtc": issuedAtUtc = ReadString(ref reader); break; + case "sourceVersion": sourceVersion = reader.ReadInt64(); break; + case "navigation": navigation = ReadNavigation(ref reader, options); break; + default: reader.Skip(); break; + } + } + + return new EveCommandReceiptDocument( + schema, receiptId, commandId, command, state, ownerRepo, authority, + providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation); + } + finally { reader.Depth--; } + } + + private static void WriteNavigation( + ref MessagePackWriter writer, + EveSurfaceNavigationTarget? navigation) + { + if (navigation == null) { writer.WriteNil(); return; } + + writer.WriteMapHeader(5); + Write(ref writer, "verseId", navigation.VerseId); + Write(ref writer, "providerId", navigation.ProviderId); + Write(ref writer, "surfaceId", navigation.SurfaceId); + Write(ref writer, "surfaceKind", navigation.SurfaceKind); + writer.Write("rendezvousEndpoints"); + writer.WriteArrayHeader(navigation.RendezvousEndpoints.Length); + foreach (var endpoint in navigation.RendezvousEndpoints) writer.Write(endpoint); + } + + private static EveSurfaceNavigationTarget? ReadNavigation( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType == MessagePackType.Array) + return options.Resolver.GetFormatterWithVerify() + .Deserialize(ref reader, options); + if (reader.NextMessagePackType != MessagePackType.Map) + throw new MessagePackSerializationException("Eve receipt navigation must be a map or legacy array."); + + options.Security.DepthStep(ref reader); + try + { + var verseId = ""; + var providerId = ""; + var surfaceId = ""; + var surfaceKind = ""; + var endpoints = Array.Empty(); + var fields = reader.ReadMapHeader(); + for (var index = 0; index < fields; index++) + { + switch (reader.ReadString()) + { + case "verseId": verseId = ReadString(ref reader); break; + case "providerId": providerId = ReadString(ref reader); break; + case "surfaceId": surfaceId = ReadString(ref reader); break; + case "surfaceKind": surfaceKind = ReadString(ref reader); break; + case "rendezvousEndpoints": endpoints = ReadStrings(ref reader); break; + default: reader.Skip(); break; + } + } + return new EveSurfaceNavigationTarget(verseId, providerId, surfaceId, surfaceKind, endpoints); + } + finally { reader.Depth--; } + } + + private static string[] ReadStrings(ref MessagePackReader reader) + { + if (reader.TryReadNil()) return Array.Empty(); + var count = reader.ReadArrayHeader(); + var values = new string[count]; + for (var index = 0; index < count; index++) values[index] = ReadString(ref reader); + return values; + } + + private static string ReadString(ref MessagePackReader reader) => reader.TryReadNil() ? "" : reader.ReadString() ?? ""; + + private static string ReadString( + ref MessagePackReader reader, + int fields, + int index, + string fallback = "") => fields > index ? ReadString(ref reader) : fallback; + + private static void Write(ref MessagePackWriter writer, string key, string value) + { + writer.Write(key); + writer.Write(value ?? ""); + } + } + [MessagePackObject] public sealed class EveSurfaceNavigationTarget { diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index 9e13410..df83356 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -212,9 +212,12 @@ public sealed class EveSurfaceSerializationCompatibilityTests var bytes = MessagePackSerializer.Serialize(document, Options); var reader = new MessagePackReader(bytes); + using var wireJson = JsonDocument.Parse(MessagePackSerializer.ConvertToJson(bytes)); var restored = MessagePackSerializer.Deserialize(bytes, Options); - Assert.That(reader.ReadArrayHeader(), Is.EqualTo(13)); + Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Map)); + Assert.That(reader.ReadMapHeader(), Is.EqualTo(13)); + Assert.That(wireJson.RootElement.GetProperty("navigation").ValueKind, Is.EqualTo(JsonValueKind.Object)); Assert.That(restored.Navigation, Is.Not.Null); Assert.That(restored.Navigation!.VerseId, Is.EqualTo("gamecult.aetheria")); Assert.That(restored.Navigation.SurfaceId, Is.EqualTo("aetheria.pilot")); @@ -222,6 +225,71 @@ public sealed class EveSurfaceSerializationCompatibilityTests Assert.That(restored.Navigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.gamecult.example:3076" })); } + [Test] + public void CommandReceiptReadsLegacyPositionalRepresentation() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(13); + writer.Write(EveCommandReceiptDocument.SchemaId); + writer.Write("receipt:legacy"); + writer.Write("legacy-command"); + writer.Write("aetheria.hangar.launch"); + writer.Write("accepted"); + writer.Write("Aetheria"); + writer.Write("commander-daemon"); + writer.Write("aetheria.daemon"); + writer.Write("aetheria.hangar"); + writer.Write(""); + writer.Write("2026-08-17T20:00:00Z"); + writer.Write(42L); + writer.WriteArrayHeader(5); + writer.Write("gamecult.aetheria"); + writer.Write("aetheria.daemon"); + writer.Write("aetheria.pilot"); + writer.Write("interactive-world"); + writer.WriteArrayHeader(1); + writer.Write("cultnet+tcp://odin.gamecult.example:3076"); + writer.Flush(); + + var restored = MessagePackSerializer.Deserialize(buffer.WrittenMemory, Options); + + Assert.Multiple(() => + { + Assert.That(restored.ReceiptId, Is.EqualTo("receipt:legacy")); + Assert.That(restored.CommandId, Is.EqualTo("legacy-command")); + Assert.That(restored.Navigation, Is.Not.Null); + Assert.That(restored.Navigation!.SurfaceId, Is.EqualTo("aetheria.pilot")); + }); + } + + [Test] + public void CommandReceiptOmitsAbsentOptionalNavigation() + { + var document = new EveCommandReceiptDocument( + "receipt:no-navigation", + "select-verse", + "aetheria.hangar.select_verse", + "accepted", + "Aetheria", + "commander-daemon", + "aetheria.daemon", + "aetheria.hangar", + "", + "2026-08-17T20:00:00Z", + 42); + + var bytes = MessagePackSerializer.Serialize(document, Options); + using var wireJson = JsonDocument.Parse(MessagePackSerializer.ConvertToJson(bytes)); + var restored = MessagePackSerializer.Deserialize(bytes, Options); + + Assert.Multiple(() => + { + Assert.That(wireJson.RootElement.TryGetProperty("navigation", out _), Is.False); + Assert.That(restored.Navigation, Is.Null); + }); + } + private static EveSurfaceDocument CreateCurrentDocument() => new( "current-provider", "current-kind",