Make Eve command receipts portable

This commit is contained in:
Meta Vens 2026-08-18 06:21:44 +02:00
commit cbe9d9f25e
2 changed files with 270 additions and 1 deletions

View file

@ -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; }
}
/// <summary>
/// Writes the public command-receipt contract as a string-keyed map while retaining
/// read compatibility with the original positional C# representation.
/// </summary>
public sealed class EveCommandReceiptCompatibilityFormatter :
IMessagePackFormatter<EveCommandReceiptDocument?>
{
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<EveSurfaceNavigationTarget>()
.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<string>();
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<string>();
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
{

View file

@ -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<EveCommandReceiptDocument>(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<byte>();
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<EveCommandReceiptDocument>(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<EveCommandReceiptDocument>(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",