From 322fa40713d4f9f137b3a9bd617cd5197f99e76e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 01:29:11 +0200 Subject: [PATCH] Unify Eve surface wire format --- docs/surface-contract-v1.md | 5 + packages/eve-contracts/README.md | 12 + packages/eve-contracts/package-lock.json | 11 + packages/eve-contracts/package.json | 1 + .../eve-contracts/test/contracts.test.mjs | 15 + .../fixtures/eve-surface-csharp-v1.base64 | 1 + .../Runtime/EveSurfaceDocument.cs | 388 +++++++++++++++++- ...eSurfaceSerializationCompatibilityTests.cs | 62 ++- ...ult.Eve.Surface.Serialization.Tests.csproj | 7 +- 9 files changed, 474 insertions(+), 28 deletions(-) create mode 100644 packages/eve-contracts/test/fixtures/eve-surface-csharp-v1.base64 diff --git a/docs/surface-contract-v1.md b/docs/surface-contract-v1.md index 0a91b3c..bb7b9aa 100644 --- a/docs/surface-contract-v1.md +++ b/docs/surface-contract-v1.md @@ -13,6 +13,11 @@ another local client, but the semantic surface is the same retained tree. - CultMesh owns delivery, replay, provenance, and subscription state. - Renderers own native projection only. They do not invent provider semantics. +The schema member names are also the canonical MessagePack wire keys. Providers +publish map-shaped documents which any runtime can decode into the schema shape +without a provider-specific adapter. The former C# indexed-array encoding is a +read-only migration format; it is not a second public contract. + ## Surface Document Required top-level fields: diff --git a/packages/eve-contracts/README.md b/packages/eve-contracts/README.md index efa861d..dd16b57 100644 --- a/packages/eve-contracts/README.md +++ b/packages/eve-contracts/README.md @@ -21,3 +21,15 @@ const input = parseEveInputCapability(untrustedInputCapability); Providers own their state, surfaces, command acceptance, and receipts. This package owns only the portable contract and validation boundary. + +## MessagePack wire shape + +The JSON Schemas also name the public MessagePack shape: contracts are encoded +as maps with the schema's camel-case member names. They are not positional +arrays. `GameCult.Eve.Surface` emits that map shape and may read the former +indexed-array encoding only as migration input. + +The checked `test/fixtures/eve-surface-csharp-v1.base64` payload is emitted by +the real C# formatter. The C# suite rejects formatter drift from that fixture; +the TypeScript suite decodes the same bytes and validates the resulting surface. +Do not replace this proof with a handwritten TypeScript facsimile of the C# DTO. diff --git a/packages/eve-contracts/package-lock.json b/packages/eve-contracts/package-lock.json index 7d85b24..3a27b3d 100644 --- a/packages/eve-contracts/package-lock.json +++ b/packages/eve-contracts/package-lock.json @@ -12,6 +12,7 @@ "ajv": "^8.17.1" }, "devDependencies": { + "@msgpack/msgpack": "^3.1.2", "@types/node": "^24.0.0", "json-schema-to-typescript": "^15.0.4", "typescript": "^5.9.0" @@ -42,6 +43,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/packages/eve-contracts/package.json b/packages/eve-contracts/package.json index 9869312..0bd9fc9 100644 --- a/packages/eve-contracts/package.json +++ b/packages/eve-contracts/package.json @@ -29,6 +29,7 @@ "ajv": "^8.17.1" }, "devDependencies": { + "@msgpack/msgpack": "^3.1.2", "@types/node": "^24.0.0", "json-schema-to-typescript": "^15.0.4", "typescript": "^5.9.0" diff --git a/packages/eve-contracts/test/contracts.test.mjs b/packages/eve-contracts/test/contracts.test.mjs index 2847c1a..3650a5f 100644 --- a/packages/eve-contracts/test/contracts.test.mjs +++ b/packages/eve-contracts/test/contracts.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import test from "node:test"; +import { decode } from "@msgpack/msgpack"; import { EveContractValidationError, isEveCommandDescriptor, @@ -12,6 +13,20 @@ import { parseEveSurfaceDocument, } from "../dist/index.js"; +test("parses the schema-shaped MessagePack surface emitted by the real C# Eve package", async () => { + const fixtureUrl = new URL("./fixtures/eve-surface-csharp-v1.base64", import.meta.url); + const bytes = Buffer.from((await readFile(fixtureUrl, "utf8")).trim(), "base64"); + const surface = parseEveSurfaceDocument(decode(bytes)); + + assert.equal(surface.providerId, "current-provider"); + assert.equal(surface.surface.root.kind, "column"); + assert.equal(surface.surface.root.stateBindings[0].pointerId, "hangar.selectedShip.name"); + assert.equal(surface.surface.root.embeddedDocuments[0].presentationKind, "inventory-grid"); + assert.equal(surface.surface.styles[0].value, "#50f5dc"); + assert.equal(surface.commands[0].command, "current.execute"); + assert.equal(surface.commands[0].payloadSchema, "gamecult.current.command.v1"); +}); + const command = { schema: "gamecult.eve.command.v1", command: "swarm.set_heat", transport: "cultmesh" }; const surface = { type: "surface-state", diff --git a/packages/eve-contracts/test/fixtures/eve-surface-csharp-v1.base64 b/packages/eve-contracts/test/fixtures/eve-surface-csharp-v1.base64 new file mode 100644 index 0000000..0615ba8 --- /dev/null +++ b/packages/eve-contracts/test/fixtures/eve-surface-csharp-v1.base64 @@ -0,0 +1 @@ +iaR0eXBlrXN1cmZhY2Utc3RhdGWmc2NoZW1ht2dhbWVjdWx0LmV2ZS5zdXJmYWNlLnYxqnByb3ZpZGVySWSwY3VycmVudC1wcm92aWRlcqxwcm92aWRlcktpbmSsY3VycmVudC1raW5kpXRpdGxlr0N1cnJlbnQgc3VyZmFjZad2ZXJzaW9uF6x1cGRhdGVkQXRVdGO0MjAyNi0wNy0xM1QxMzowMDowMFqnc3VyZmFjZYOiaWSvY3VycmVudC1zdXJmYWNlpHJvb3SIomlkpHJvb3Ska2luZKZjb2x1bW6lcHJvcHOBpWxhYmVsqFZhbmd1YXJkqGNoaWxkcmVukK1zdGF0ZUJpbmRpbmdzkYaqdGFyZ2V0UHJvcKVsYWJlbKlwb2ludGVySWS4aGFuZ2FyLnNlbGVjdGVkU2hpcC5uYW1lqHNvdXJjZUlkrmhhbmdhcjpjdXJyZW50qHNjaGVtYUlku2dhbWVjdWx0LmFldGhlcmlhLmhhbmdhci52Malyb3V0ZUtpbmSoQ3VsdE1lc2iwcm91dGVEZXNjcmlwdGlvbqVsb2NhbLFlbWJlZGRlZERvY3VtZW50c5GFpnNsb3RJZKlpbnZlbnRvcnmqZG9jdW1lbnRJZLBoYW5nYXI6aW52ZW50b3J5qHNjaGVtYUlkvmdhbWVjdWx0LmFldGhlcmlhLmludmVudG9yeS52MbBwcmVzZW50YXRpb25LaW5krmludmVudG9yeS1ncmlkqXJvdXRlSGludIKka2luZKhDdWx0TWVzaKtkZXNjcmlwdGlvbqVsb2NhbKZsYXlvdXSBpGdyb3ehMaVzdHlsZYGmYWNjZW50pmhhbmdhcqZzdHlsZXORgqRuYW1lpmFjY2VudKV2YWx1ZacjNTBmNWRjqGNvbW1hbmRzkYemc2NoZW1ht2dhbWVjdWx0LmV2ZS5jb21tYW5kLnYxp2NvbW1hbmSvY3VycmVudC5leGVjdXRlpWxhYmVst0V4ZWN1dGUgY3VycmVudCBjb21tYW5krXBheWxvYWRTY2hlbWG7Z2FtZWN1bHQuY3VycmVudC5jb21tYW5kLnYxqXRyYW5zcG9ydKhjdWx0bWVzaKlyb3V0ZUtpbmSoY3VsdG1lc2iwcm91dGVEZXNjcmlwdGlvbrBjdXJyZW50LmNvbW1hbmRz diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs index e7d6536..4bb33f5 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs @@ -426,23 +426,28 @@ namespace GameCult.Eve.Surface 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); + writer.WriteMapHeader(CurrentFieldCount); + Write(ref writer, "type", value.Type); + Write(ref writer, "schema", value.Schema); + Write(ref writer, "providerId", value.ProviderId); + Write(ref writer, "providerKind", value.ProviderKind); + Write(ref writer, "title", value.Title); + writer.Write("version"); writer.Write(value.Version); + Write(ref writer, "updatedAtUtc", value.UpdatedAtUtc); + writer.Write("surface"); WriteSurface(ref writer, value.Surface, options); + writer.Write("commands"); + writer.WriteArrayHeader(value.Commands.Count); + foreach (var command in value.Commands) + Formatter(options).Serialize(ref writer, command, options); } public EveSurfaceDocument? 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 surface document must be an array."); + throw new MessagePackSerializationException("Eve surface document must be a map or legacy array."); options.Security.DepthStep(ref reader); try @@ -476,6 +481,317 @@ namespace GameCult.Eve.Surface finally { reader.Depth--; } } + private static EveSurfaceDocument ReadMap(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + options.Security.DepthStep(ref reader); + try + { + var type = EveSurfaceDocument.DefaultType; + var schema = EveSurfaceDocument.SchemaId; + var providerId = ""; + var providerKind = ""; + var title = ""; + long version = 0; + var updatedAtUtc = ""; + EveSurfaceTree? surface = null; + IReadOnlyList commands = Array.Empty(); + var fields = reader.ReadMapHeader(); + for (var index = 0; index < fields; index++) + { + switch (reader.ReadString()) + { + case "type": type = ReadString(ref reader); break; + case "schema": schema = ReadString(ref reader); break; + case "providerId": providerId = ReadString(ref reader); break; + case "providerKind": providerKind = ReadString(ref reader); break; + case "title": title = ReadString(ref reader); break; + case "version": version = reader.ReadInt64(); break; + case "updatedAt": + case "updatedAtUtc": updatedAtUtc = ReadString(ref reader); break; + case "surface": surface = ReadSurface(ref reader, options); break; + case "commands": commands = ReadCommands(ref reader, options); break; + default: reader.Skip(); break; + } + } + + return new EveSurfaceDocument( + type, schema, providerId, providerKind, title, version, updatedAtUtc, + surface ?? throw new MessagePackSerializationException("Eve surface tree is required."), + commands); + } + finally { reader.Depth--; } + } + + private static void WriteSurface( + ref MessagePackWriter writer, + EveSurfaceTree surface, + MessagePackSerializerOptions options) + { + writer.WriteMapHeader(3); + Write(ref writer, "id", surface.Id); + writer.Write("root"); WriteComponent(ref writer, surface.Root, options); + writer.Write("styles"); + writer.WriteArrayHeader(surface.Styles.Count); + foreach (var style in surface.Styles) + { + writer.WriteMapHeader(2); + Write(ref writer, "name", style.Name); + Write(ref writer, "value", style.Value); + } + } + + private static EveSurfaceTree ReadSurface( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + if (reader.NextMessagePackType == MessagePackType.Array) + return Formatter(options).Deserialize(ref reader, options)!; + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadMapHeader(); + var id = ""; + EveSurfaceComponent? root = null; + IReadOnlyList styles = Array.Empty(); + for (var index = 0; index < fields; index++) + { + switch (reader.ReadString()) + { + case "id": id = ReadString(ref reader); break; + case "root": root = ReadComponent(ref reader, options); break; + case "styles": styles = ReadStyles(ref reader); break; + default: reader.Skip(); break; + } + } + return new EveSurfaceTree( + id, + root ?? throw new MessagePackSerializationException("Eve surface root is required."), + styles); + } + finally { reader.Depth--; } + } + + private static void WriteComponent( + ref MessagePackWriter writer, + EveSurfaceComponent component, + MessagePackSerializerOptions options) + { + writer.WriteMapHeader(8); + Write(ref writer, "id", component.Id); + Write(ref writer, "kind", component.Kind); + writer.Write("props"); WriteStringMap(ref writer, component.Props); + writer.Write("children"); + writer.WriteArrayHeader(component.Children.Count); + foreach (var child in component.Children) WriteComponent(ref writer, child, options); + writer.Write("stateBindings"); + writer.WriteArrayHeader(component.StateBindingRecords.Length); + foreach (var binding in component.StateBindingRecords) WriteStateBinding(ref writer, binding); + writer.Write("embeddedDocuments"); + writer.WriteArrayHeader(component.EmbeddedDocuments.Count); + foreach (var embedded in component.EmbeddedDocuments) WriteEmbedded(ref writer, embedded); + writer.Write("layout"); WriteStringMap(ref writer, component.Layout); + writer.Write("style"); WriteStringMap(ref writer, component.Style); + } + + private static EveSurfaceComponent ReadComponent( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + if (reader.NextMessagePackType == MessagePackType.Array) + return Formatter(options).Deserialize(ref reader, options)!; + options.Security.DepthStep(ref reader); + try + { + var fields = reader.ReadMapHeader(); + var id = ""; + var kind = ""; + IReadOnlyDictionary props = new Dictionary(); + IReadOnlyList children = Array.Empty(); + CultMeshStateBindingRecord[] stateBindings = Array.Empty(); + IReadOnlyList embedded = Array.Empty(); + IReadOnlyDictionary layout = new Dictionary(); + IReadOnlyDictionary style = new Dictionary(); + for (var index = 0; index < fields; index++) + { + switch (reader.ReadString()) + { + case "id": id = ReadString(ref reader); break; + case "kind": kind = ReadString(ref reader); break; + case "props": props = ReadStringMap(ref reader); break; + case "children": children = ReadComponents(ref reader, options); break; + case "stateBindings": stateBindings = ReadStateBindings(ref reader); break; + case "embeddedDocuments": embedded = ReadEmbeddedDocuments(ref reader); break; + case "layout": layout = ReadStringMap(ref reader); break; + case "style": style = ReadStringMap(ref reader); break; + default: reader.Skip(); break; + } + } + return new EveSurfaceComponent(id, kind, props, children, stateBindings, embedded, layout, style); + } + finally { reader.Depth--; } + } + + private static void WriteStateBinding(ref MessagePackWriter writer, CultMeshStateBindingRecord binding) + { + writer.WriteMapHeader(6); + Write(ref writer, "targetProp", binding.TargetProp); + Write(ref writer, "pointerId", binding.PointerId); + Write(ref writer, "sourceId", binding.SourceId); + Write(ref writer, "schemaId", binding.SchemaId); + Write(ref writer, "routeKind", binding.RouteKind); + Write(ref writer, "routeDescription", binding.RouteDescription); + } + + private static CultMeshStateBindingRecord[] ReadStateBindings(ref MessagePackReader reader) + { + var count = reader.ReadArrayHeader(); + var values = new CultMeshStateBindingRecord[count]; + for (var index = 0; index < count; index++) + { + var fields = reader.ReadMapHeader(); + var targetProp = ""; var pointerId = ""; var sourceId = ""; + var schemaId = ""; var routeKind = ""; var routeDescription = ""; + for (var field = 0; field < fields; field++) + { + switch (reader.ReadString()) + { + case "targetProp": targetProp = ReadString(ref reader); break; + case "pointerId": pointerId = ReadString(ref reader); break; + case "sourceId": sourceId = ReadString(ref reader); break; + case "schemaId": schemaId = ReadString(ref reader); break; + case "routeKind": routeKind = ReadString(ref reader); break; + case "routeDescription": routeDescription = ReadString(ref reader); break; + default: reader.Skip(); break; + } + } + values[index] = new CultMeshStateBindingRecord( + targetProp, pointerId, sourceId, schemaId, routeKind, routeDescription); + } + return values; + } + + private static void WriteEmbedded(ref MessagePackWriter writer, EveEmbeddedDocumentSlot embedded) + { + writer.WriteMapHeader(5); + Write(ref writer, "slotId", embedded.SlotId); + Write(ref writer, "documentId", embedded.DocumentId); + Write(ref writer, "schemaId", embedded.SchemaId); + Write(ref writer, "presentationKind", embedded.PresentationKind); + writer.Write("routeHint"); + writer.WriteMapHeader(2); + Write(ref writer, "kind", embedded.Route.Kind); + Write(ref writer, "description", embedded.Route.Description); + } + + private static IReadOnlyList ReadEmbeddedDocuments(ref MessagePackReader reader) + { + var count = reader.ReadArrayHeader(); + var values = new EveEmbeddedDocumentSlot[count]; + for (var index = 0; index < count; index++) + { + var fields = reader.ReadMapHeader(); + var slotId = ""; var documentId = ""; var schemaId = ""; var presentationKind = ""; + var route = new CultMeshRouteRecord(); + for (var field = 0; field < fields; field++) + { + switch (reader.ReadString()) + { + case "slotId": slotId = ReadString(ref reader); break; + case "documentId": documentId = ReadString(ref reader); break; + case "schemaId": schemaId = ReadString(ref reader); break; + case "presentationKind": presentationKind = ReadString(ref reader); break; + case "routeHint": route = ReadRoute(ref reader); break; + default: reader.Skip(); break; + } + } + values[index] = new EveEmbeddedDocumentSlot(slotId, documentId, schemaId, presentationKind, route); + } + return values; + } + + private static CultMeshRouteRecord ReadRoute(ref MessagePackReader reader) + { + if (reader.NextMessagePackType == MessagePackType.String) + return new CultMeshRouteRecord(ReadString(ref reader), ""); + var fields = reader.ReadMapHeader(); + var kind = ""; var description = ""; + for (var index = 0; index < fields; index++) + { + switch (reader.ReadString()) + { + case "kind": kind = ReadString(ref reader); break; + case "description": description = ReadString(ref reader); break; + default: reader.Skip(); break; + } + } + return new CultMeshRouteRecord(kind, description); + } + + private static IReadOnlyList ReadComponents( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + var count = reader.ReadArrayHeader(); + var values = new EveSurfaceComponent[count]; + for (var index = 0; index < count; index++) values[index] = ReadComponent(ref reader, options); + return values; + } + + private static IReadOnlyList ReadStyles(ref MessagePackReader reader) + { + var count = reader.ReadArrayHeader(); + var values = new EveStyleToken[count]; + for (var index = 0; index < count; index++) + { + var fields = reader.ReadMapHeader(); + var name = ""; var value = ""; + for (var field = 0; field < fields; field++) + { + switch (reader.ReadString()) + { + case "name": name = ReadString(ref reader); break; + case "value": value = ReadString(ref reader); break; + default: reader.Skip(); break; + } + } + values[index] = new EveStyleToken(name, value); + } + return values; + } + + private static IReadOnlyList ReadCommands( + ref MessagePackReader reader, + MessagePackSerializerOptions options) + { + var count = reader.ReadArrayHeader(); + var values = new EveCommandTemplate[count]; + for (var index = 0; index < count; index++) + values[index] = Formatter(options).Deserialize(ref reader, options)!; + return values; + } + + private static void WriteStringMap( + ref MessagePackWriter writer, + IReadOnlyDictionary values) + { + writer.WriteMapHeader(values.Count); + foreach (var pair in values) Write(ref writer, pair.Key, pair.Value); + } + + private static IReadOnlyDictionary ReadStringMap(ref MessagePackReader reader) + { + var count = reader.ReadMapHeader(); + var values = new Dictionary(count, StringComparer.Ordinal); + for (var index = 0; index < count; index++) values[ReadString(ref reader)] = ReadString(ref reader); + return values; + } + + private static void Write(ref MessagePackWriter writer, string key, string value) + { + writer.Write(key); + writer.Write(value); + } + private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) => options.Resolver.GetFormatter() ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}."); @@ -493,15 +809,23 @@ namespace GameCult.Eve.Surface 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); + writer.WriteMapHeader(7); + Write(ref writer, "schema", "gamecult.eve.command.v1"); + Write(ref writer, "command", value.OperationRecord.OperationId); + Write(ref writer, "label", value.OperationRecord.Label); + Write(ref writer, "payloadSchema", value.OperationRecord.SchemaId); + Write(ref writer, "transport", value.OperationRecord.RouteKind); + Write(ref writer, "routeKind", value.OperationRecord.RouteKind); + Write(ref writer, "routeDescription", value.OperationRecord.RouteDescription); } public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { if (reader.TryReadNil()) return null; + if (reader.NextMessagePackType == MessagePackType.Map) + return ReadMap(ref reader); if (reader.NextMessagePackType != MessagePackType.Array) - throw new MessagePackSerializationException("Eve command template must be an array."); + throw new MessagePackSerializationException("Eve command template must be a map or legacy array."); options.Security.DepthStep(ref reader); try @@ -526,6 +850,32 @@ namespace GameCult.Eve.Surface finally { reader.Depth--; } } + private static EveCommandTemplate ReadMap(ref MessagePackReader reader) + { + var fields = reader.ReadMapHeader(); + var operationId = ""; var label = ""; var schemaId = ""; + var transport = ""; var routeKind = ""; var routeDescription = ""; + for (var index = 0; index < fields; index++) + { + switch (reader.ReadString()) + { + case "command": + case "operationId": operationId = ReadString(ref reader); break; + case "label": label = ReadString(ref reader); break; + case "payloadSchema": + case "schemaId": schemaId = ReadString(ref reader); break; + case "transport": transport = ReadString(ref reader); break; + case "routeKind": routeKind = ReadString(ref reader); break; + case "routeDescription": routeDescription = ReadString(ref reader); break; + default: reader.Skip(); break; + } + } + return new EveCommandTemplate(new CultMeshOperationBindingRecord( + operationId, label, schemaId, + string.IsNullOrWhiteSpace(routeKind) ? transport : routeKind, + routeDescription)); + } + private static IMessagePackFormatter BindingFormatter( MessagePackSerializerOptions options) => options.Resolver.GetFormatter() @@ -533,8 +883,14 @@ namespace GameCult.Eve.Surface private static string ReadString(ref MessagePackReader reader, int fields, int index) => index < fields ? reader.ReadString() ?? "" : ""; + + private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? ""; + + private static void Write(ref MessagePackWriter writer, string key, string value) + { + writer.Write(key); + writer.Write(value); + } } } - - diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index 22d4417..9e13410 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Text.Json; using GameCult.Eve.Surface; using GameCult.Mesh; using MessagePack; @@ -108,19 +109,37 @@ public sealed class EveSurfaceSerializationCompatibilityTests } [Test] - public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding() + public void CurrentSerializationWritesSchemaShapedMessagePackMap() { var document = CreateCurrentDocument(); var bytes = MessagePackSerializer.Serialize(document, Options); - var reader = new MessagePackReader(bytes); + var fixturePath = Path.Combine( + AppContext.BaseDirectory, + "Fixtures", + "eve-surface-csharp-v1.base64"); + Assert.That( + Convert.ToBase64String(bytes), + Is.EqualTo(File.ReadAllText(fixturePath).Trim()), + "The checked cross-runtime fixture must be regenerated from the real C# formatter when the wire changes."); + using var wire = JsonDocument.Parse(MessagePackSerializer.ConvertToJson(bytes)); + var root = wire.RootElement; + var surface = root.GetProperty("surface"); + var component = surface.GetProperty("root"); + var command = root.GetProperty("commands")[0]; - 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"); + Assert.Multiple(() => + { + Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(root.GetProperty("schema").GetString(), Is.EqualTo(EveSurfaceDocument.SchemaId)); + Assert.That(surface.ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(component.ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(component.GetProperty("stateBindings").ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(command.ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(command.GetProperty("schema").GetString(), Is.EqualTo("gamecult.eve.command.v1")); + Assert.That(command.GetProperty("command").GetString(), Is.EqualTo("current.execute")); + Assert.That(command.GetProperty("payloadSchema").GetString(), Is.EqualTo("gamecult.current.command.v1")); + }); var roundTrip = MessagePackSerializer.Deserialize(bytes, Options); Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute")); @@ -225,9 +244,30 @@ public sealed class EveSurfaceSerializationCompatibilityTests new EveSurfaceComponent( "root", "column", - new Dictionary(), - Array.Empty()), - Array.Empty()); + new Dictionary { ["label"] = "Vanguard" }, + Array.Empty(), + new[] + { + new CultMeshStateBindingRecord( + "label", + "hangar.selectedShip.name", + "hangar:current", + "gamecult.aetheria.hangar.v1", + "CultMesh", + "local") + }, + new[] + { + new EveEmbeddedDocumentSlot( + "inventory", + "hangar:inventory", + "gamecult.aetheria.inventory.v1", + "inventory-grid", + new CultMeshRouteRecord("CultMesh", "local")) + }, + new Dictionary { ["grow"] = "1" }, + new Dictionary { ["accent"] = "hangar" }), + new[] { new EveStyleToken("accent", "#50f5dc") }); private static byte[] WriteLegacySurfaceDocument() { 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 index 85d1b96..c60c305 100644 --- 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 @@ -17,6 +17,11 @@ + + + + -