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",