diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 64e786c..e51f0d6 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -1279,8 +1279,8 @@ export function createInventoryDropIntent(source, target, destinationX, destinat const targetIndex = numberProp(target.targetIndex, -1); const targetKind = firstString(target.targetKind, ""); const payload = {}; - copyInventoryPayloadProps(source, payload); - copyInventoryPayloadProps(target, payload); + copyPayloadProps(source, payload); + copyPayloadProps(target, payload); Object.assign(payload, { sourceKind, originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""), @@ -1353,7 +1353,7 @@ function parseInventoryCells(value) { return Number.isInteger(x) && Number.isInteger(y) ? [{ x, y }] : []; }); } -function copyInventoryPayloadProps(source, payload) { +function copyPayloadProps(source, payload) { for (const [key, value] of Object.entries(source)) { if (key.startsWith("payload.") && key.length > "payload.".length) payload[key.slice("payload.".length)] = value; @@ -1702,7 +1702,9 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { ...stringArray(props.captureBindings), ])]; const bindings = captureBindingValues(surface?.surface?.root, providerId, surfaceId, captureBindings, options.draftStore); - const payload = commandPayload(action); + const payload = {}; + copyPayloadProps(props, payload); + Object.assign(payload, commandPayload(action)); if (captureBindings.length) payload.bindings = bindings; const intent = { diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 2be87c0..8822796 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1520,8 +1520,8 @@ export function createInventoryDropIntent( const targetIndex = numberProp(target.targetIndex, -1); const targetKind = firstString(target.targetKind, ""); const payload: Record = {}; - copyInventoryPayloadProps(source, payload); - copyInventoryPayloadProps(target, payload); + copyPayloadProps(source, payload); + copyPayloadProps(target, payload); Object.assign(payload, { sourceKind, originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""), @@ -1610,7 +1610,7 @@ function parseInventoryCells(value: unknown): InventoryPlacementCell[] { }); } -function copyInventoryPayloadProps(source: Record, payload: Record): void { +function copyPayloadProps(source: Record, payload: Record): void { for (const [key, value] of Object.entries(source)) { if (key.startsWith("payload.") && key.length > "payload.".length) payload[key.slice("payload.".length)] = value; @@ -2007,7 +2007,9 @@ export function createEveCommandIntent( captureBindings, options.draftStore, ); - const payload = commandPayload(action); + const payload: Record = {}; + copyPayloadProps(props, payload); + Object.assign(payload, commandPayload(action)); if (captureBindings.length) payload.bindings = bindings; const intent: EveCommandIntent = { schema: "gamecult.eve.command_invocation.v1", diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index 05069fe..fb7816d 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -59,6 +59,30 @@ test("world interactions emit provider-routed command intents without local auth assert.equal(intent.payload.directionX, 1); }); +test("ordinary controls preserve provider-declared payload fields", () => { + const intent = createEveCommandIntent("aetheria.hangar.launch", { + label: "LAUNCH", + command: "aetheria.hangar.launch", + "payload.expectedProgressionVerseId": "gamecult.aetheria", + "payload.expectedProgressionSourceRevision": "7", + }, { + activeSurfaceId: "aetheria.hangar", + clientId: "browser.hangar-test", + provider: { providerId: "aetheria", surfaces: [{ + surfaceId: "aetheria.hangar", + worldInteraction: { + commandBoundary: "aetheria.hangar.commands", + receiptSchema: "gamecult.eve.command_receipt.v1", + }, + }] }, + }); + + assert.equal(intent.payload.expectedProgressionVerseId, "gamecult.aetheria"); + assert.equal(intent.payload.expectedProgressionSourceRevision, "7"); + assert.equal(intent.payload.label, undefined); + assert.equal(intent.payload.command, undefined); +}); + test("inventory drops select the provider-advertised operation and preserve spatial identity", () => { const intent = createInventoryDropIntent({ sourceKind: "cargo", diff --git a/packages/eve-contracts/dist/generated/command-invocation.d.ts b/packages/eve-contracts/dist/generated/command-invocation.d.ts index b0818b2..aba9560 100644 --- a/packages/eve-contracts/dist/generated/command-invocation.d.ts +++ b/packages/eve-contracts/dist/generated/command-invocation.d.ts @@ -18,4 +18,9 @@ export interface EveCommandInvocation { clientId: string; commandBoundary: string; receiptSchema: string; + delegation?: { + originalInvocationHash: string; + originalClientId: string; + delegatingRuntimeId: string; + }; } diff --git a/packages/eve-contracts/dist/generated/command-receipt.d.ts b/packages/eve-contracts/dist/generated/command-receipt.d.ts index 7c6c6c4..735cc2f 100644 --- a/packages/eve-contracts/dist/generated/command-receipt.d.ts +++ b/packages/eve-contracts/dist/generated/command-receipt.d.ts @@ -13,9 +13,21 @@ export interface EveCommandReceipt { [k: string]: any; }[]; issuedAtUtc?: string; + /** + * Provider-state generation that causally owns this result. It is not implicitly an Eve surface version. + */ sourceVersion: number; + /** + * Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality. + */ + presentationSurfaceVersion?: number; + /** + * Canonical digest of the immutable invocation envelope finalized by this receipt. + */ + invocationHash?: string; navigation?: { verseId: string; + authorityRuntimeId?: string; providerId?: string; surfaceId: string; surfaceKind?: string; diff --git a/packages/eve-contracts/dist/generated/command-result.d.ts b/packages/eve-contracts/dist/generated/command-result.d.ts index c59e932..5734cad 100644 --- a/packages/eve-contracts/dist/generated/command-result.d.ts +++ b/packages/eve-contracts/dist/generated/command-result.d.ts @@ -32,9 +32,21 @@ export interface EveCommandReceipt { [k: string]: any; }[]; issuedAtUtc?: string; + /** + * Provider-state generation that causally owns this result. It is not implicitly an Eve surface version. + */ sourceVersion: number; + /** + * Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality. + */ + presentationSurfaceVersion?: number; + /** + * Canonical digest of the immutable invocation envelope finalized by this receipt. + */ + invocationHash?: string; navigation?: { verseId: string; + authorityRuntimeId?: string; providerId?: string; surfaceId: string; surfaceKind?: string; diff --git a/packages/eve-contracts/dist/generated/schemas.d.ts b/packages/eve-contracts/dist/generated/schemas.d.ts index 98723d3..77d4d95 100644 --- a/packages/eve-contracts/dist/generated/schemas.d.ts +++ b/packages/eve-contracts/dist/generated/schemas.d.ts @@ -596,6 +596,25 @@ export declare const eveContractSchemas: { readonly type: "string"; readonly minLength: 1; }; + readonly delegation: { + readonly type: "object"; + readonly required: readonly ["originalInvocationHash", "originalClientId", "delegatingRuntimeId"]; + readonly properties: { + readonly originalInvocationHash: { + readonly type: "string"; + readonly minLength: 1; + }; + readonly originalClientId: { + readonly type: "string"; + readonly minLength: 1; + }; + readonly delegatingRuntimeId: { + readonly type: "string"; + readonly minLength: 1; + }; + }; + readonly additionalProperties: false; + }; }; readonly additionalProperties: false; }; @@ -653,6 +672,17 @@ export declare const eveContractSchemas: { readonly sourceVersion: { readonly type: "integer"; readonly minimum: 0; + readonly description: "Provider-state generation that causally owns this result. It is not implicitly an Eve surface version."; + }; + readonly presentationSurfaceVersion: { + readonly type: "integer"; + readonly minimum: 1; + readonly description: "Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality."; + }; + readonly invocationHash: { + readonly type: "string"; + readonly minLength: 1; + readonly description: "Canonical digest of the immutable invocation envelope finalized by this receipt."; }; readonly navigation: { readonly type: "object"; @@ -662,6 +692,10 @@ export declare const eveContractSchemas: { readonly type: "string"; readonly minLength: 1; }; + readonly authorityRuntimeId: { + readonly type: "string"; + readonly minLength: 1; + }; readonly providerId: { readonly type: "string"; }; diff --git a/packages/eve-contracts/dist/generated/schemas.js b/packages/eve-contracts/dist/generated/schemas.js index 7aacb44..e55b17f 100644 --- a/packages/eve-contracts/dist/generated/schemas.js +++ b/packages/eve-contracts/dist/generated/schemas.js @@ -714,6 +714,29 @@ export const eveContractSchemas = { "receiptSchema": { "type": "string", "minLength": 1 + }, + "delegation": { + "type": "object", + "required": [ + "originalInvocationHash", + "originalClientId", + "delegatingRuntimeId" + ], + "properties": { + "originalInvocationHash": { + "type": "string", + "minLength": 1 + }, + "originalClientId": { + "type": "string", + "minLength": 1 + }, + "delegatingRuntimeId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -787,7 +810,18 @@ export const eveContractSchemas = { }, "sourceVersion": { "type": "integer", - "minimum": 0 + "minimum": 0, + "description": "Provider-state generation that causally owns this result. It is not implicitly an Eve surface version." + }, + "presentationSurfaceVersion": { + "type": "integer", + "minimum": 1, + "description": "Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality." + }, + "invocationHash": { + "type": "string", + "minLength": 1, + "description": "Canonical digest of the immutable invocation envelope finalized by this receipt." }, "navigation": { "type": "object", @@ -800,6 +834,10 @@ export const eveContractSchemas = { "type": "string", "minLength": 1 }, + "authorityRuntimeId": { + "type": "string", + "minLength": 1 + }, "providerId": { "type": "string" }, diff --git a/packages/eve-contracts/src/generated/command-invocation.ts b/packages/eve-contracts/src/generated/command-invocation.ts index 5c15093..8c72787 100644 --- a/packages/eve-contracts/src/generated/command-invocation.ts +++ b/packages/eve-contracts/src/generated/command-invocation.ts @@ -20,4 +20,9 @@ issuedAt: string clientId: string commandBoundary: string receiptSchema: string +delegation?: { +originalInvocationHash: string +originalClientId: string +delegatingRuntimeId: string +} } diff --git a/packages/eve-contracts/src/generated/command-receipt.ts b/packages/eve-contracts/src/generated/command-receipt.ts index 46a6db4..7b95536 100644 --- a/packages/eve-contracts/src/generated/command-receipt.ts +++ b/packages/eve-contracts/src/generated/command-receipt.ts @@ -15,9 +15,21 @@ diagnostics?: { [k: string]: any }[] issuedAtUtc?: string +/** + * Provider-state generation that causally owns this result. It is not implicitly an Eve surface version. + */ sourceVersion: number +/** + * Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality. + */ +presentationSurfaceVersion?: number +/** + * Canonical digest of the immutable invocation envelope finalized by this receipt. + */ +invocationHash?: string navigation?: { verseId: string +authorityRuntimeId?: string providerId?: string surfaceId: string surfaceKind?: string diff --git a/packages/eve-contracts/src/generated/command-result.ts b/packages/eve-contracts/src/generated/command-result.ts index f6351e0..996252e 100644 --- a/packages/eve-contracts/src/generated/command-result.ts +++ b/packages/eve-contracts/src/generated/command-result.ts @@ -34,9 +34,21 @@ diagnostics?: { [k: string]: any }[] issuedAtUtc?: string +/** + * Provider-state generation that causally owns this result. It is not implicitly an Eve surface version. + */ sourceVersion: number +/** + * Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality. + */ +presentationSurfaceVersion?: number +/** + * Canonical digest of the immutable invocation envelope finalized by this receipt. + */ +invocationHash?: string navigation?: { verseId: string +authorityRuntimeId?: string providerId?: string surfaceId: string surfaceKind?: string diff --git a/packages/eve-contracts/src/generated/schemas.ts b/packages/eve-contracts/src/generated/schemas.ts index 736b0c0..96a71ee 100644 --- a/packages/eve-contracts/src/generated/schemas.ts +++ b/packages/eve-contracts/src/generated/schemas.ts @@ -714,6 +714,29 @@ export const eveContractSchemas = { "receiptSchema": { "type": "string", "minLength": 1 + }, + "delegation": { + "type": "object", + "required": [ + "originalInvocationHash", + "originalClientId", + "delegatingRuntimeId" + ], + "properties": { + "originalInvocationHash": { + "type": "string", + "minLength": 1 + }, + "originalClientId": { + "type": "string", + "minLength": 1 + }, + "delegatingRuntimeId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -787,7 +810,18 @@ export const eveContractSchemas = { }, "sourceVersion": { "type": "integer", - "minimum": 0 + "minimum": 0, + "description": "Provider-state generation that causally owns this result. It is not implicitly an Eve surface version." + }, + "presentationSurfaceVersion": { + "type": "integer", + "minimum": 1, + "description": "Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality." + }, + "invocationHash": { + "type": "string", + "minLength": 1, + "description": "Canonical digest of the immutable invocation envelope finalized by this receipt." }, "navigation": { "type": "object", @@ -800,6 +834,10 @@ export const eveContractSchemas = { "type": "string", "minLength": 1 }, + "authorityRuntimeId": { + "type": "string", + "minLength": 1 + }, "providerId": { "type": "string" }, diff --git a/packages/eve-contracts/test/contracts.test.mjs b/packages/eve-contracts/test/contracts.test.mjs index 377d94f..2795a9d 100644 --- a/packages/eve-contracts/test/contracts.test.mjs +++ b/packages/eve-contracts/test/contracts.test.mjs @@ -82,6 +82,7 @@ test("accepts canonical provider, surface, command, invocation, and receipt docu providerId: "voidbot.swarm", surfaceId: "voidbot.swarm", sourceVersion: 1, + invocationHash: "sha256:command-1", }).state, "reconciled"); }); diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj index 72b5b3f..c821cfb 100644 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj @@ -6,7 +6,7 @@ enable Portable Eve surface and provider contracts. GameCult.Eve.Surface - 0.3.2 + 0.3.6 GameCult MPL-2.0 https://github.com/GameCult/Eve diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs new file mode 100644 index 0000000..d7ee976 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs @@ -0,0 +1,53 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + /// Canonical digest for one immutable Eve command invocation. + public static class EveCommandInvocationHash + { + public static string Compute(EveSurfaceCommandRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + var canonical = new StringBuilder(); + Append(request.Schema); + Append(request.ProviderId); + Append(request.SurfaceId); + Append(request.OperationRecord.OperationId); + Append(request.OperationRecord.SchemaId); + Append(request.OperationRecord.RouteKind); + Append(request.OperationRecord.RouteDescription); + Append(request.OperationRecord.IdempotencyKey); + foreach (var field in request.PayloadFields.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + Append(field.Key); + Append(field.Value); + } + Append(request.IssuedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + Append(request.ClientId); + Append(request.CommandBoundary); + Append(request.ReceiptSchema); + Append(request.Delegation?.OriginalInvocationHash); + Append(request.Delegation?.OriginalClientId); + Append(request.Delegation?.DelegatingRuntimeId); + using var sha256 = SHA256.Create(); + var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(canonical.ToString())); + var digest = new StringBuilder(bytes.Length * 2); + foreach (var value in bytes) digest.Append(value.ToString("x2", CultureInfo.InvariantCulture)); + return digest.ToString(); + + void Append(string? value) + { + value ??= ""; + canonical.Append(value.Length.ToString(CultureInfo.InvariantCulture)); + canonical.Append(':'); + canonical.Append(value); + } + } + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta new file mode 100644 index 0000000..cf3cc3e --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d5f81f817c44d1ab25d76166354364d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs index 052f05e..31705be 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -366,8 +366,10 @@ namespace GameCult.Eve.Surface string message, string issuedAtUtc, long sourceVersion, - EveSurfaceNavigationTarget? navigation = null) - : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation) + EveSurfaceNavigationTarget? navigation = null, + string invocationHash = "", + long presentationSurfaceVersion = 0) + : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash, presentationSurfaceVersion) { } @@ -385,7 +387,9 @@ namespace GameCult.Eve.Surface string message, string issuedAtUtc, long sourceVersion, - EveSurfaceNavigationTarget? navigation = null) + EveSurfaceNavigationTarget? navigation = null, + string invocationHash = "", + long presentationSurfaceVersion = 0) { Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; ReceiptId = receiptId ?? ""; @@ -400,6 +404,8 @@ namespace GameCult.Eve.Surface IssuedAtUtc = issuedAtUtc ?? ""; SourceVersion = sourceVersion; Navigation = navigation; + InvocationHash = invocationHash ?? ""; + PresentationSurfaceVersion = presentationSurfaceVersion; } [Key(0)] public string Schema { get; } @@ -415,6 +421,8 @@ namespace GameCult.Eve.Surface [Key(10)] public string IssuedAtUtc { get; } [Key(11)] public long SourceVersion { get; } [Key(12)] public EveSurfaceNavigationTarget? Navigation { get; } + [Key(13)] public string InvocationHash { get; } + [Key(14)] public long PresentationSurfaceVersion { get; } } /// @@ -424,7 +432,7 @@ namespace GameCult.Eve.Surface public sealed class EveCommandReceiptCompatibilityFormatter : IMessagePackFormatter { - private const int LegacyFieldCount = 13; + private const int LegacyFieldCount = 15; private const int RequiredAndScalarFieldCount = 12; public void Serialize( @@ -434,9 +442,10 @@ namespace GameCult.Eve.Surface { if (value == null) { writer.WriteNil(); return; } - writer.WriteMapHeader(value.Navigation == null - ? RequiredAndScalarFieldCount - : RequiredAndScalarFieldCount + 1); + var optionalFields = (value.Navigation == null ? 0 : 1) + + (string.IsNullOrWhiteSpace(value.InvocationHash) ? 0 : 1) + + (value.PresentationSurfaceVersion <= 0 ? 0 : 1); + writer.WriteMapHeader(RequiredAndScalarFieldCount + optionalFields); Write(ref writer, "schema", value.Schema); Write(ref writer, "receiptId", value.ReceiptId); Write(ref writer, "commandId", value.CommandId); @@ -454,6 +463,13 @@ namespace GameCult.Eve.Surface writer.Write("navigation"); WriteNavigation(ref writer, value.Navigation); } + if (!string.IsNullOrWhiteSpace(value.InvocationHash)) + Write(ref writer, "invocationHash", value.InvocationHash); + if (value.PresentationSurfaceVersion > 0) + { + writer.Write("presentationSurfaceVersion"); + writer.Write(value.PresentationSurfaceVersion); + } } public EveCommandReceiptDocument? Deserialize( @@ -483,10 +499,13 @@ namespace GameCult.Eve.Surface var issuedAtUtc = ReadString(ref reader, fields, 10); var sourceVersion = fields > 11 ? reader.ReadInt64() : 0; var navigation = fields > 12 ? ReadNavigation(ref reader, options) : null; + var invocationHash = ReadString(ref reader, fields, 13); + var presentationSurfaceVersion = fields > 14 ? reader.ReadInt64() : 0; 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); + providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash, + presentationSurfaceVersion); } finally { reader.Depth--; } } @@ -511,6 +530,8 @@ namespace GameCult.Eve.Surface var issuedAtUtc = ""; long sourceVersion = 0; EveSurfaceNavigationTarget? navigation = null; + var invocationHash = ""; + long presentationSurfaceVersion = 0; var fields = reader.ReadMapHeader(); for (var index = 0; index < fields; index++) { @@ -529,13 +550,16 @@ namespace GameCult.Eve.Surface case "issuedAtUtc": issuedAtUtc = ReadString(ref reader); break; case "sourceVersion": sourceVersion = reader.ReadInt64(); break; case "navigation": navigation = ReadNavigation(ref reader, options); break; + case "invocationHash": invocationHash = ReadString(ref reader); break; + case "presentationSurfaceVersion": presentationSurfaceVersion = reader.ReadInt64(); break; default: reader.Skip(); break; } } return new EveCommandReceiptDocument( schema, receiptId, commandId, command, state, ownerRepo, authority, - providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation); + providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash, + presentationSurfaceVersion); } finally { reader.Depth--; } } @@ -546,8 +570,9 @@ namespace GameCult.Eve.Surface { if (navigation == null) { writer.WriteNil(); return; } - writer.WriteMapHeader(5); + writer.WriteMapHeader(6); Write(ref writer, "verseId", navigation.VerseId); + Write(ref writer, "authorityRuntimeId", navigation.AuthorityRuntimeId); Write(ref writer, "providerId", navigation.ProviderId); Write(ref writer, "surfaceId", navigation.SurfaceId); Write(ref writer, "surfaceKind", navigation.SurfaceKind); @@ -571,6 +596,7 @@ namespace GameCult.Eve.Surface try { var verseId = ""; + var authorityRuntimeId = ""; var providerId = ""; var surfaceId = ""; var surfaceKind = ""; @@ -581,6 +607,7 @@ namespace GameCult.Eve.Surface switch (reader.ReadString()) { case "verseId": verseId = ReadString(ref reader); break; + case "authorityRuntimeId": authorityRuntimeId = 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; @@ -588,7 +615,8 @@ namespace GameCult.Eve.Surface default: reader.Skip(); break; } } - return new EveSurfaceNavigationTarget(verseId, providerId, surfaceId, surfaceKind, endpoints); + return new EveSurfaceNavigationTarget( + verseId, providerId, surfaceId, surfaceKind, endpoints, authorityRuntimeId); } finally { reader.Depth--; } } @@ -626,9 +654,11 @@ namespace GameCult.Eve.Surface string providerId, string surfaceId, string surfaceKind, - string[]? rendezvousEndpoints = null) + string[]? rendezvousEndpoints = null, + string authorityRuntimeId = "") { VerseId = verseId ?? ""; + AuthorityRuntimeId = authorityRuntimeId ?? ""; ProviderId = providerId ?? ""; SurfaceId = surfaceId ?? ""; SurfaceKind = surfaceKind ?? ""; @@ -640,5 +670,6 @@ namespace GameCult.Eve.Surface [Key(2)] public string SurfaceId { get; } [Key(3)] public string SurfaceKind { get; } [Key(4)] public string[] RendezvousEndpoints { get; } + [Key(5)] public string AuthorityRuntimeId { get; } } } diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs index 4bb33f5..c9c2f39 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs @@ -335,7 +335,8 @@ namespace GameCult.Eve.Surface DateTimeOffset issuedAt, string clientId, string commandBoundary = "", - string receiptSchema = "") + string receiptSchema = "", + EveCommandDelegationRecord? delegation = null) : this( SchemaId, providerId, @@ -346,7 +347,8 @@ namespace GameCult.Eve.Surface issuedAt, clientId, commandBoundary, - receiptSchema) + receiptSchema, + delegation) { } @@ -360,7 +362,8 @@ namespace GameCult.Eve.Surface DateTimeOffset issuedAt, string clientId, string commandBoundary, - string receiptSchema) + string receiptSchema, + EveCommandDelegationRecord? delegation = null) { Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; ProviderId = providerId; @@ -373,6 +376,7 @@ namespace GameCult.Eve.Surface ClientId = clientId; CommandBoundary = commandBoundary ?? ""; ReceiptSchema = receiptSchema ?? ""; + Delegation = delegation; } [Key(0)] @@ -411,10 +415,32 @@ namespace GameCult.Eve.Surface [Key(8)] public string ReceiptSchema { get; } + [Key(9)] + public EveCommandDelegationRecord? Delegation { get; } + [IgnoreMember] public string CommandId => Operation.IdempotencyKey ?? ""; } + [MessagePackObject] + public sealed class EveCommandDelegationRecord + { + [SerializationConstructor] + public EveCommandDelegationRecord( + string originalInvocationHash, + string originalClientId, + string delegatingRuntimeId) + { + OriginalInvocationHash = originalInvocationHash ?? ""; + OriginalClientId = originalClientId ?? ""; + DelegatingRuntimeId = delegatingRuntimeId ?? ""; + } + + [Key(0)] public string OriginalInvocationHash { get; } + [Key(1)] public string OriginalClientId { get; } + [Key(2)] public string DelegatingRuntimeId { get; } + } + /// /// Reads the current Eve surface envelope and the original seven-field v1 envelope. /// diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index c4b93b8..fdbc322 100644 --- a/packages/org.gamecult.eve.surface/package.json +++ b/packages/org.gamecult.eve.surface/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.surface", "displayName": "Eve Surface Contract", - "version": "0.3.2", + "version": "0.3.6", "unity": "2021.3", "description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.", "dependencies": { diff --git a/schemas/gamecult.eve.command_invocation.v1.schema.json b/schemas/gamecult.eve.command_invocation.v1.schema.json index e3697fb..1df8dcc 100644 --- a/schemas/gamecult.eve.command_invocation.v1.schema.json +++ b/schemas/gamecult.eve.command_invocation.v1.schema.json @@ -33,7 +33,17 @@ "issuedAt": { "type": "string" }, "clientId": { "type": "string", "minLength": 1 }, "commandBoundary": { "type": "string", "minLength": 1 }, - "receiptSchema": { "type": "string", "minLength": 1 } + "receiptSchema": { "type": "string", "minLength": 1 }, + "delegation": { + "type": "object", + "required": ["originalInvocationHash", "originalClientId", "delegatingRuntimeId"], + "properties": { + "originalInvocationHash": { "type": "string", "minLength": 1 }, + "originalClientId": { "type": "string", "minLength": 1 }, + "delegatingRuntimeId": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } }, "additionalProperties": false } diff --git a/schemas/gamecult.eve.command_receipt.v1.schema.json b/schemas/gamecult.eve.command_receipt.v1.schema.json index 90ea3fb..d862a90 100644 --- a/schemas/gamecult.eve.command_receipt.v1.schema.json +++ b/schemas/gamecult.eve.command_receipt.v1.schema.json @@ -67,7 +67,18 @@ }, "sourceVersion": { "type": "integer", - "minimum": 0 + "minimum": 0, + "description": "Provider-state generation that causally owns this result. It is not implicitly an Eve surface version." + }, + "presentationSurfaceVersion": { + "type": "integer", + "minimum": 1, + "description": "Optional advertised base-surface version that a renderer must mount before exposing terminal presentation finality." + }, + "invocationHash": { + "type": "string", + "minLength": 1, + "description": "Canonical digest of the immutable invocation envelope finalized by this receipt." }, "navigation": { "type": "object", @@ -80,6 +91,10 @@ "type": "string", "minLength": 1 }, + "authorityRuntimeId": { + "type": "string", + "minLength": 1 + }, "providerId": { "type": "string" }, diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index df83356..c756359 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -68,6 +68,62 @@ public sealed class EveSurfaceSerializationCompatibilityTests private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard; + [Test] + public void CommandInvocationHashBindsEveryImmutableEnvelopeField() + { + var request = new EveSurfaceCommandRequest( + "provider", + "surface", + new CultMeshOperationInvocationDescriptor( + "launch", + "launch.v1", + new CultMeshRouteHint(CultMeshLocalityKind.Network, "commands"), + "command-1"), + CultMesh.OperationPayload(("shipId", "ship:one")), + DateTimeOffset.Parse("2026-08-19T00:00:00Z"), + "pilot:one", + "eve:commands", + EveCommandReceiptDocument.SchemaId); + var same = new EveSurfaceCommandRequest( + request.Schema, + request.ProviderId, + request.SurfaceId, + request.OperationRecord, + new Dictionary(request.PayloadFields, StringComparer.Ordinal), + request.IssuedAt, + request.ClientId, + request.CommandBoundary, + request.ReceiptSchema); + var changedPayload = new EveSurfaceCommandRequest( + request.ProviderId, + request.SurfaceId, + request.Operation, + CultMesh.OperationPayload(("shipId", "ship:two")), + request.IssuedAt, + request.ClientId, + request.CommandBoundary, + request.ReceiptSchema); + + Assert.That(EveCommandInvocationHash.Compute(same), Is.EqualTo(EveCommandInvocationHash.Compute(request))); + Assert.That(EveCommandInvocationHash.Compute(changedPayload), Is.Not.EqualTo(EveCommandInvocationHash.Compute(request))); + var delegated = new EveSurfaceCommandRequest( + request.Schema, + request.ProviderId, + request.SurfaceId, + request.OperationRecord, + new Dictionary(request.PayloadFields, StringComparer.Ordinal), + request.IssuedAt, + "progression-router", + request.CommandBoundary, + request.ReceiptSchema, + new EveCommandDelegationRecord( + EveCommandInvocationHash.Compute(request), + request.ClientId, + "progression-router")); + Assert.That(EveCommandInvocationHash.Compute(delegated), Is.Not.EqualTo(EveCommandInvocationHash.Compute(request))); + Assert.That(delegated.Delegation!.OriginalInvocationHash, Is.EqualTo(EveCommandInvocationHash.Compute(request))); + } + [Test] public void LegacySevenFieldSurfaceDocumentDeserializes() { @@ -208,7 +264,10 @@ public sealed class EveSurfaceSerializationCompatibilityTests "aetheria.daemon", "aetheria.pilot", "interactive-world", - new[] { "cultnet+tcp://odin.gamecult.example:3076" })); + new[] { "cultnet+tcp://odin.gamecult.example:3076" }, + "commander-daemon"), + "sha256:launch-envelope", + presentationSurfaceVersion: 77); var bytes = MessagePackSerializer.Serialize(document, Options); var reader = new MessagePackReader(bytes); @@ -216,13 +275,16 @@ public sealed class EveSurfaceSerializationCompatibilityTests var restored = MessagePackSerializer.Deserialize(bytes, Options); Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Map)); - Assert.That(reader.ReadMapHeader(), Is.EqualTo(13)); + Assert.That(reader.ReadMapHeader(), Is.EqualTo(15)); 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.AuthorityRuntimeId, Is.EqualTo("commander-daemon")); Assert.That(restored.Navigation.SurfaceId, Is.EqualTo("aetheria.pilot")); Assert.That(restored.Navigation.SurfaceKind, Is.EqualTo("interactive-world")); Assert.That(restored.Navigation.RendezvousEndpoints, Is.EqualTo(new[] { "cultnet+tcp://odin.gamecult.example:3076" })); + Assert.That(restored.InvocationHash, Is.EqualTo("sha256:launch-envelope")); + Assert.That(restored.PresentationSurfaceVersion, Is.EqualTo(77)); } [Test] @@ -260,6 +322,9 @@ public sealed class EveSurfaceSerializationCompatibilityTests Assert.That(restored.CommandId, Is.EqualTo("legacy-command")); Assert.That(restored.Navigation, Is.Not.Null); Assert.That(restored.Navigation!.SurfaceId, Is.EqualTo("aetheria.pilot")); + Assert.That(restored.Navigation.AuthorityRuntimeId, Is.Empty); + Assert.That(restored.InvocationHash, Is.Empty); + Assert.That(restored.PresentationSurfaceVersion, Is.Zero); }); } @@ -286,7 +351,11 @@ public sealed class EveSurfaceSerializationCompatibilityTests Assert.Multiple(() => { Assert.That(wireJson.RootElement.TryGetProperty("navigation", out _), Is.False); + Assert.That(wireJson.RootElement.TryGetProperty("invocationHash", out _), Is.False); + Assert.That(wireJson.RootElement.TryGetProperty("presentationSurfaceVersion", out _), Is.False); Assert.That(restored.Navigation, Is.Null); + Assert.That(restored.InvocationHash, Is.Empty); + Assert.That(restored.PresentationSurfaceVersion, Is.Zero); }); } 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 c60c305..5a415f5 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 @@ -16,6 +16,7 @@ +