From ad32be863cbe35df226f8e16e2a71c9540abfe0c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 23:18:07 +0200 Subject: [PATCH 01/57] Make Eve onboarding contracts portable --- README.md | 6 +++--- docs/renderer-parity.md | 2 +- packages/eve-contracts/package.json | 8 ++++---- packages/eve-contracts/scripts/generate-contracts.mjs | 6 +++++- scripts/capture-web-reference.ps1 | 2 +- scripts/start-browser-reference.ps1 | 2 +- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 476ec0a..de190cc 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ See `docs/eve-dsl-reactive-bindings.md` for the first Eve DSL and reactive CultMesh binding contract. See `docs/eve-multiverse.md` for the Eve MultiVerse philosophy and missing API surface. -See `docs/eve-face.md` for Eve's persistent mission memory. +See `docs/eve-persona.md` for Eve's persistent mission memory. ## Browser Reference @@ -347,8 +347,8 @@ without losing the basic app deployment path. ## Next Cut -- Create the browser reference Eve runtime and use it as the visual/behavior - test oracle for native clients. +- Connect the existing browser reference to a clean-consumer, networked + CultMesh sample and use it as the visual/behavior oracle for native clients. - Split the shared CultNet/CultMesh surface contract from the iOS app code and keep `gamecult.eve.surface.v1` as the renderer-facing contract. - Replace JSON/base64 sensor packets with binary framing once camera and mic diff --git a/docs/renderer-parity.md b/docs/renderer-parity.md index df7440c..ae51508 100644 --- a/docs/renderer-parity.md +++ b/docs/renderer-parity.md @@ -77,5 +77,5 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-browser-reference.ps1 Then open: ```text -http://127.0.0.1:8891/ +http://127.0.0.1:8798/ ``` diff --git a/packages/eve-contracts/package.json b/packages/eve-contracts/package.json index b57c0ca..9869312 100644 --- a/packages/eve-contracts/package.json +++ b/packages/eve-contracts/package.json @@ -20,10 +20,10 @@ "README.md" ], "scripts": { - "generate": "C:\\Progra~1\\nodejs\\node.exe scripts/generate-contracts.mjs", - "generate:check": "C:\\Progra~1\\nodejs\\node.exe scripts/generate-contracts.mjs --check", - "build": "C:\\Progra~1\\nodejs\\node.exe scripts/generate-contracts.mjs --check && C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.json", - "test": "C:\\Progra~1\\nodejs\\node.exe scripts/generate-contracts.mjs --check && C:\\Progra~1\\nodejs\\node.exe ./node_modules/typescript/bin/tsc -p tsconfig.json && C:\\Progra~1\\nodejs\\node.exe --test test/*.test.mjs" + "generate": "node scripts/generate-contracts.mjs", + "generate:check": "node scripts/generate-contracts.mjs --check", + "build": "node scripts/generate-contracts.mjs --check && node ./node_modules/typescript/bin/tsc -p tsconfig.json", + "test": "node scripts/generate-contracts.mjs --check && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test test/*.test.mjs" }, "dependencies": { "ajv": "^8.17.1" diff --git a/packages/eve-contracts/scripts/generate-contracts.mjs b/packages/eve-contracts/scripts/generate-contracts.mjs index a8be632..4ad587f 100644 --- a/packages/eve-contracts/scripts/generate-contracts.mjs +++ b/packages/eve-contracts/scripts/generate-contracts.mjs @@ -56,7 +56,7 @@ function generatedHeader() { async function checkOrWrite(path, expected) { if (process.argv.includes("--check")) { const actual = await readFile(path, "utf8").catch(() => ""); - if (actual !== expected) { + if (normalizeNewlines(actual) !== normalizeNewlines(expected)) { throw new Error(`${path} is stale. Run npm run generate in ${packageRoot}.`); } return; @@ -64,6 +64,10 @@ async function checkOrWrite(path, expected) { await writeFile(path, expected, "utf8"); } +function normalizeNewlines(value) { + return value.replace(/\r\n/g, "\n"); +} + function inlineKnownRefs(value) { if (Array.isArray(value)) return value.map(inlineKnownRefs); if (!value || typeof value !== "object") return value; diff --git a/scripts/capture-web-reference.ps1 b/scripts/capture-web-reference.ps1 index cde9fe3..bc96c1e 100644 --- a/scripts/capture-web-reference.ps1 +++ b/scripts/capture-web-reference.ps1 @@ -1,7 +1,7 @@ param( [string] $ProviderId = "eve.cultui.inspector", [string] $OutputPath = "artifacts\parity\web-cultui-inspector.png", - [int] $Port = 8891, + [int] $Port = 8798, [int] $Width = 1280, [int] $Height = 720 ) diff --git a/scripts/start-browser-reference.ps1 b/scripts/start-browser-reference.ps1 index 9f5a57e..893ce34 100644 --- a/scripts/start-browser-reference.ps1 +++ b/scripts/start-browser-reference.ps1 @@ -1,6 +1,6 @@ param( [int] $Port = 8798, - [string] $OdinRoot = "E:\Projects\Odin", + [string] $OdinRoot = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Odin"), [string] $OdinCultMeshUri = $(if ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }) ) From 322fa40713d4f9f137b3a9bd617cd5197f99e76e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 01:29:11 +0200 Subject: [PATCH 02/57] 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 @@ + + + + - From b68b833cd8c070d9b4bb3cad7c9bfff9be81137c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 02:08:56 +0200 Subject: [PATCH 03/57] Lower Eve select controls natively in browsers --- packages/eve-browser-lowering/dist/index.js | 45 +++++++++++++++- packages/eve-browser-lowering/src/index.ts | 54 ++++++++++++++++++- .../test/host-isolation.test.mjs | 42 +++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 200d438..e6f5454 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -683,7 +683,10 @@ function renderEveComponentProjection(node, options) { toggle.append(el("span", "toggle-copy", stringProp(props.label, "Toggle"))); return toggle; } - if (kind === "input.number" || kind === "input.select" || kind === "control.range" || kind === "control.input.text" || kind === "control.select") { + if (kind === "input.select" || kind === "control.select") { + return renderSelect(node, props, children, layout, style, options); + } + if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") { const field = el("label", "field cultui-field"); assignId(field, node); applyBoxProps(field, props); @@ -1538,6 +1541,46 @@ function wireCommand(element, node, commandId, props, options) { } }); } +function renderSelect(node, props, children, layout, style, options) { + const field = el("label", "field cultui-field cultui-select"); + assignId(field, node); + applyBoxProps(field, props); + applyGeneratedLayout(field, layout, style); + const label = stringProp(props.label, ""); + if (label) + field.append(el("span", "field-label", label)); + const select = el("select", "field-control cultui-field-select"); + select.setAttribute("aria-label", label || node.id || "Select"); + for (const child of children) { + if (child.kind !== "control.option" && child.kind !== "input.option" && child.kind !== "option") + continue; + const optionProps = objectProps(child.props); + const option = document.createElement("option"); + option.value = stringProp(optionProps.value, child.id || ""); + option.textContent = stringProp(optionProps.label, stringProp(optionProps.text, option.value)); + option.disabled = boolProp(optionProps.disabled) || + (optionProps.enabled !== undefined && !boolProp(optionProps.enabled)); + select.append(option); + } + select.value = stringProp(props.value, ""); + select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled)); + const commandId = resolveComponentCommandId(props, node); + if (commandId) { + select.dataset.commandId = commandId; + select.addEventListener("change", () => { + const intent = createEveCommandIntent(commandId, { + ...props, + action: { + ...objectProps(props.action), + value: select.value, + }, + }, options); + void options.commandSink?.(intent, node); + }); + } + field.append(select); + return field; +} function renderSlider(props, children, options) { const anatomy = resolveSliderAnatomy(props, children, options); const box = objectProps(anatomy.find(part => part.kind === "control.box")?.props); diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 541f72f..24ae685 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -885,7 +885,11 @@ function renderEveComponentProjection( return toggle; } - if (kind === "input.number" || kind === "input.select" || kind === "control.range" || kind === "control.input.text" || kind === "control.select") { + if (kind === "input.select" || kind === "control.select") { + return renderSelect(node, props, children, layout, style, options); + } + + if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") { const field = el("label", "field cultui-field"); assignId(field, node); applyBoxProps(field, props); @@ -1816,6 +1820,54 @@ function wireCommand( }); } +function renderSelect( + node: EveSurfaceComponent, + props: Record, + children: EveSurfaceComponent[], + layout: Record, + style: Record, + options: EveBrowserLoweringOptions, +): HTMLElement { + const field = el("label", "field cultui-field cultui-select"); + assignId(field, node); + applyBoxProps(field, props); + applyGeneratedLayout(field, layout, style); + const label = stringProp(props.label, ""); + if (label) field.append(el("span", "field-label", label)); + + const select = el("select", "field-control cultui-field-select") as HTMLSelectElement; + select.setAttribute("aria-label", label || node.id || "Select"); + for (const child of children) { + if (child.kind !== "control.option" && child.kind !== "input.option" && child.kind !== "option") continue; + const optionProps = objectProps(child.props); + const option = document.createElement("option"); + option.value = stringProp(optionProps.value, child.id || ""); + option.textContent = stringProp(optionProps.label, stringProp(optionProps.text, option.value)); + option.disabled = boolProp(optionProps.disabled) || + (optionProps.enabled !== undefined && !boolProp(optionProps.enabled)); + select.append(option); + } + select.value = stringProp(props.value, ""); + select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled)); + + const commandId = resolveComponentCommandId(props, node); + if (commandId) { + select.dataset.commandId = commandId; + select.addEventListener("change", () => { + const intent = createEveCommandIntent(commandId, { + ...props, + action: { + ...objectProps(props.action), + value: select.value, + }, + }, options); + void options.commandSink?.(intent, node); + }); + } + field.append(select); + return field; +} + function renderSlider( props: Record, children: EveSurfaceComponent[], diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 601fd32..55ed2ca 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -15,6 +15,8 @@ function installDom() { "HTMLElement", "HTMLButtonElement", "HTMLInputElement", + "HTMLSelectElement", + "HTMLOptionElement", "HTMLTextAreaElement", "HTMLImageElement", ]; @@ -152,3 +154,43 @@ test("browser lowering isolates two hosts and patches only the bound component", restoreDom(); } }); + +test("browser lowering renders command-backed select options", () => { + const restoreDom = installDom(); + try { + const commands = []; + const host = document.querySelector("#a"); + renderEveSurface({ + providerId: "hangar-provider", + surface: { + id: "hangar", + root: { + id: "hangar.verse", + kind: "control.select", + props: { label: "VERSE", value: "local", command: "hangar.select_verse" }, + children: [ + { kind: "control.option", props: { label: "Local", value: "local" } }, + { kind: "control.option", props: { label: "GameCult", value: "gamecult" } }, + ], + }, + }, + }, host, { + clientId: "browser-test", + commandSink: intent => commands.push(intent), + }); + + const select = host.querySelector("select"); + assert.deepEqual([...select.options].map(option => [option.text, option.value]), [ + ["Local", "local"], + ["GameCult", "gamecult"], + ]); + assert.equal(select.value, "local"); + select.value = "gamecult"; + select.dispatchEvent(new window.Event("change", { bubbles: true })); + assert.equal(commands.length, 1); + assert.equal(commands[0].command, "hangar.select_verse"); + assert.equal(commands[0].payload.value, "gamecult"); + } finally { + restoreDom(); + } +}); From 4b8f8a85c07c3ac3c92f931e0431efb7d5caf3ec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 02:44:10 +0200 Subject: [PATCH 04/57] Test portable Eve packages on Windows and Linux --- .github/workflows/portable-contracts.yml | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/portable-contracts.yml diff --git a/.github/workflows/portable-contracts.yml b/.github/workflows/portable-contracts.yml new file mode 100644 index 0000000..db8849b --- /dev/null +++ b/.github/workflows/portable-contracts.yml @@ -0,0 +1,43 @@ +name: Portable Eve contracts and browser lowering + +on: + pull_request: + workflow_dispatch: + +jobs: + contracts-and-browser: + name: ${{ matrix.os }} ${{ matrix.package }} + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + package: [eve-contracts, eve-browser-lowering] + + steps: + - name: Check out Eve + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: packages/${{ matrix.package }}/package-lock.json + + - name: Install package dependencies + working-directory: packages/${{ matrix.package }} + run: npm ci + + - name: Build and test source + working-directory: packages/${{ matrix.package }} + run: npm test + + - name: Reject stale generated output + shell: pwsh + run: | + if (git status --short) { + git status --short + throw "The package build changed checked-in generated output." + } From e593dc7d916989b44d08c30f76ed09f6e9ceb6c9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 02:49:59 +0200 Subject: [PATCH 05/57] Run Eve portability gates on branch pushes --- .github/workflows/portable-contracts.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/portable-contracts.yml b/.github/workflows/portable-contracts.yml index db8849b..08f8689 100644 --- a/.github/workflows/portable-contracts.yml +++ b/.github/workflows/portable-contracts.yml @@ -1,6 +1,10 @@ name: Portable Eve contracts and browser lowering on: + push: + branches: + - main + - "codex/**" pull_request: workflow_dispatch: From 36e9d36c78d6fa6b681dca33eb896967489f21e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 02:54:10 +0200 Subject: [PATCH 06/57] Emit portable Eve package output with LF endings --- packages/eve-browser-lowering/tsconfig.json | 1 + packages/eve-contracts/tsconfig.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/eve-browser-lowering/tsconfig.json b/packages/eve-browser-lowering/tsconfig.json index 2c115b7..f8a945b 100644 --- a/packages/eve-browser-lowering/tsconfig.json +++ b/packages/eve-browser-lowering/tsconfig.json @@ -3,6 +3,7 @@ "declaration": true, "module": "ES2022", "moduleResolution": "Bundler", + "newLine": "lf", "noEmitOnError": true, "outDir": "dist", "rootDir": "src", diff --git a/packages/eve-contracts/tsconfig.json b/packages/eve-contracts/tsconfig.json index 4c38cb5..a2cc16c 100644 --- a/packages/eve-contracts/tsconfig.json +++ b/packages/eve-contracts/tsconfig.json @@ -3,6 +3,7 @@ "declaration": true, "module": "ES2022", "moduleResolution": "Bundler", + "newLine": "lf", "noEmitOnError": true, "outDir": "dist", "rootDir": "src", From cfef87525a2944932c5ba9ffdb8f34e50d66a28b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 02:55:14 +0200 Subject: [PATCH 07/57] Keep generated Eve package artifacts byte-stable --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..47a5b0a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +packages/*/dist/** text eol=lf From 3aca3b7a1b1e0968d2d047c4af0975ecf88e3955 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 02:56:58 +0200 Subject: [PATCH 08/57] Ignore line-ending-only generated package drift --- .github/workflows/portable-contracts.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/portable-contracts.yml b/.github/workflows/portable-contracts.yml index 08f8689..616651d 100644 --- a/.github/workflows/portable-contracts.yml +++ b/.github/workflows/portable-contracts.yml @@ -41,7 +41,8 @@ jobs: - name: Reject stale generated output shell: pwsh run: | - if (git status --short) { - git status --short + $changed = git diff --ignore-space-at-eol --name-only -- packages/${{ matrix.package }} + if ($changed) { + git diff --ignore-space-at-eol -- packages/${{ matrix.package }} throw "The package build changed checked-in generated output." } From 2c4a9adb05fdce316e922f08b6e2070a691d62fa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 05:22:45 +0200 Subject: [PATCH 09/57] Add portable inventory placement previews --- packages/eve-browser-lowering/dist/index.d.ts | 10 + packages/eve-browser-lowering/dist/index.js | 108 +++++++++- .../eve-browser-lowering/package-lock.json | 4 +- packages/eve-browser-lowering/package.json | 2 +- packages/eve-browser-lowering/src/index.ts | 130 +++++++++++- packages/eve-browser-lowering/styles.css | 16 ++ .../test/browser-lowering.test.mjs | 29 ++- packages/org.gamecult.eve.surface/README.md | 6 +- .../Runtime/EveInventoryInteraction.cs | 187 +++++++++++++++--- .../org.gamecult.eve.surface/package.json | 2 +- 10 files changed, 458 insertions(+), 36 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index 95b85a3..cbb8b83 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -171,6 +171,16 @@ export declare function projectSemanticListItem(node: EveSurfaceComponent): EveS export declare function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[]; export declare function createWorldActionIntent(command: string, action: Record, options?: EveBrowserLoweringOptions): EveCommandIntent; export declare function createInventoryDropIntent(source: Record, target: Record, destinationX: number, destinationY: number, options?: EveBrowserLoweringOptions): EveCommandIntent | undefined; +export interface InventoryPlacementCell { + x: number; + y: number; +} +export interface InventoryPlacementPreview { + valid: boolean; + reason: "valid" | "outside-grid" | "outside-valid-shape" | "occupied"; + cells: InventoryPlacementCell[]; +} +export declare function createInventoryPlacementPreview(source: Record, target: Record, targetChildren: EveSurfaceComponent[], destinationX: number, destinationY: number): InventoryPlacementPreview; export declare function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body?: HTMLElement): void; export declare function createEveCommandIntent(commandId: string, props?: Record, options?: EveBrowserLoweringOptions): EveCommandIntent; export declare function emptyState(message: string): HTMLElement; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index e6f5454..cdd4e5c 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -1042,8 +1042,30 @@ function renderInventoryGrid(node, props, layout, style, options) { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || ""; + if (!encoded) + return; + try { + const source = JSON.parse(encoded); + const rect = board.getBoundingClientRect(); + const cellSize = Math.max(1, numberProp(props.cellSize, 72)); + const cellGap = Math.max(0, numberProp(props.cellGap, 4)); + const pitch = cellSize + cellGap; + const x = Math.max(0, Math.floor((event.clientX - rect.left) / pitch)); + const y = Math.max(0, Math.floor((event.clientY - rect.top) / pitch)); + showInventoryPlacementPreview(board, source, props, node.children || [], x, y, cellSize); + } + catch { + clearInventoryPlacementPreview(board); + } + }); + board.addEventListener("dragleave", event => { + if (event.relatedTarget instanceof Node && board.contains(event.relatedTarget)) + return; + clearInventoryPlacementPreview(board); }); board.addEventListener("drop", event => { + clearInventoryPlacementPreview(board); const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || ""; if (!encoded) return; @@ -1060,6 +1082,8 @@ function renderInventoryGrid(node, props, layout, style, options) { const pitch = cellSize + cellGap; const x = Math.max(0, Math.min(columns - 1, Math.floor((event.clientX - rect.left) / pitch))); const y = Math.max(0, Math.min(rows - 1, Math.floor((event.clientY - rect.top) / pitch))); + if (!createInventoryPlacementPreview(source, props, node.children || [], x, y).valid) + return; const intent = createInventoryDropIntent(source, props, x, y, options); if (!intent) return; @@ -1108,7 +1132,7 @@ function renderInventoryItem(node, props, layout, style, options) { if (!item.draggable || !event.dataTransfer) return; event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("application/vnd.gamecult.eve.inventory-item+json", JSON.stringify(props)); + event.dataTransfer.setData("application/vnd.gamecult.eve.inventory-item+json", JSON.stringify({ ...props, id: node.id })); }); let widthCells = positiveInt(props.shapeWidth, 1); let heightCells = positiveInt(props.shapeHeight, 1); @@ -1153,7 +1177,10 @@ export function createInventoryDropIntent(source, target, destinationX, destinat const sourceIndex = numberProp(source.sourceIndex, -1); const targetIndex = numberProp(target.targetIndex, -1); const targetKind = firstString(target.targetKind, ""); - const payload = { + const payload = {}; + copyInventoryPayloadProps(source, payload); + copyInventoryPayloadProps(target, payload); + Object.assign(payload, { sourceKind, originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""), originIndex: sourceIndex, @@ -1162,16 +1189,91 @@ export function createInventoryDropIntent(source, target, destinationX, destinat quantity: Math.max(1, numberProp(source.quantity, 1)), sourceX: numberProp(source.x, -2147483648), sourceY: numberProp(source.y, -2147483648), + sourceRotation: firstString(source.rotation, "None"), destinationKind: targetKind, destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""), destinationIndex: targetIndex, destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1, destinationX: Math.trunc(destinationX), destinationY: Math.trunc(destinationY), + destinationRotation: firstString(target.dropRotation, source.rotation, "None"), hasDestinationPosition: true, - }; + }); return createEveCommandIntent(command, { action: payload }, options); } +export function createInventoryPlacementPreview(source, target, targetChildren, destinationX, destinationY) { + const columns = Math.max(1, positiveInt(target.columns, 1)); + const rows = Math.max(1, positiveInt(target.rows, 1)); + const cells = inventoryItemCells(source).map(cell => ({ + x: Math.trunc(destinationX) + cell.x, + y: Math.trunc(destinationY) + cell.y, + })); + const validCells = new Set(parseInventoryCells(target.validCells).map(cell => `${cell.x}:${cell.y}`)); + const sourceId = firstString(source.id, ""); + const occupied = new Set(); + for (const child of targetChildren || []) { + if (child.kind !== "inventory.item" || (sourceId && child.id === sourceId)) + continue; + const props = objectProps(child.props); + const x = Math.trunc(numberProp(props.x, 0)); + const y = Math.trunc(numberProp(props.y, 0)); + for (const cell of inventoryItemCells(props)) + occupied.add(`${x + cell.x}:${y + cell.y}`); + } + let reason = "valid"; + if (cells.some(cell => cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows)) { + reason = "outside-grid"; + } + else if (validCells.size > 0 && cells.some(cell => !validCells.has(`${cell.x}:${cell.y}`))) { + reason = "outside-valid-shape"; + } + else if (cells.some(cell => occupied.has(`${cell.x}:${cell.y}`))) { + reason = "occupied"; + } + return { valid: reason === "valid", reason, cells }; +} +function inventoryItemCells(item) { + const explicit = parseInventoryCells(item.shapeCells); + if (explicit.length > 0) + return explicit; + let width = Math.max(1, positiveInt(item.shapeWidth, 1)); + let height = Math.max(1, positiveInt(item.shapeHeight, 1)); + const rotation = firstString(item.rotation, "").toLowerCase(); + if (["clockwise", "counterclockwise", "right", "left", "rotate90", "rotate270", "clockwise90", "clockwise270"].includes(rotation)) { + [width, height] = [height, width]; + } + return Array.from({ length: width * height }, (_, index) => ({ x: index % width, y: Math.floor(index / width) })); +} +function parseInventoryCells(value) { + if (typeof value !== "string") + return []; + return value.split(";").flatMap(token => { + const [x, y] = token.split(",").map(Number); + return Number.isInteger(x) && Number.isInteger(y) ? [{ x, y }] : []; + }); +} +function copyInventoryPayloadProps(source, payload) { + for (const [key, value] of Object.entries(source)) { + if (key.startsWith("payload.") && key.length > "payload.".length) + payload[key.slice("payload.".length)] = value; + } +} +function clearInventoryPlacementPreview(board) { + board.querySelectorAll(".cultui-inventory-placement-preview").forEach(element => element.remove()); +} +function showInventoryPlacementPreview(board, source, target, targetChildren, x, y, cellSize) { + clearInventoryPlacementPreview(board); + const preview = createInventoryPlacementPreview(source, target, targetChildren, x, y); + for (const cell of preview.cells) { + const ghost = el("div", `cultui-inventory-placement-preview ${preview.valid ? "is-valid" : "is-invalid"}`); + ghost.style.gridColumn = String(cell.x + 1); + ghost.style.gridRow = String(cell.y + 1); + ghost.style.width = cssSize(String(cellSize)); + ghost.style.height = cssSize(String(cellSize)); + ghost.style.pointerEvents = "none"; + board.append(ghost); + } +} function renderInputBindingMap(node, props, options) { const root = el("section", "cultui-binding-map"); assignId(root, node); diff --git a/packages/eve-browser-lowering/package-lock.json b/packages/eve-browser-lowering/package-lock.json index 10c1e35..adbc042 100644 --- a/packages/eve-browser-lowering/package-lock.json +++ b/packages/eve-browser-lowering/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gamecult/eve-browser-lowering", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gamecult/eve-browser-lowering", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { "@types/jsdom": "^30.0.0", "jsdom": "^30.0.1", diff --git a/packages/eve-browser-lowering/package.json b/packages/eve-browser-lowering/package.json index c0eccf5..f9103ce 100644 --- a/packages/eve-browser-lowering/package.json +++ b/packages/eve-browser-lowering/package.json @@ -1,6 +1,6 @@ { "name": "@gamecult/eve-browser-lowering", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 24ae685..7234da2 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1254,8 +1254,27 @@ function renderInventoryGrid( if (!hasInventoryDropCommand(props)) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || ""; + if (!encoded) return; + try { + const source = JSON.parse(encoded) as Record; + const rect = board.getBoundingClientRect(); + const cellSize = Math.max(1, numberProp(props.cellSize, 72)); + const cellGap = Math.max(0, numberProp(props.cellGap, 4)); + const pitch = cellSize + cellGap; + const x = Math.max(0, Math.floor((event.clientX - rect.left) / pitch)); + const y = Math.max(0, Math.floor((event.clientY - rect.top) / pitch)); + showInventoryPlacementPreview(board, source, props, node.children || [], x, y, cellSize); + } catch { + clearInventoryPlacementPreview(board); + } + }); + board.addEventListener("dragleave", event => { + if (event.relatedTarget instanceof Node && board.contains(event.relatedTarget)) return; + clearInventoryPlacementPreview(board); }); board.addEventListener("drop", event => { + clearInventoryPlacementPreview(board); const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || ""; if (!encoded) return; let source: Record; @@ -1270,6 +1289,7 @@ function renderInventoryGrid( const pitch = cellSize + cellGap; const x = Math.max(0, Math.min(columns - 1, Math.floor((event.clientX - rect.left) / pitch))); const y = Math.max(0, Math.min(rows - 1, Math.floor((event.clientY - rect.top) / pitch))); + if (!createInventoryPlacementPreview(source, props, node.children || [], x, y).valid) return; const intent = createInventoryDropIntent(source, props, x, y, options); if (!intent) return; event.preventDefault(); @@ -1324,7 +1344,7 @@ function renderInventoryItem( event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData( "application/vnd.gamecult.eve.inventory-item+json", - JSON.stringify(props), + JSON.stringify({ ...props, id: node.id }), ); }); let widthCells = positiveInt(props.shapeWidth, 1); @@ -1379,7 +1399,10 @@ export function createInventoryDropIntent( const sourceIndex = numberProp(source.sourceIndex, -1); const targetIndex = numberProp(target.targetIndex, -1); const targetKind = firstString(target.targetKind, ""); - const payload: Record = { + const payload: Record = {}; + copyInventoryPayloadProps(source, payload); + copyInventoryPayloadProps(target, payload); + Object.assign(payload, { sourceKind, originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""), originIndex: sourceIndex, @@ -1388,17 +1411,118 @@ export function createInventoryDropIntent( quantity: Math.max(1, numberProp(source.quantity, 1)), sourceX: numberProp(source.x, -2147483648), sourceY: numberProp(source.y, -2147483648), + sourceRotation: firstString(source.rotation, "None"), destinationKind: targetKind, destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""), destinationIndex: targetIndex, destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1, destinationX: Math.trunc(destinationX), destinationY: Math.trunc(destinationY), + destinationRotation: firstString(target.dropRotation, source.rotation, "None"), hasDestinationPosition: true, - }; + }); return createEveCommandIntent(command, { action: payload }, options); } +export interface InventoryPlacementCell { + x: number; + y: number; +} + +export interface InventoryPlacementPreview { + valid: boolean; + reason: "valid" | "outside-grid" | "outside-valid-shape" | "occupied"; + cells: InventoryPlacementCell[]; +} + +export function createInventoryPlacementPreview( + source: Record, + target: Record, + targetChildren: EveSurfaceComponent[], + destinationX: number, + destinationY: number, +): InventoryPlacementPreview { + const columns = Math.max(1, positiveInt(target.columns, 1)); + const rows = Math.max(1, positiveInt(target.rows, 1)); + const cells = inventoryItemCells(source).map(cell => ({ + x: Math.trunc(destinationX) + cell.x, + y: Math.trunc(destinationY) + cell.y, + })); + const validCells = new Set(parseInventoryCells(target.validCells).map(cell => `${cell.x}:${cell.y}`)); + const sourceId = firstString(source.id, ""); + const occupied = new Set(); + for (const child of targetChildren || []) { + if (child.kind !== "inventory.item" || (sourceId && child.id === sourceId)) continue; + const props = objectProps(child.props); + const x = Math.trunc(numberProp(props.x, 0)); + const y = Math.trunc(numberProp(props.y, 0)); + for (const cell of inventoryItemCells(props)) occupied.add(`${x + cell.x}:${y + cell.y}`); + } + + let reason: InventoryPlacementPreview["reason"] = "valid"; + if (cells.some(cell => cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows)) { + reason = "outside-grid"; + } else if (validCells.size > 0 && cells.some(cell => !validCells.has(`${cell.x}:${cell.y}`))) { + reason = "outside-valid-shape"; + } else if (cells.some(cell => occupied.has(`${cell.x}:${cell.y}`))) { + reason = "occupied"; + } + return { valid: reason === "valid", reason, cells }; +} + +function inventoryItemCells(item: Record): InventoryPlacementCell[] { + const explicit = parseInventoryCells(item.shapeCells); + if (explicit.length > 0) return explicit; + let width = Math.max(1, positiveInt(item.shapeWidth, 1)); + let height = Math.max(1, positiveInt(item.shapeHeight, 1)); + const rotation = firstString(item.rotation, "").toLowerCase(); + if (["clockwise", "counterclockwise", "right", "left", "rotate90", "rotate270", "clockwise90", "clockwise270"].includes(rotation)) { + [width, height] = [height, width]; + } + return Array.from({ length: width * height }, (_, index) => ({ x: index % width, y: Math.floor(index / width) })); +} + +function parseInventoryCells(value: unknown): InventoryPlacementCell[] { + if (typeof value !== "string") return []; + return value.split(";").flatMap(token => { + const [x, y] = token.split(",").map(Number); + return Number.isInteger(x) && Number.isInteger(y) ? [{ x, y }] : []; + }); +} + +function copyInventoryPayloadProps(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; + } +} + +function clearInventoryPlacementPreview(board: HTMLElement): void { + board.querySelectorAll(".cultui-inventory-placement-preview").forEach(element => element.remove()); +} + +function showInventoryPlacementPreview( + board: HTMLElement, + source: Record, + target: Record, + targetChildren: EveSurfaceComponent[], + x: number, + y: number, + cellSize: number, +): void { + clearInventoryPlacementPreview(board); + const preview = createInventoryPlacementPreview(source, target, targetChildren, x, y); + for (const cell of preview.cells) { + const ghost = el("div", `cultui-inventory-placement-preview ${preview.valid ? "is-valid" : "is-invalid"}`); + ghost.style.gridColumn = String(cell.x + 1); + ghost.style.gridRow = String(cell.y + 1); + ghost.style.width = cssSize(String(cellSize)); + ghost.style.height = cssSize(String(cellSize)); + ghost.style.pointerEvents = "none"; + board.append(ghost); + } +} + function renderInputBindingMap( node: EveSurfaceComponent, props: Record, diff --git a/packages/eve-browser-lowering/styles.css b/packages/eve-browser-lowering/styles.css index 038aef2..789b7fa 100644 --- a/packages/eve-browser-lowering/styles.css +++ b/packages/eve-browser-lowering/styles.css @@ -1086,6 +1086,22 @@ body[data-pixel-art="true"] .cultui-background { padding: 14px; } +.cultui-inventory-placement-preview { + z-index: 20; + border: 1px solid currentColor; + box-sizing: border-box; +} + +.cultui-inventory-placement-preview.is-valid { + color: #45e6a8; + background: rgb(69 230 168 / 34%); +} + +.cultui-inventory-placement-preview.is-invalid { + color: #ff5148; + background: rgb(255 81 72 / 38%); +} + @media (max-width: 900px) { .cultui-graph { right: 12px; diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index 608ab91..b4cbb2d 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { applyEveStateBindingValue, createEveCommandIntent, createInventoryDropIntent, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectSemanticListItems, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js"; +import { applyEveStateBindingValue, createEveCommandIntent, createInventoryDropIntent, createInventoryPlacementPreview, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectSemanticListItems, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js"; test("applies provider state bindings to component props instead of receipt diagnostics", () => { const component = { props: { value: 1.2 } }; @@ -70,6 +70,8 @@ test("inventory drops select the provider-advertised operation and preserve spat targetEntityKey: "zone.0.entity.1", targetIndex: -1, "dropCommand.cargo": "aetheria.daemon.commands.EquipItem", + "payload.shipId": "hangar.ship.1", + "payload.expectedHangarRevision": "42", }, 7, 9, { activeSurfaceId: "aetheria.refit", clientId: "browser.inventory-test", @@ -83,6 +85,31 @@ test("inventory drops select the provider-advertised operation and preserve spat assert.equal(intent.payload.destinationX, 7); assert.equal(intent.payload.destinationY, 9); assert.equal(intent.payload.hasDestinationPosition, true); + assert.equal(intent.payload.shipId, "hangar.ship.1"); + assert.equal(intent.payload.expectedHangarRevision, "42"); +}); + +test("inventory placement previews preserve irregular cells and reject occupancy", () => { + const source = { id: "moving", shapeCells: "0,0;1,0;0,1" }; + const target = { + columns: 4, + rows: 3, + validCells: "0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1;0,2;1,2;2,2;3,2", + }; + const children = [{ + id: "installed", + kind: "inventory.item", + props: { x: 2, y: 1, shapeCells: "0,0" }, + children: [], + }]; + + assert.deepEqual(createInventoryPlacementPreview(source, target, children, 0, 0), { + valid: true, + reason: "valid", + cells: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }], + }); + assert.equal(createInventoryPlacementPreview(source, target, children, 1, 1).reason, "occupied"); + assert.equal(createInventoryPlacementPreview(source, target, children, 3, 2).reason, "outside-grid"); }); test("inventory drops fail closed without an operation for the source kind", () => { diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md index 5138bbd..bd31b4d 100644 --- a/packages/org.gamecult.eve.surface/README.md +++ b/packages/org.gamecult.eve.surface/README.md @@ -1,5 +1,10 @@ # Eve Surface Contract +Inventory surfaces may publish irregular `shapeCells`, optional grid +`validCells`, and `payload.*` fields. Lowerers use those fields for transient +placement previews and forward the namespaced payload through typed drop +operations; providers remain the final placement authority. + Eve owns this renderer-neutral package. It defines the retained CultUI surface document, provider advertisement, asset catalog, input vocabulary, and builder primitives shared by providers and lowerers. Headless daemons depend on this @@ -33,4 +38,3 @@ there. EveUnity must not carry another live copy of this package. Its Unity Scene and UI Toolkit packages depend on the released Eve-owned package. - diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs index 21490a9..4ea1820 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs @@ -14,6 +14,57 @@ namespace GameCult.Eve.Surface public const string ItemKind = "inventory.item"; public const string DragSessionKind = "inventory.drag_session"; + public static bool TryCreatePlacementPreview( + EveSurfaceComponent source, + EveSurfaceComponent target, + int destinationX, + int destinationY, + out EveInventoryPlacementPreview? preview) + { + preview = null; + if (source == null || target == null || + !string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) || + !string.Equals(target.Kind, GridKind, StringComparison.Ordinal)) + { + return false; + } + + var columns = Math.Max(1, Int(target.GetProp("columns"), 1)); + var rows = Math.Max(1, Int(target.GetProp("rows"), 1)); + var localCells = ItemCells(source); + var cells = localCells + .Select(cell => new EveInventoryCell(destinationX + cell.X, destinationY + cell.Y)) + .ToArray(); + var validCells = ParseCells(target.GetProp("validCells")); + var occupied = new HashSet<(int X, int Y)>(); + foreach (var item in target.Children.Where(child => + string.Equals(child.Kind, ItemKind, StringComparison.Ordinal) && + !ReferenceEquals(child, source) && + !string.Equals(child.Id, source.Id, StringComparison.Ordinal))) + { + var originX = Int(item.GetProp("x"), 0); + var originY = Int(item.GetProp("y"), 0); + foreach (var cell in ItemCells(item)) + occupied.Add((originX + cell.X, originY + cell.Y)); + } + + var reason = "valid"; + if (cells.Any(cell => cell.X < 0 || cell.Y < 0 || cell.X >= columns || cell.Y >= rows)) + reason = "outside-grid"; + else if (validCells.Count > 0 && cells.Any(cell => !validCells.Contains((cell.X, cell.Y)))) + reason = "outside-valid-shape"; + else if (cells.Any(cell => occupied.Contains((cell.X, cell.Y)))) + reason = "occupied"; + + preview = new EveInventoryPlacementPreview( + string.Equals(reason, "valid", StringComparison.Ordinal), + reason, + destinationX, + destinationY, + cells); + return true; + } + public static bool TryCreateDropRequest( EveSurfaceDocument document, EveSurfaceComponent source, @@ -39,31 +90,34 @@ namespace GameCult.Eve.Surface var sourceIndex = Int(source.GetProp("sourceIndex"), -1); var targetIndex = Int(target.GetProp("targetIndex"), -1); var targetKind = target.GetProp("targetKind"); - var payload = new Dictionary(StringComparer.Ordinal) - { - ["sourceKind"] = sourceKind, - ["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")), - ["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture), - ["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1) - .ToString(CultureInfo.InvariantCulture), - ["itemKey"] = source.GetProp("itemKey"), - ["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture), - ["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture), - ["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture), - ["destinationKind"] = targetKind, - ["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")), - ["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture), - ["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1) - .ToString(CultureInfo.InvariantCulture), - ["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture), - ["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture), - ["hasDestinationPosition"] = "true" - }; + var payload = new Dictionary(StringComparer.Ordinal); + CopyPayloadProps(source, payload); + CopyPayloadProps(target, payload); + payload["sourceKind"] = sourceKind; + payload["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")); + payload["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture); + payload["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1) + .ToString(CultureInfo.InvariantCulture); + payload["itemKey"] = source.GetProp("itemKey"); + payload["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture); + payload["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture); + payload["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture); + payload["sourceRotation"] = source.GetProp("rotation", "None"); + payload["destinationKind"] = targetKind; + payload["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")); + payload["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture); + payload["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1) + .ToString(CultureInfo.InvariantCulture); + payload["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture); + payload["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture); + payload["destinationRotation"] = target.GetProp("dropRotation", source.GetProp("rotation", "None")); + payload["hasDestinationPosition"] = "true"; var template = document.Commands.FirstOrDefault(candidate => string.Equals(candidate.Command, command, StringComparison.Ordinal)); + var idempotencyKey = "eve-inventory-drop-" + Guid.NewGuid().ToString("N"); var operation = template == null - ? CultMesh.OperationInvocation(command) - : CultMesh.OperationInvocation(template.Operation); + ? CultMesh.OperationInvocation(command, idempotencyKey: idempotencyKey) + : CultMesh.OperationInvocation(template.Operation, idempotencyKey); request = new EveSurfaceCommandRequest( document.ProviderId, document.Surface.Id, @@ -74,11 +128,96 @@ namespace GameCult.Eve.Surface return true; } + private static void CopyPayloadProps( + EveSurfaceComponent component, + IDictionary payload) + { + const string prefix = "payload."; + foreach (var prop in component.Props) + { + if (!prop.Key.StartsWith(prefix, StringComparison.Ordinal) || + prop.Key.Length == prefix.Length) + { + continue; + } + + payload[prop.Key.Substring(prefix.Length)] = prop.Value ?? ""; + } + } + private static int Int(string value, int fallback) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; + + private static IReadOnlyList ItemCells(EveSurfaceComponent item) + { + var explicitCells = ParseCells(item.GetProp("shapeCells")); + if (explicitCells.Count > 0) + return explicitCells.Select(cell => new EveInventoryCell(cell.X, cell.Y)).ToArray(); + + var width = Math.Max(1, Int(item.GetProp("shapeWidth"), 1)); + var height = Math.Max(1, Int(item.GetProp("shapeHeight"), 1)); + var rotation = item.GetProp("rotation"); + if (string.Equals(rotation, "Clockwise90", StringComparison.OrdinalIgnoreCase) || + string.Equals(rotation, "Clockwise270", StringComparison.OrdinalIgnoreCase)) + { + (width, height) = (height, width); + } + return Enumerable.Range(0, height) + .SelectMany(y => Enumerable.Range(0, width).Select(x => new EveInventoryCell(x, y))) + .ToArray(); + } + + private static HashSet<(int X, int Y)> ParseCells(string value) + { + var cells = new HashSet<(int X, int Y)>(); + foreach (var token in (value ?? "").Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var coordinates = token.Split(','); + if (coordinates.Length == 2 && + int.TryParse(coordinates[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var x) && + int.TryParse(coordinates[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var y)) + { + cells.Add((x, y)); + } + } + return cells; + } + } + + public sealed class EveInventoryPlacementPreview + { + public EveInventoryPlacementPreview( + bool isValid, + string reason, + int destinationX, + int destinationY, + IReadOnlyList cells) + { + IsValid = isValid; + Reason = reason ?? ""; + DestinationX = destinationX; + DestinationY = destinationY; + Cells = cells ?? Array.Empty(); + } + + public bool IsValid { get; } + public string Reason { get; } + public int DestinationX { get; } + public int DestinationY { get; } + public IReadOnlyList Cells { get; } + } + + public readonly struct EveInventoryCell + { + public EveInventoryCell(int x, int y) + { + X = x; + Y = y; + } + + public int X { get; } + public int Y { get; } } } - - diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index 110f784..c4b93b8 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.1", + "version": "0.3.2", "unity": "2021.3", "description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.", "dependencies": { From cbe9d9f25eb7abd4c82f6f1a7293672eb8e2994e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 06:21:44 +0200 Subject: [PATCH 10/57] Make Eve command receipts portable --- .../Runtime/EveProviderDocuments.cs | 201 ++++++++++++++++++ ...eSurfaceSerializationCompatibilityTests.cs | 70 +++++- 2 files changed, 270 insertions(+), 1 deletion(-) 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", From e7b09f755de2fa54e2c5936331e3a03839a13cd1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 06:33:29 +0200 Subject: [PATCH 11/57] Package the portable Eve surface for .NET --- .../GameCult.Eve.Surface.csproj | 13 ++- packages/org.gamecult.eve.surface/README.md | 12 ++- scripts/pack-dotnet-surface.ps1 | 99 +++++++++++++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 scripts/pack-dotnet-surface.ps1 diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj index 442ae1a..72b5b3f 100644 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj @@ -5,6 +5,14 @@ latest enable Portable Eve surface and provider contracts. + GameCult.Eve.Surface + 0.3.2 + GameCult + MPL-2.0 + https://github.com/GameCult/Eve + https://github.com/GameCult/Eve + README.md + eve;cultui;cultmesh;multiplayer;ui $(MSBuildThisFileDirectory)..\..\..\CultLib @@ -12,6 +20,9 @@ + + + + - diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md index bd31b4d..b0e6dd5 100644 --- a/packages/org.gamecult.eve.surface/README.md +++ b/packages/org.gamecult.eve.surface/README.md @@ -16,15 +16,19 @@ bindings through `StateBindings`, and nested synced document regions through schema, presentation kind, and route hint; renderers resolve that child through CultMesh instead of rebuilding it with runtime-local projection code. -See `../../docs/surface-contract-v1.md` for the full contract and -`../../web/fixtures/cultui-embedded-surface.json` for the parity fixture. -Runtime discovery and required feature coverage live in -`../../tools/parity/parity-manifest.json`; active GUI runtimes must list +See the +[surface contract](https://github.com/GameCult/Eve/blob/main/docs/surface-contract-v1.md) +for the full contract and the +[embedded-surface fixture](https://github.com/GameCult/Eve/blob/main/web/fixtures/cultui-embedded-surface.json) +for parity evidence. Runtime discovery and required feature coverage live in +the [parity manifest](https://github.com/GameCult/Eve/blob/main/tools/parity/parity-manifest.json); +active GUI runtimes must list `embeddedDocuments` as a supported feature and require the `embedded-surface` fixture. Verification entrypoints: +- clean .NET package consumer: `powershell -ExecutionPolicy Bypass -File .\scripts\pack-dotnet-surface.ps1` - C# serialization: `dotnet test tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj` - Browser/TypeScript: `node --test web\eve-dsl.test.mjs` - Semantic runtime matrix: `powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1` diff --git a/scripts/pack-dotnet-surface.ps1 b/scripts/pack-dotnet-surface.ps1 new file mode 100644 index 0000000..963c843 --- /dev/null +++ b/scripts/pack-dotnet-surface.ps1 @@ -0,0 +1,99 @@ +param( + [string] $CultLibRoot = "", + [string] $Configuration = "Release", + [string] $OutputDirectory = "artifacts\nuget", + [switch] $SkipConsumerSmoke +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($CultLibRoot)) { + $CultLibRoot = Join-Path (Split-Path -Parent $repoRoot) "CultLib" +} +$cultLibPath = [IO.Path]::GetFullPath($CultLibRoot) +$cultLibPack = Join-Path $cultLibPath "scripts\pack-nuget.ps1" +if (-not (Test-Path -LiteralPath $cultLibPack)) { + throw "CultLib NuGet packer was not found at '$cultLibPack'." +} +$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { + [IO.Path]::GetFullPath($OutputDirectory) +} else { + [IO.Path]::GetFullPath((Join-Path $repoRoot $OutputDirectory)) +} + +& $cultLibPack -Configuration $Configuration -OutputDirectory $outputRoot -SkipConsumerSmoke +if ($LASTEXITCODE -ne 0) { + throw "CultLib NuGet dependency pack failed with exit code $LASTEXITCODE." +} + +$surfaceProject = Join-Path $repoRoot "packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" +dotnet pack $surfaceProject -c $Configuration -o $outputRoot ` + -p:CultLibRoot=$cultLibPath -p:NoWarn=1591%3BCS8632 ` + --nologo --verbosity quiet +if ($LASTEXITCODE -ne 0) { + throw "Eve surface NuGet pack failed with exit code $LASTEXITCODE." +} + +$surfacePackage = Get-ChildItem -LiteralPath $outputRoot -Filter "GameCult.Eve.Surface.*.nupkg" | + Where-Object { $_.Name -notlike "*.snupkg" } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 +if (-not $surfacePackage) { + throw "GameCult.Eve.Surface package was not produced." +} +$version = [regex]::Match( + $surfacePackage.Name, + '^GameCult\.Eve\.Surface\.(.+)\.nupkg$').Groups[1].Value + +if (-not $SkipConsumerSmoke) { + $smokeRoot = Join-Path ([IO.Path]::GetTempPath()) ("eve-surface-consumer-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null + try { + $projectDocument = @" + + + net10.0 + Exe + enable + $outputRoot;https://api.nuget.org/v3/index.json + + + + + +"@ + $program = @" +using GameCult.Eve.Surface; + +var surface = EveSurface.Create("sample.counter") + .Provider("sample.counter-provider", "sample.daemon") + .Title("Clean package consumer") + .RootColumn("root", root => root.Title("title", "Portable Eve")) + .Build(); +if (surface.Schema != EveSurfaceDocument.SchemaId) + throw new InvalidOperationException("The packed surface contract returned the wrong schema."); +Console.WriteLine(surface.Schema); +"@ + [IO.File]::WriteAllText( + (Join-Path $smokeRoot "Consumer.csproj"), + $projectDocument, + [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText( + (Join-Path $smokeRoot "Program.cs"), + $program, + [Text.UTF8Encoding]::new($false)) + dotnet run --project (Join-Path $smokeRoot "Consumer.csproj") -c $Configuration ` + --nologo --verbosity quiet + if ($LASTEXITCODE -ne 0) { + throw "Eve surface clean NuGet consumer failed with exit code $LASTEXITCODE." + } + } finally { + if (Test-Path -LiteralPath $smokeRoot) { + Remove-Item -LiteralPath $smokeRoot -Recurse -Force + } + } +} + +Write-Host "Eve .NET package feed: $outputRoot" +Write-Host "GameCult.Eve.Surface: $version" +Write-Host "Clean PackageReference consumer: $(-not $SkipConsumerSmoke)" From 351f70245bd27552ed1921861ddf36dbef32dcba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 03:29:46 +0200 Subject: [PATCH 12/57] Bind Eve navigation to an authority runtime --- .../dist/generated/command-receipt.d.ts | 1 + .../eve-contracts/src/generated/command-receipt.ts | 1 + packages/eve-contracts/src/generated/schemas.ts | 4 ++++ .../GameCult.Eve.Surface.csproj | 2 +- .../Runtime/EveProviderDocuments.cs | 13 ++++++++++--- packages/org.gamecult.eve.surface/package.json | 2 +- schemas/gamecult.eve.command_receipt.v1.schema.json | 4 ++++ .../EveSurfaceSerializationCompatibilityTests.cs | 5 ++++- 8 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/eve-contracts/dist/generated/command-receipt.d.ts b/packages/eve-contracts/dist/generated/command-receipt.d.ts index 7c6c6c4..c6b4005 100644 --- a/packages/eve-contracts/dist/generated/command-receipt.d.ts +++ b/packages/eve-contracts/dist/generated/command-receipt.d.ts @@ -16,6 +16,7 @@ export interface EveCommandReceipt { sourceVersion: number; navigation?: { verseId: string; + authorityRuntimeId?: string; providerId?: string; surfaceId: string; surfaceKind?: string; diff --git a/packages/eve-contracts/src/generated/command-receipt.ts b/packages/eve-contracts/src/generated/command-receipt.ts index 46a6db4..dee7125 100644 --- a/packages/eve-contracts/src/generated/command-receipt.ts +++ b/packages/eve-contracts/src/generated/command-receipt.ts @@ -18,6 +18,7 @@ issuedAtUtc?: string sourceVersion: number 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 a77d3e3..d54289d 100644 --- a/packages/eve-contracts/src/generated/schemas.ts +++ b/packages/eve-contracts/src/generated/schemas.ts @@ -757,6 +757,10 @@ export const eveContractSchemas = { "type": "string", "minLength": 1 }, + "authorityRuntimeId": { + "type": "string", + "minLength": 1 + }, "providerId": { "type": "string" }, diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj index 72b5b3f..c3efc08 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.3 GameCult MPL-2.0 https://github.com/GameCult/Eve diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs index 052f05e..7b4621a 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -546,8 +546,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 +572,7 @@ namespace GameCult.Eve.Surface try { var verseId = ""; + var authorityRuntimeId = ""; var providerId = ""; var surfaceId = ""; var surfaceKind = ""; @@ -581,6 +583,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 +591,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 +630,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 +646,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/package.json b/packages/org.gamecult.eve.surface/package.json index c4b93b8..a2badf5 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.3", "unity": "2021.3", "description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.", "dependencies": { diff --git a/schemas/gamecult.eve.command_receipt.v1.schema.json b/schemas/gamecult.eve.command_receipt.v1.schema.json index 90ea3fb..f0c3091 100644 --- a/schemas/gamecult.eve.command_receipt.v1.schema.json +++ b/schemas/gamecult.eve.command_receipt.v1.schema.json @@ -80,6 +80,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..4c44d62 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -208,7 +208,8 @@ 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")); var bytes = MessagePackSerializer.Serialize(document, Options); var reader = new MessagePackReader(bytes); @@ -220,6 +221,7 @@ public sealed class EveSurfaceSerializationCompatibilityTests 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" })); @@ -260,6 +262,7 @@ 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); }); } From 4167fc32ca966699dbaeba1ab0f3db15e596657f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 04:18:18 +0200 Subject: [PATCH 13/57] Bind Eve receipts to immutable invocations --- .../dist/generated/command-receipt.d.ts | 4 +++ .../eve-contracts/dist/generated/schemas.d.ts | 9 +++++++ .../eve-contracts/dist/generated/schemas.js | 9 +++++++ .../src/generated/command-receipt.ts | 4 +++ .../eve-contracts/src/generated/schemas.ts | 5 ++++ .../eve-contracts/test/contracts.test.mjs | 1 + .../GameCult.Eve.Surface.csproj | 2 +- .../Runtime/EveProviderDocuments.cs | 27 ++++++++++++------- .../org.gamecult.eve.surface/package.json | 2 +- ...amecult.eve.command_receipt.v1.schema.json | 5 ++++ ...eSurfaceSerializationCompatibilityTests.cs | 9 +++++-- ...ult.Eve.Surface.Serialization.Tests.csproj | 1 + 12 files changed, 65 insertions(+), 13 deletions(-) diff --git a/packages/eve-contracts/dist/generated/command-receipt.d.ts b/packages/eve-contracts/dist/generated/command-receipt.d.ts index c6b4005..1ee105b 100644 --- a/packages/eve-contracts/dist/generated/command-receipt.d.ts +++ b/packages/eve-contracts/dist/generated/command-receipt.d.ts @@ -14,6 +14,10 @@ export interface EveCommandReceipt { }[]; issuedAtUtc?: string; sourceVersion: number; + /** + * Canonical digest of the immutable invocation envelope finalized by this receipt. + */ + invocationHash?: string; navigation?: { verseId: string; authorityRuntimeId?: string; diff --git a/packages/eve-contracts/dist/generated/schemas.d.ts b/packages/eve-contracts/dist/generated/schemas.d.ts index 0a823e6..0f1f0a4 100644 --- a/packages/eve-contracts/dist/generated/schemas.d.ts +++ b/packages/eve-contracts/dist/generated/schemas.d.ts @@ -622,6 +622,11 @@ export declare const eveContractSchemas: { readonly type: "integer"; readonly minimum: 0; }; + 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"; readonly required: readonly ["verseId", "surfaceId"]; @@ -630,6 +635,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 88188b2..403420e 100644 --- a/packages/eve-contracts/dist/generated/schemas.js +++ b/packages/eve-contracts/dist/generated/schemas.js @@ -746,6 +746,11 @@ export const eveContractSchemas = { "type": "integer", "minimum": 0 }, + "invocationHash": { + "type": "string", + "minLength": 1, + "description": "Canonical digest of the immutable invocation envelope finalized by this receipt." + }, "navigation": { "type": "object", "required": [ @@ -757,6 +762,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-receipt.ts b/packages/eve-contracts/src/generated/command-receipt.ts index dee7125..58fba85 100644 --- a/packages/eve-contracts/src/generated/command-receipt.ts +++ b/packages/eve-contracts/src/generated/command-receipt.ts @@ -16,6 +16,10 @@ diagnostics?: { }[] issuedAtUtc?: string sourceVersion: number +/** + * Canonical digest of the immutable invocation envelope finalized by this receipt. + */ +invocationHash?: string navigation?: { verseId: string authorityRuntimeId?: string diff --git a/packages/eve-contracts/src/generated/schemas.ts b/packages/eve-contracts/src/generated/schemas.ts index d54289d..bd01380 100644 --- a/packages/eve-contracts/src/generated/schemas.ts +++ b/packages/eve-contracts/src/generated/schemas.ts @@ -746,6 +746,11 @@ export const eveContractSchemas = { "type": "integer", "minimum": 0 }, + "invocationHash": { + "type": "string", + "minLength": 1, + "description": "Canonical digest of the immutable invocation envelope finalized by this receipt." + }, "navigation": { "type": "object", "required": [ diff --git a/packages/eve-contracts/test/contracts.test.mjs b/packages/eve-contracts/test/contracts.test.mjs index 3650a5f..8b78020 100644 --- a/packages/eve-contracts/test/contracts.test.mjs +++ b/packages/eve-contracts/test/contracts.test.mjs @@ -81,6 +81,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 c3efc08..4d03a28 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.3 + 0.3.4 GameCult MPL-2.0 https://github.com/GameCult/Eve diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs index 7b4621a..e924a8d 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -366,8 +366,9 @@ 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 = "") + : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash) { } @@ -385,7 +386,8 @@ namespace GameCult.Eve.Surface string message, string issuedAtUtc, long sourceVersion, - EveSurfaceNavigationTarget? navigation = null) + EveSurfaceNavigationTarget? navigation = null, + string invocationHash = "") { Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; ReceiptId = receiptId ?? ""; @@ -400,6 +402,7 @@ namespace GameCult.Eve.Surface IssuedAtUtc = issuedAtUtc ?? ""; SourceVersion = sourceVersion; Navigation = navigation; + InvocationHash = invocationHash ?? ""; } [Key(0)] public string Schema { get; } @@ -415,6 +418,7 @@ 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; } } /// @@ -424,7 +428,7 @@ namespace GameCult.Eve.Surface public sealed class EveCommandReceiptCompatibilityFormatter : IMessagePackFormatter { - private const int LegacyFieldCount = 13; + private const int LegacyFieldCount = 14; private const int RequiredAndScalarFieldCount = 12; public void Serialize( @@ -434,9 +438,9 @@ 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); + writer.WriteMapHeader(RequiredAndScalarFieldCount + optionalFields); Write(ref writer, "schema", value.Schema); Write(ref writer, "receiptId", value.ReceiptId); Write(ref writer, "commandId", value.CommandId); @@ -454,6 +458,8 @@ namespace GameCult.Eve.Surface writer.Write("navigation"); WriteNavigation(ref writer, value.Navigation); } + if (!string.IsNullOrWhiteSpace(value.InvocationHash)) + Write(ref writer, "invocationHash", value.InvocationHash); } public EveCommandReceiptDocument? Deserialize( @@ -483,10 +489,11 @@ 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); 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); } finally { reader.Depth--; } } @@ -511,6 +518,7 @@ namespace GameCult.Eve.Surface var issuedAtUtc = ""; long sourceVersion = 0; EveSurfaceNavigationTarget? navigation = null; + var invocationHash = ""; var fields = reader.ReadMapHeader(); for (var index = 0; index < fields; index++) { @@ -529,13 +537,14 @@ 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; 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); } finally { reader.Depth--; } } diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index a2badf5..939a870 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.3", + "version": "0.3.4", "unity": "2021.3", "description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.", "dependencies": { diff --git a/schemas/gamecult.eve.command_receipt.v1.schema.json b/schemas/gamecult.eve.command_receipt.v1.schema.json index f0c3091..b668e95 100644 --- a/schemas/gamecult.eve.command_receipt.v1.schema.json +++ b/schemas/gamecult.eve.command_receipt.v1.schema.json @@ -69,6 +69,11 @@ "type": "integer", "minimum": 0 }, + "invocationHash": { + "type": "string", + "minLength": 1, + "description": "Canonical digest of the immutable invocation envelope finalized by this receipt." + }, "navigation": { "type": "object", "required": [ diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index 4c44d62..30156c3 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -209,7 +209,8 @@ public sealed class EveSurfaceSerializationCompatibilityTests "aetheria.pilot", "interactive-world", new[] { "cultnet+tcp://odin.gamecult.example:3076" }, - "commander-daemon")); + "commander-daemon"), + "sha256:launch-envelope"); var bytes = MessagePackSerializer.Serialize(document, Options); var reader = new MessagePackReader(bytes); @@ -217,7 +218,7 @@ 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(14)); 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")); @@ -225,6 +226,7 @@ public sealed class EveSurfaceSerializationCompatibilityTests 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")); } [Test] @@ -263,6 +265,7 @@ public sealed class EveSurfaceSerializationCompatibilityTests 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); }); } @@ -289,7 +292,9 @@ 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(restored.Navigation, Is.Null); + Assert.That(restored.InvocationHash, Is.Empty); }); } 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 @@ + From 0d75021c8b5fe18aed7f75e261ee6690d38f79b9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 04:25:04 +0200 Subject: [PATCH 14/57] Canonicalize Eve invocation digests --- .../Runtime/EveCommandInvocationHash.cs | 50 +++++++++++++++++++ .../Runtime/EveCommandInvocationHash.cs.meta | 11 ++++ ...eSurfaceSerializationCompatibilityTests.cs | 40 +++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs create mode 100644 packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta 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..507b163 --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs @@ -0,0 +1,50 @@ +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); + 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/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index 30156c3..52d71cc 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -68,6 +68,46 @@ 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))); + } + [Test] public void LegacySevenFieldSurfaceDocumentDeserializes() { From 3ede0799abde0bf21a4b691d99dae73ce2294f4b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 04:31:55 +0200 Subject: [PATCH 15/57] Carry delegated Eve invocation identity --- .../dist/generated/command-invocation.d.ts | 5 +++ .../eve-contracts/dist/generated/schemas.d.ts | 19 +++++++++++ .../eve-contracts/dist/generated/schemas.js | 23 +++++++++++++ .../src/generated/command-invocation.ts | 5 +++ .../eve-contracts/src/generated/schemas.ts | 23 +++++++++++++ .../GameCult.Eve.Surface.csproj | 2 +- .../Runtime/EveCommandInvocationHash.cs | 3 ++ .../Runtime/EveSurfaceDocument.cs | 32 +++++++++++++++++-- .../org.gamecult.eve.surface/package.json | 2 +- ...cult.eve.command_invocation.v1.schema.json | 12 ++++++- ...eSurfaceSerializationCompatibilityTests.cs | 16 ++++++++++ 11 files changed, 136 insertions(+), 6 deletions(-) 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/schemas.d.ts b/packages/eve-contracts/dist/generated/schemas.d.ts index 0f1f0a4..8ea315a 100644 --- a/packages/eve-contracts/dist/generated/schemas.d.ts +++ b/packages/eve-contracts/dist/generated/schemas.d.ts @@ -564,6 +564,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; }; diff --git a/packages/eve-contracts/dist/generated/schemas.js b/packages/eve-contracts/dist/generated/schemas.js index 403420e..c036cf9 100644 --- a/packages/eve-contracts/dist/generated/schemas.js +++ b/packages/eve-contracts/dist/generated/schemas.js @@ -671,6 +671,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 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/schemas.ts b/packages/eve-contracts/src/generated/schemas.ts index bd01380..18c877e 100644 --- a/packages/eve-contracts/src/generated/schemas.ts +++ b/packages/eve-contracts/src/generated/schemas.ts @@ -671,6 +671,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 diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj index 4d03a28..b74ed1f 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.4 + 0.3.5 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 index 507b163..d7ee976 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs @@ -32,6 +32,9 @@ namespace GameCult.Eve.Surface 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); 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 939a870..378f218 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.4", + "version": "0.3.5", "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/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index 52d71cc..be11714 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -106,6 +106,22 @@ public sealed class EveSurfaceSerializationCompatibilityTests 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] From 7f9b5f63ed10168cdbe6a365cd06fe728f4cdc88 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 05:30:30 +0200 Subject: [PATCH 16/57] Preserve declared Eve control payloads --- packages/eve-browser-lowering/dist/index.js | 9 ++++++--- packages/eve-browser-lowering/src/index.ts | 9 ++++++--- .../test/browser-lowering.test.mjs | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index cdd4e5c..72df2ac 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -1178,8 +1178,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, ""), @@ -1252,7 +1252,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; @@ -1590,6 +1590,8 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId); const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target); const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema); + const declaredPayload = {}; + copyPayloadProps(props, declaredPayload); const intent = { type: "surface-command", schema: "gamecult.eve.command_invocation.v1", @@ -1597,6 +1599,7 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { surfaceId, command: commandId || stringProp(action.type, "invoke"), payload: { + ...declaredPayload, ...action, transport: props.transport ?? null, }, diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 7234da2..6d5896a 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1400,8 +1400,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, ""), @@ -1490,7 +1490,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; @@ -1870,6 +1870,8 @@ export function createEveCommandIntent( const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId); const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target); const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema); + const declaredPayload: Record = {}; + copyPayloadProps(props, declaredPayload); const intent: EveCommandIntent = { type: "surface-command", schema: "gamecult.eve.command_invocation.v1", @@ -1877,6 +1879,7 @@ export function createEveCommandIntent( surfaceId, command: commandId || stringProp(action.type, "invoke"), payload: { + ...declaredPayload, ...action, transport: props.transport ?? null, }, diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index b4cbb2d..d910192 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -56,6 +56,24 @@ 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" }] }, + }); + + 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", From f7c79ee784aae57b090b8093967ad2d8f22fc19c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 19 Aug 2026 09:35:25 +0200 Subject: [PATCH 17/57] Define explicit Eve presentation finality --- .../dist/generated/command-receipt.d.ts | 7 +++++ .../eve-contracts/dist/generated/schemas.d.ts | 6 ++++ .../eve-contracts/dist/generated/schemas.js | 8 ++++- .../src/generated/command-receipt.ts | 7 +++++ .../eve-contracts/src/generated/schemas.ts | 8 ++++- .../GameCult.Eve.Surface.csproj | 2 +- .../Runtime/EveProviderDocuments.cs | 29 ++++++++++++++----- .../org.gamecult.eve.surface/package.json | 2 +- ...amecult.eve.command_receipt.v1.schema.json | 8 ++++- ...eSurfaceSerializationCompatibilityTests.cs | 9 ++++-- 10 files changed, 72 insertions(+), 14 deletions(-) diff --git a/packages/eve-contracts/dist/generated/command-receipt.d.ts b/packages/eve-contracts/dist/generated/command-receipt.d.ts index 1ee105b..735cc2f 100644 --- a/packages/eve-contracts/dist/generated/command-receipt.d.ts +++ b/packages/eve-contracts/dist/generated/command-receipt.d.ts @@ -13,7 +13,14 @@ 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. */ diff --git a/packages/eve-contracts/dist/generated/schemas.d.ts b/packages/eve-contracts/dist/generated/schemas.d.ts index 8ea315a..a75705f 100644 --- a/packages/eve-contracts/dist/generated/schemas.d.ts +++ b/packages/eve-contracts/dist/generated/schemas.d.ts @@ -640,6 +640,12 @@ 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"; diff --git a/packages/eve-contracts/dist/generated/schemas.js b/packages/eve-contracts/dist/generated/schemas.js index c036cf9..4695090 100644 --- a/packages/eve-contracts/dist/generated/schemas.js +++ b/packages/eve-contracts/dist/generated/schemas.js @@ -767,7 +767,13 @@ 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", diff --git a/packages/eve-contracts/src/generated/command-receipt.ts b/packages/eve-contracts/src/generated/command-receipt.ts index 58fba85..7b95536 100644 --- a/packages/eve-contracts/src/generated/command-receipt.ts +++ b/packages/eve-contracts/src/generated/command-receipt.ts @@ -15,7 +15,14 @@ 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. */ diff --git a/packages/eve-contracts/src/generated/schemas.ts b/packages/eve-contracts/src/generated/schemas.ts index 18c877e..4716939 100644 --- a/packages/eve-contracts/src/generated/schemas.ts +++ b/packages/eve-contracts/src/generated/schemas.ts @@ -767,7 +767,13 @@ 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", diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj index b74ed1f..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.5 + 0.3.6 GameCult MPL-2.0 https://github.com/GameCult/Eve diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs index e924a8d..31705be 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs @@ -367,8 +367,9 @@ namespace GameCult.Eve.Surface string issuedAtUtc, long sourceVersion, EveSurfaceNavigationTarget? navigation = null, - string invocationHash = "") - : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash) + string invocationHash = "", + long presentationSurfaceVersion = 0) + : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash, presentationSurfaceVersion) { } @@ -387,7 +388,8 @@ namespace GameCult.Eve.Surface string issuedAtUtc, long sourceVersion, EveSurfaceNavigationTarget? navigation = null, - string invocationHash = "") + string invocationHash = "", + long presentationSurfaceVersion = 0) { Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; ReceiptId = receiptId ?? ""; @@ -403,6 +405,7 @@ namespace GameCult.Eve.Surface SourceVersion = sourceVersion; Navigation = navigation; InvocationHash = invocationHash ?? ""; + PresentationSurfaceVersion = presentationSurfaceVersion; } [Key(0)] public string Schema { get; } @@ -419,6 +422,7 @@ namespace GameCult.Eve.Surface [Key(11)] public long SourceVersion { get; } [Key(12)] public EveSurfaceNavigationTarget? Navigation { get; } [Key(13)] public string InvocationHash { get; } + [Key(14)] public long PresentationSurfaceVersion { get; } } /// @@ -428,7 +432,7 @@ namespace GameCult.Eve.Surface public sealed class EveCommandReceiptCompatibilityFormatter : IMessagePackFormatter { - private const int LegacyFieldCount = 14; + private const int LegacyFieldCount = 15; private const int RequiredAndScalarFieldCount = 12; public void Serialize( @@ -439,7 +443,8 @@ namespace GameCult.Eve.Surface if (value == null) { writer.WriteNil(); return; } var optionalFields = (value.Navigation == null ? 0 : 1) + - (string.IsNullOrWhiteSpace(value.InvocationHash) ? 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); @@ -460,6 +465,11 @@ namespace GameCult.Eve.Surface } 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( @@ -490,10 +500,12 @@ namespace GameCult.Eve.Surface 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, invocationHash); + providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash, + presentationSurfaceVersion); } finally { reader.Depth--; } } @@ -519,6 +531,7 @@ namespace GameCult.Eve.Surface long sourceVersion = 0; EveSurfaceNavigationTarget? navigation = null; var invocationHash = ""; + long presentationSurfaceVersion = 0; var fields = reader.ReadMapHeader(); for (var index = 0; index < fields; index++) { @@ -538,13 +551,15 @@ namespace GameCult.Eve.Surface 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, invocationHash); + providerId, surfaceId, message, issuedAtUtc, sourceVersion, navigation, invocationHash, + presentationSurfaceVersion); } finally { reader.Depth--; } } diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index 378f218..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.5", + "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_receipt.v1.schema.json b/schemas/gamecult.eve.command_receipt.v1.schema.json index b668e95..d862a90 100644 --- a/schemas/gamecult.eve.command_receipt.v1.schema.json +++ b/schemas/gamecult.eve.command_receipt.v1.schema.json @@ -67,7 +67,13 @@ }, "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", diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index be11714..c756359 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -266,7 +266,8 @@ public sealed class EveSurfaceSerializationCompatibilityTests "interactive-world", new[] { "cultnet+tcp://odin.gamecult.example:3076" }, "commander-daemon"), - "sha256:launch-envelope"); + "sha256:launch-envelope", + presentationSurfaceVersion: 77); var bytes = MessagePackSerializer.Serialize(document, Options); var reader = new MessagePackReader(bytes); @@ -274,7 +275,7 @@ public sealed class EveSurfaceSerializationCompatibilityTests var restored = MessagePackSerializer.Deserialize(bytes, Options); Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Map)); - Assert.That(reader.ReadMapHeader(), Is.EqualTo(14)); + 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")); @@ -283,6 +284,7 @@ public sealed class EveSurfaceSerializationCompatibilityTests 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] @@ -322,6 +324,7 @@ public sealed class EveSurfaceSerializationCompatibilityTests 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); }); } @@ -349,8 +352,10 @@ public sealed class EveSurfaceSerializationCompatibilityTests { 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); }); } From 4338a42c6e498ad65de6eec49dce7d5231307b8b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 00:02:17 +0200 Subject: [PATCH 18/57] Repair Eve browser command and editable binding contracts --- docs/plugin-architecture.md | 7 + docs/surface-contract-v1.md | 19 +- packages/eve-browser-lowering/README.md | 6 +- .../dist/fields-browser-adapter.d.ts | 8 + packages/eve-browser-lowering/dist/index.d.ts | 43 +- packages/eve-browser-lowering/dist/index.js | 359 +++++++++++++- .../eve-browser-lowering/package-lock.json | 21 + packages/eve-browser-lowering/package.json | 3 + .../src/fields-browser-adapter.ts | 5 + packages/eve-browser-lowering/src/index.ts | 445 ++++++++++++++++-- packages/eve-browser-lowering/styles.css | 47 ++ .../test/browser-lowering.test.mjs | 15 +- .../test/host-isolation.test.mjs | 74 ++- .../dist/generated/command-descriptor.d.ts | 1 + .../dist/generated/command-result.d.ts | 44 ++ .../dist/generated/command-result.js | 1 + .../eve-contracts/dist/generated/schemas.d.ts | 94 ++++ .../eve-contracts/dist/generated/schemas.js | 116 +++++ .../eve-contracts/dist/generated/surface.d.ts | 8 + packages/eve-contracts/dist/index.d.ts | 6 + packages/eve-contracts/dist/index.js | 8 + .../scripts/generate-contracts.mjs | 1 + .../src/generated/command-descriptor.ts | 1 + .../src/generated/command-result.ts | 46 ++ .../eve-contracts/src/generated/schemas.ts | 116 +++++ .../eve-contracts/src/generated/surface.ts | 8 + packages/eve-contracts/src/index.ts | 11 + .../eve-contracts/test/contracts.test.mjs | 28 ++ schemas/gamecult.eve.command.v1.schema.json | 7 +- ...gamecult.eve.command_result.v1.schema.json | 43 ++ ...ecult.eve.operation_payload.v1.schema.json | 13 + schemas/gamecult.eve.surface.v1.schema.json | 15 +- 32 files changed, 1533 insertions(+), 86 deletions(-) create mode 100644 packages/eve-contracts/dist/generated/command-result.d.ts create mode 100644 packages/eve-contracts/dist/generated/command-result.js create mode 100644 packages/eve-contracts/src/generated/command-result.ts create mode 100644 schemas/gamecult.eve.command_result.v1.schema.json create mode 100644 schemas/gamecult.eve.operation_payload.v1.schema.json diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 097d822..c102a7b 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -58,6 +58,13 @@ Providers own: - reconciliation; - side effects. +Command delivery returns `gamecult.eve.command_result.v1`. The generic result +always contains the provider's persisted receipt and may carry a transient Eve +projection. A single optional plugin payload names its plugin id and schema; +only that plugin's renderer adapter may consume it. The payload cannot accept a +provider command or mutate provider state, so plugin rendering never becomes a +shadow authority. + Odin/CultMesh owns: - provider advertisement discovery; diff --git a/docs/surface-contract-v1.md b/docs/surface-contract-v1.md index bb7b9aa..b8a7039 100644 --- a/docs/surface-contract-v1.md +++ b/docs/surface-contract-v1.md @@ -247,14 +247,20 @@ invocations use `gamecult.eve.command_invocation.v1`: ```json { - "type": "surface-command", "schema": "gamecult.eve.command_invocation.v1", "providerId": "gamecult.home.vn", "surfaceId": "sai.visual_novel.surface", - "command": "story.choose", + "operation": { + "operationId": "story.choose", + "schemaId": "sai.story_choice.v1", + "idempotencyKey": "21a816d5-7ed5-4513-afb0-0a9bc9aa334b", + "routeHint": { "sourceVersion": 41 } + }, "payload": { "index": 0, "targetPath": "eve" }, "issuedAt": "2026-05-31T00:00:00.000Z", - "clientId": "browser.reference" + "clientId": "browser.reference", + "commandBoundary": "sai.commands", + "receiptSchema": "gamecult.eve.command_receipt.v1" } ``` @@ -283,6 +289,13 @@ are not the authoritative live command model and should not appear as public runtime construction APIs. Renderer command requests carry `CultMeshOperationInvocationDescriptor` values. +Editable controls remain state bindings rather than form-shaped command DTOs. +Bindings may additionally name their document and field, value kind, access +mode, authority, stable `bindingName`, and optional `writeCommand`. Renderers +keep unaccepted edits as local drafts. Operations declare `captureBindings` and +receive those values under `payload.bindings`; accepted command results may +request that exact bindings be cleared. HTML form elements are an accessibility +lowering detail, not part of Eve's public ontology. That invocation descriptor is the canonical operation identity at click/change time and preserves request schema, preferred route, and optional idempotency. Renderer command requests also carry `CultMeshOperationPayload`, a shared diff --git a/packages/eve-browser-lowering/README.md b/packages/eve-browser-lowering/README.md index ffe883f..f776573 100644 --- a/packages/eve-browser-lowering/README.md +++ b/packages/eve-browser-lowering/README.md @@ -7,9 +7,13 @@ This package owns the shared browser renderer contract: - `renderEveSurface(surface, host, options)` lowers an Eve surface document into a DOM host. - `renderEveComponent(component, options)` lowers a single component tree. - `applyEveSurfaceStyles(styles, body)` maps portable Eve style tokens to CSS custom properties. -- `createEveCommandIntent(commandId, props, options)` creates the browser command envelope for host daemons and carries the provider-advertised `commandBoundary` / `receiptSchema` when the active surface exposes `worldInteraction`. +- `createEveCommandIntent(commandId, props, options)` emits the canonical `gamecult.eve.command_invocation.v1` operation descriptor, including the provider-advertised command boundary and receipt schema, a source-version route hint, and a fresh idempotency key. - `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary. +- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves draft values by provider/surface/binding identity and an operation may capture those bindings atomically under `payload.bindings`. +- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route. +An HTML `
` may be used internally for keyboard behavior, but it is not an Eve primitive or state owner. Eve exposes editable bindings and typed operations; HTML remains a lowering detail. + Asset references are left as-is unless the host provides `assetUrlResolver`. Eir uses that hook only for CultMesh/CultCache asset URIs such as `cultmesh://schema/record` or `cultcache://schema/record`; ordinary HTTPS assets and web fonts remain ordinary browser URLs. diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts index a25d7de..6907489 100644 --- a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts @@ -4,6 +4,14 @@ export interface EveBrowserPluginAdapter { componentKinds: readonly string[]; schemas: readonly string[]; normalizeDocument(schemaId: string | undefined, value: unknown): unknown; + consumeCommandResult?: (payload: { + pluginId: string; + schemaId: string; + payload: Record; + }, result: unknown, context: { + provider: unknown; + surface: unknown; + }) => void | Promise; renderComponent?: (component: FieldsComponent, props: Record, layout: Record, style: Record, options: FieldsLoweringOptions, context: FieldsRuntimeContext) => HTMLElement; } export declare const fieldsBrowserAdapter: EveBrowserPluginAdapter; diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index cbb8b83..51d9a3d 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -1,4 +1,5 @@ import { type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; +import { type EveCommandInvocation, type EveCommandResult } from "@gamecult/eve-contracts"; export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; export interface EveSurfaceComponent { id?: string; @@ -19,6 +20,13 @@ export interface EveStateBindingDescriptor { schemaId: string; routeKind: string; routeDescription?: string; + bindingName?: string; + documentId?: string; + fieldPath?: string; + valueKind?: "string" | "number" | "boolean" | "choice" | "string-list"; + accessMode?: "read" | "write" | "read-write" | "local-draft"; + authority?: string; + writeCommand?: string; } export interface EveStateBindingHandle { latest(): Promise; @@ -36,6 +44,12 @@ export interface EveSurfaceDocument { styles?: EveSurfaceStyles; }; mesh?: unknown; + commands?: Array<{ + command: string; + payloadSchema?: string; + captureBindings?: string[]; + transport?: string; + }>; } export type EveSurfaceStyles = { tokens?: Record; @@ -49,18 +63,8 @@ export type EveSurfaceStyles = { name?: string; value?: unknown; }>; -export interface EveCommandIntent { - type: "surface-command"; - schema: "gamecult.eve.command_invocation.v1"; - providerId: string; - surfaceId: string; - command: string; - commandBoundary?: string; - receiptSchema?: string; - payload: Record; - issuedAt: string; - clientId: string; -} +export type EveCommandIntent = EveCommandInvocation; +export type EveBrowserCommandResult = EveCommandResult; export interface EveSemanticListItem { label: string; status: string; @@ -99,13 +103,14 @@ export interface EveBrowserLoweringOptions { assetUrlResolver?: (uri: string, surface: EveSurfaceDocument | undefined) => string; body?: HTMLElement; clientId?: string; - commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise; + commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise; documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; provider?: EveProviderAdvertisement; pluginAdapters?: readonly EveBrowserPluginAdapter[]; stateBindingResolver?: EveStateBindingResolver; source?: string; statusElement?: HTMLElement; + draftStore?: EveBrowserDraftStore; } export interface EveBrowserProviderTransport { providerAdvertisement(): Promise; @@ -123,6 +128,15 @@ export interface EveBrowserProviderHostOptions { statusElement?: HTMLElement; pluginAdapters?: readonly EveBrowserPluginAdapter[]; } +export declare class EveBrowserDraftStore { + private readonly values; + private key; + has(providerId: string, surfaceId: string, bindingName: string): boolean; + get(providerId: string, surfaceId: string, bindingName: string): unknown; + set(providerId: string, surfaceId: string, bindingName: string, value: unknown): void; + clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void; + capture(providerId: string, surfaceId: string, bindingNames: readonly string[]): Record; +} export declare const defaultBrowserPluginAdapters: readonly EveBrowserPluginAdapter[]; export declare function resolveRequiredPluginAdapters(surface: EveProviderSurfaceAdvertisement, available?: readonly EveBrowserPluginAdapter[]): readonly EveBrowserPluginAdapter[]; export declare function selectAdvertisedSurface(provider: EveProviderAdvertisement, requestedSurfaceId?: string): EveProviderSurfaceAdvertisement; @@ -136,11 +150,14 @@ export declare class EveBrowserProviderHost { private provider; private selected; private pluginAdapters; + private readonly draftStore; constructor(host: HTMLElement, transport: EveBrowserProviderTransport, options?: EveBrowserProviderHostOptions); start(): Promise; stop(): void; refresh(): Promise; private submit; + private consumeCommandResult; + private presentCommandStatus; } export interface EveEmbeddedDocumentRequest { documentId: string; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index cdd4e5c..018dcbd 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -1,5 +1,35 @@ import { fieldsBrowserAdapter } from "./fields-browser-adapter.js"; +import { parseEveCommandResult, } from "@gamecult/eve-contracts"; export { fieldsBrowserAdapter, normalizeFieldsDocument } from "./fields-browser-adapter.js"; +export class EveBrowserDraftStore { + values = new Map(); + key(providerId, surfaceId, bindingName) { + return `${providerId}\u001f${surfaceId}\u001f${bindingName}`; + } + has(providerId, surfaceId, bindingName) { + return this.values.has(this.key(providerId, surfaceId, bindingName)); + } + get(providerId, surfaceId, bindingName) { + return this.values.get(this.key(providerId, surfaceId, bindingName)); + } + set(providerId, surfaceId, bindingName, value) { + this.values.set(this.key(providerId, surfaceId, bindingName), value); + } + clear(providerId, surfaceId, bindingNames) { + if (bindingNames?.length) { + for (const name of bindingNames) + this.values.delete(this.key(providerId, surfaceId, name)); + return; + } + const prefix = `${providerId}\u001f${surfaceId}\u001f`; + for (const key of this.values.keys()) + if (key.startsWith(prefix)) + this.values.delete(key); + } + capture(providerId, surfaceId, bindingNames) { + return Object.fromEntries(bindingNames.map(name => [name, this.get(providerId, surfaceId, name)])); + } +} export const defaultBrowserPluginAdapters = [fieldsBrowserAdapter]; export function resolveRequiredPluginAdapters(surface, available = defaultBrowserPluginAdapters) { const resolved = []; @@ -39,15 +69,26 @@ export class EveBrowserProviderHost { provider; selected; pluginAdapters = []; + draftStore = new EveBrowserDraftStore(); constructor(host, transport, options = {}) { this.host = host; this.transport = transport; this.options = options; } async start() { - this.provider = await this.transport.providerAdvertisement(); - this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId); - this.pluginAdapters = resolveRequiredPluginAdapters(this.selected, this.options.pluginAdapters || defaultBrowserPluginAdapters); + try { + this.provider = await this.transport.providerAdvertisement(); + this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId); + requireCommandCapability(this.selected); + this.pluginAdapters = resolveRequiredPluginAdapters(this.selected, this.options.pluginAdapters || defaultBrowserPluginAdapters); + } + catch (error) { + const message = error instanceof Error ? error.message : "Eve provider capabilities are unavailable."; + this.host.replaceChildren(emptyState(message)); + if (this.options.statusElement) + this.options.statusElement.textContent = message; + throw error; + } this.active = true; await this.refresh(); const pollMs = Math.max(0, this.options.pollMs ?? 250); @@ -81,6 +122,7 @@ export class EveBrowserProviderHost { pluginAdapters: this.pluginAdapters, source: this.options.source, statusElement: this.options.statusElement, + draftStore: this.draftStore, }); } catch (error) { @@ -90,10 +132,59 @@ export class EveBrowserProviderHost { } } async submit(intent) { - await this.transport.submitCommand(intent); + try { + const result = parseEveCommandResult(await this.transport.submitCommand(intent)); + await this.consumeCommandResult(intent, result); + } + catch (error) { + this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error"); + return; + } this.lastSurfaceVersion = ""; window.setTimeout(() => void this.refresh(), 100); } + async consumeCommandResult(intent, result) { + const accepted = result.receipt.state === "accepted" || result.receipt.state === "reconciled"; + if (accepted && result.draftDirective?.clear) { + this.draftStore.clear(intent.providerId, intent.surfaceId, result.draftDirective.bindingNames); + } + if (result.pluginPayload) { + const adapter = this.pluginAdapters.find(candidate => candidate.pluginId === result.pluginPayload?.pluginId); + if (!adapter?.consumeCommandResult) { + throw new Error(`No Eve plugin adapter can consume ${result.pluginPayload.pluginId}.`); + } + await adapter.consumeCommandResult(result.pluginPayload, result, { + provider: this.provider, + surface: this.selected, + }); + } + this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "error", result.transientProjection); + } + presentCommandStatus(message, role, transient) { + let region = this.host.querySelector(":scope > .eve-command-result-region"); + if (!region) { + region = el("section", "eve-command-result-region"); + region.setAttribute("aria-live", "polite"); + this.host.append(region); + } + region.setAttribute("role", role); + region.replaceChildren(el("p", "eve-command-result-message", message)); + const projection = transient; + if (projection?.surface?.root) { + region.append(renderEveComponent(projection.surface.root, { + activeSurfaceId: projection.surface.id || this.selected?.surfaceId, + clientId: this.options.clientId, + draftStore: this.draftStore, + pluginAdapters: this.pluginAdapters, + provider: this.provider, + })); + } + } +} +function requireCommandCapability(surface) { + if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) { + throw new Error(`Eve surface ${surface.surfaceId || "(unknown)"} does not advertise a command boundary and receipt schema.`); + } } const defaultStyleTokens = { "--bg": "#020909", @@ -124,6 +215,7 @@ const defaultStyleTokens = { }; const loadedFontStylesheets = new Map(); const activeSurfaceBindings = new WeakMap(); +const surfaceDraftStores = new WeakMap(); const eveBrowserRenderContext = Symbol("eve.browser.render-context"); function scopeEveBrowserOptions(surface, options) { return { @@ -140,7 +232,12 @@ function renderContext(options) { } export function renderEveSurface(surface, host, options = {}) { activeSurfaceBindings.get(host)?.dispose(); - const scopedOptions = scopeEveBrowserOptions(surface, options); + let draftStore = options.draftStore || surfaceDraftStores.get(host); + if (!draftStore) { + draftStore = new EveBrowserDraftStore(); + surfaceDraftStores.set(host, draftStore); + } + const scopedOptions = scopeEveBrowserOptions(surface, { ...options, draftStore }); applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host); host.dataset.provider = surface.providerId || options.provider?.providerId || ""; if (options.body) { @@ -686,14 +783,15 @@ function renderEveComponentProjection(node, options) { if (kind === "input.select" || kind === "control.select") { return renderSelect(node, props, children, layout, style, options); } - if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") { - const field = el("label", "field cultui-field"); - assignId(field, node); - applyBoxProps(field, props); - applyGeneratedLayout(field, layout, style); - field.append(el("span", "field-label", stringProp(props.label, ""))); - field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value))); - return field; + if (kind === "control.choice-group") { + return renderChoiceGroup(node, props, children, layout, style, options); + } + if (kind === "input.number" || kind === "control.range" || kind === "control.input.number" || + kind === "control.input.text" || kind === "control.input.textarea") { + return renderEditableControl(node, kind, props, layout, style, options); + } + if (kind === "control.tabs") { + return renderTabs(node, props, children, layout, style, options); } if (kind === "color.swatch") { const swatch = el("div", "paint-swatch cultui-swatch"); @@ -1590,25 +1688,57 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId); const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target); const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema); + if (!commandBoundary || !receiptSchema) { + throw new Error(`Eve surface ${surfaceId} cannot invoke commands without an advertised command boundary and receipt schema.`); + } + const operationId = commandId || stringProp(action.type, "invoke"); + const descriptor = surface?.commands?.find(command => command.command === operationId); + const captureBindings = stringArray(descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings); + const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {}; + const payload = commandPayload(action); + if (captureBindings.length) + payload.bindings = bindings; const intent = { - type: "surface-command", schema: "gamecult.eve.command_invocation.v1", providerId, surfaceId, - command: commandId || stringProp(action.type, "invoke"), - payload: { - ...action, - transport: props.transport ?? null, + operation: { + operationId, + schemaId: firstString(descriptor?.payloadSchema, action.schemaId, props.payloadSchema, "gamecult.eve.operation_payload.v1"), + idempotencyKey: randomIdempotencyKey(), + routeHint: { + sourceVersion: Math.max(0, Math.trunc(surface?.version ?? 0)), + ...(firstString(descriptor?.transport, props.transport, action.transport) + ? { transport: firstString(descriptor?.transport, props.transport, action.transport) } + : {}), + }, }, + payload, issuedAt: new Date().toISOString(), clientId: options.clientId || "eve.browser", + commandBoundary, + receiptSchema, }; - if (commandBoundary) - intent.commandBoundary = commandBoundary; - if (receiptSchema) - intent.receiptSchema = receiptSchema; return intent; } +function commandPayload(action) { + const payload = { ...action }; + for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) { + delete payload[key]; + } + return payload; +} +function randomIdempotencyKey() { + if (typeof globalThis.crypto?.randomUUID === "function") + return globalThis.crypto.randomUUID(); + const bytes = new Uint8Array(16); + globalThis.crypto?.getRandomValues?.(bytes); + if (!bytes.some(Boolean)) { + for (let index = 0; index < bytes.length; index++) + bytes[index] = Math.floor(Math.random() * 256); + } + return [...bytes].map(value => value.toString(16).padStart(2, "0")).join(""); +} function resolveAdvertisedWorldInteraction(options, surfaceId) { const surfaces = [ ...(options.provider?.surfaces || []), @@ -1643,7 +1773,172 @@ function wireCommand(element, node, commandId, props, options) { } }); } +function editableBindingContext(node, props, options) { + const binding = (node.stateBindings || []).find(candidate => candidate.targetProp === "value") + ?? node.stateBindings?.[0]; + const surface = renderContext(options)?.surface; + const providerId = options.provider?.providerId || surface?.providerId || "surface unknown"; + const surfaceId = options.activeSurfaceId || surface?.surface?.id || providerId; + const bindingName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id); + const draft = bindingName && options.draftStore?.has(providerId, surfaceId, bindingName) + ? options.draftStore.get(providerId, surfaceId, bindingName) + : undefined; + const accessMode = binding?.accessMode || (binding ? "read" : "local-draft"); + return { + binding, + bindingName, + providerId, + surfaceId, + value: draft ?? props.value ?? "", + disabled: boolProp(props.disabled) || accessMode === "read", + }; +} +function updateDraft(context, value, options) { + if (context.bindingName) + options.draftStore?.set(context.providerId, context.surfaceId, context.bindingName, value); +} +function publishDirectEdit(context, value, node, options) { + const binding = context.binding; + const writeCommand = binding?.writeCommand; + if (!binding || !writeCommand || !context.bindingName) + return; + void options.commandSink?.(createEveCommandIntent(writeCommand, { + action: { + bindingName: context.bindingName, + documentId: binding.documentId, + fieldPath: binding.fieldPath, + value, + }, + }, options), node); +} +function renderEditableControl(node, kind, props, layout, style, options) { + const context = editableBindingContext(node, props, options); + const field = el("label", "field cultui-field cultui-editable"); + assignId(field, node); + applyBoxProps(field, props); + applyGeneratedLayout(field, layout, style); + const label = stringProp(props.label, ""); + if (label) + field.append(el("span", "field-label", label)); + const control = kind === "control.input.textarea" + ? el("textarea", "field-control cultui-field-input") + : el("input", "field-control cultui-field-input"); + if (control instanceof HTMLInputElement) { + control.type = kind.includes("number") || kind === "control.range" ? "number" : "text"; + if (props.min !== undefined) + control.min = String(props.min); + if (props.max !== undefined) + control.max = String(props.max); + if (props.step !== undefined) + control.step = String(props.step); + } + else if (props.rows !== undefined) { + control.rows = positiveInt(props.rows, 3); + } + control.value = String(context.value ?? ""); + control.disabled = context.disabled; + control.name = context.bindingName; + control.dataset.bindingName = context.bindingName; + control.setAttribute("aria-label", label || context.bindingName || node.id || "Input"); + if (props.placeholder !== undefined) + control.setAttribute("placeholder", String(props.placeholder)); + if (boolProp(props.required)) + control.required = true; + control.addEventListener("input", () => { + const value = control instanceof HTMLInputElement && control.type === "number" + ? (control.value === "" ? "" : Number(control.value)) + : control.value; + updateDraft(context, value, options); + }); + control.addEventListener("change", () => { + const value = control instanceof HTMLInputElement && control.type === "number" + ? (control.value === "" ? "" : Number(control.value)) + : control.value; + publishDirectEdit(context, value, node, options); + }); + field.append(control); + const validation = firstString(props.validationMessage, props.error, ""); + if (validation) { + const message = el("span", "cultui-field-validation", validation); + message.setAttribute("role", "alert"); + field.append(message); + control.setAttribute("aria-invalid", "true"); + } + return field; +} +function renderChoiceGroup(node, props, children, layout, style, options) { + const context = editableBindingContext(node, props, options); + const group = el("fieldset", "field cultui-field cultui-choice-group"); + assignId(group, node); + applyGeneratedLayout(group, layout, style); + const label = firstString(props.label, node.text, context.bindingName); + if (label) + group.append(el("legend", "field-label", label)); + for (const child of children) { + const optionProps = objectProps(child.props); + const value = firstString(optionProps.value, child.id); + const option = el("label", "cultui-choice"); + const input = el("input"); + input.type = boolProp(props.multiple) ? "checkbox" : "radio"; + input.name = context.bindingName; + input.value = value; + input.disabled = context.disabled || boolProp(optionProps.disabled); + input.checked = input.type === "checkbox" + ? stringArray(context.value).includes(value) + : String(context.value) === value; + input.addEventListener("change", () => { + const next = input.type === "checkbox" + ? [...group.querySelectorAll("input:checked")].map(candidate => candidate.value) + : value; + updateDraft(context, next, options); + publishDirectEdit(context, next, node, options); + }); + option.append(input, document.createTextNode(firstString(optionProps.label, child.text, value))); + group.append(option); + } + return group; +} +function renderTabs(node, props, children, layout, style, options) { + const context = editableBindingContext(node, { ...props, bind: props.bind || `${node.id || "tabs"}.selected` }, options); + const root = el("section", "cultui-tabs"); + assignId(root, node); + applyGeneratedLayout(root, layout, style); + const tabList = el("div", "cultui-tab-list"); + tabList.setAttribute("role", "tablist"); + const panels = el("div", "cultui-tab-panels"); + const selected = firstString(context.value, objectProps(children[0]?.props).value, children[0]?.id); + const activate = (value) => { + updateDraft(context, value, options); + for (const button of tabList.querySelectorAll("[role=tab]")) { + const active = button.dataset.value === value; + button.setAttribute("aria-selected", String(active)); + button.tabIndex = active ? 0 : -1; + } + for (const panel of panels.querySelectorAll("[role=tabpanel]")) + panel.hidden = panel.dataset.value !== value; + }; + for (const [index, child] of children.entries()) { + const childProps = objectProps(child.props); + const value = firstString(childProps.value, child.id, String(index)); + const button = el("button", "cultui-tab", firstString(childProps.label, child.text, value)); + button.type = "button"; + button.dataset.value = value; + button.setAttribute("role", "tab"); + button.addEventListener("click", () => activate(value)); + tabList.append(button); + const panel = el("section", "cultui-tab-panel"); + panel.dataset.value = value; + panel.setAttribute("role", "tabpanel"); + for (const content of child.children || []) + panel.append(renderEveComponent(content, options)); + panels.append(panel); + } + root.append(tabList, panels); + activate(selected); + return root; +} function renderSelect(node, props, children, layout, style, options) { + const context = editableBindingContext(node, props, options); const field = el("label", "field cultui-field cultui-select"); assignId(field, node); applyBoxProps(field, props); @@ -1664,12 +1959,15 @@ function renderSelect(node, props, children, layout, style, options) { (optionProps.enabled !== undefined && !boolProp(optionProps.enabled)); select.append(option); } - select.value = stringProp(props.value, ""); - select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled)); + select.value = String(context.value ?? ""); + select.name = context.bindingName; + select.dataset.bindingName = context.bindingName; + select.disabled = context.disabled || (props.enabled !== undefined && !boolProp(props.enabled)); const commandId = resolveComponentCommandId(props, node); if (commandId) { select.dataset.commandId = commandId; select.addEventListener("change", () => { + updateDraft(context, select.value, options); const intent = createEveCommandIntent(commandId, { ...props, action: { @@ -1680,6 +1978,12 @@ function renderSelect(node, props, children, layout, style, options) { void options.commandSink?.(intent, node); }); } + else { + select.addEventListener("change", () => { + updateDraft(context, select.value, options); + publishDirectEdit(context, select.value, node, options); + }); + } field.append(select); return field; } @@ -1917,6 +2221,13 @@ export function projectSemanticListItems(value) { function stringProp(value, fallback) { return value === null || value === undefined ? fallback : String(value); } +function stringArray(value) { + if (Array.isArray(value)) + return value.map(item => String(item).trim()).filter(Boolean); + if (typeof value === "string") + return value.split(",").map(item => item.trim()).filter(Boolean); + return []; +} function firstString(...values) { for (const value of values) { if (typeof value === "string" && value.trim()) diff --git a/packages/eve-browser-lowering/package-lock.json b/packages/eve-browser-lowering/package-lock.json index adbc042..0947e8c 100644 --- a/packages/eve-browser-lowering/package-lock.json +++ b/packages/eve-browser-lowering/package-lock.json @@ -7,12 +7,29 @@ "": { "name": "@gamecult/eve-browser-lowering", "version": "0.1.1", + "dependencies": { + "@gamecult/eve-contracts": "file:../eve-contracts" + }, "devDependencies": { "@types/jsdom": "^30.0.0", "jsdom": "^30.0.1", "typescript": "^5.6.3" } }, + "../eve-contracts": { + "name": "@gamecult/eve-contracts", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "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" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", @@ -217,6 +234,10 @@ } } }, + "node_modules/@gamecult/eve-contracts": { + "resolved": "../eve-contracts", + "link": true + }, "node_modules/@types/jsdom": { "version": "30.0.0", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz", diff --git a/packages/eve-browser-lowering/package.json b/packages/eve-browser-lowering/package.json index f9103ce..c2163b3 100644 --- a/packages/eve-browser-lowering/package.json +++ b/packages/eve-browser-lowering/package.json @@ -8,6 +8,9 @@ "dist", "styles.css" ], + "dependencies": { + "@gamecult/eve-contracts": "file:../eve-contracts" + }, "scripts": { "build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json", "test": "node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test test/*.test.mjs" diff --git a/packages/eve-browser-lowering/src/fields-browser-adapter.ts b/packages/eve-browser-lowering/src/fields-browser-adapter.ts index 0940a6f..3e24aa0 100644 --- a/packages/eve-browser-lowering/src/fields-browser-adapter.ts +++ b/packages/eve-browser-lowering/src/fields-browser-adapter.ts @@ -12,6 +12,11 @@ export interface EveBrowserPluginAdapter { componentKinds: readonly string[]; schemas: readonly string[]; normalizeDocument(schemaId: string | undefined, value: unknown): unknown; + consumeCommandResult?: ( + payload: { pluginId: string; schemaId: string; payload: Record }, + result: unknown, + context: { provider: unknown; surface: unknown }, + ) => void | Promise; renderComponent?: ( component: FieldsComponent, props: Record, diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 7234da2..4ce39f0 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1,4 +1,9 @@ import { fieldsBrowserAdapter, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; +import { + parseEveCommandResult, + type EveCommandInvocation, + type EveCommandResult, +} from "@gamecult/eve-contracts"; export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; @@ -22,6 +27,13 @@ export interface EveStateBindingDescriptor { schemaId: string; routeKind: string; routeDescription?: string; + bindingName?: string; + documentId?: string; + fieldPath?: string; + valueKind?: "string" | "number" | "boolean" | "choice" | "string-list"; + accessMode?: "read" | "write" | "read-write" | "local-draft"; + authority?: string; + writeCommand?: string; } export interface EveStateBindingHandle { @@ -45,6 +57,12 @@ export interface EveSurfaceDocument { styles?: EveSurfaceStyles; }; mesh?: unknown; + commands?: Array<{ + command: string; + payloadSchema?: string; + captureBindings?: string[]; + transport?: string; + }>; } export type EveSurfaceStyles = @@ -55,18 +73,8 @@ export type EveSurfaceStyles = } | Array<{ name?: string; value?: unknown }>; -export interface EveCommandIntent { - type: "surface-command"; - schema: "gamecult.eve.command_invocation.v1"; - providerId: string; - surfaceId: string; - command: string; - commandBoundary?: string; - receiptSchema?: string; - payload: Record; - issuedAt: string; - clientId: string; -} +export type EveCommandIntent = EveCommandInvocation; +export type EveBrowserCommandResult = EveCommandResult; export interface EveSemanticListItem { label: string; @@ -111,13 +119,14 @@ export interface EveBrowserLoweringOptions { assetUrlResolver?: (uri: string, surface: EveSurfaceDocument | undefined) => string; body?: HTMLElement; clientId?: string; - commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise; + commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise; documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; provider?: EveProviderAdvertisement; pluginAdapters?: readonly EveBrowserPluginAdapter[]; stateBindingResolver?: EveStateBindingResolver; source?: string; statusElement?: HTMLElement; + draftStore?: EveBrowserDraftStore; } export interface EveBrowserProviderTransport { @@ -138,6 +147,39 @@ export interface EveBrowserProviderHostOptions { pluginAdapters?: readonly EveBrowserPluginAdapter[]; } +export class EveBrowserDraftStore { + private readonly values = new Map(); + + private key(providerId: string, surfaceId: string, bindingName: string): string { + return `${providerId}\u001f${surfaceId}\u001f${bindingName}`; + } + + has(providerId: string, surfaceId: string, bindingName: string): boolean { + return this.values.has(this.key(providerId, surfaceId, bindingName)); + } + + get(providerId: string, surfaceId: string, bindingName: string): unknown { + return this.values.get(this.key(providerId, surfaceId, bindingName)); + } + + set(providerId: string, surfaceId: string, bindingName: string, value: unknown): void { + this.values.set(this.key(providerId, surfaceId, bindingName), value); + } + + clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void { + if (bindingNames?.length) { + for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name)); + return; + } + const prefix = `${providerId}\u001f${surfaceId}\u001f`; + for (const key of this.values.keys()) if (key.startsWith(prefix)) this.values.delete(key); + } + + capture(providerId: string, surfaceId: string, bindingNames: readonly string[]): Record { + return Object.fromEntries(bindingNames.map(name => [name, this.get(providerId, surfaceId, name)])); + } +} + export const defaultBrowserPluginAdapters: readonly EveBrowserPluginAdapter[] = [fieldsBrowserAdapter]; export function resolveRequiredPluginAdapters( @@ -183,6 +225,7 @@ export class EveBrowserProviderHost { private provider: EveProviderAdvertisement | undefined; private selected: EveProviderSurfaceAdvertisement | undefined; private pluginAdapters: readonly EveBrowserPluginAdapter[] = []; + private readonly draftStore = new EveBrowserDraftStore(); constructor( private readonly host: HTMLElement, @@ -191,12 +234,20 @@ export class EveBrowserProviderHost { ) {} async start(): Promise { - this.provider = await this.transport.providerAdvertisement(); - this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId); - this.pluginAdapters = resolveRequiredPluginAdapters( - this.selected, - this.options.pluginAdapters || defaultBrowserPluginAdapters, - ); + try { + this.provider = await this.transport.providerAdvertisement(); + this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId); + requireCommandCapability(this.selected); + this.pluginAdapters = resolveRequiredPluginAdapters( + this.selected, + this.options.pluginAdapters || defaultBrowserPluginAdapters, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Eve provider capabilities are unavailable."; + this.host.replaceChildren(emptyState(message)); + if (this.options.statusElement) this.options.statusElement.textContent = message; + throw error; + } this.active = true; await this.refresh(); const pollMs = Math.max(0, this.options.pollMs ?? 250); @@ -229,6 +280,7 @@ export class EveBrowserProviderHost { pluginAdapters: this.pluginAdapters, source: this.options.source, statusElement: this.options.statusElement, + draftStore: this.draftStore, }); } catch (error) { if (this.options.statusElement) { @@ -238,10 +290,65 @@ export class EveBrowserProviderHost { } private async submit(intent: EveCommandIntent): Promise { - await this.transport.submitCommand(intent); + try { + const result = parseEveCommandResult(await this.transport.submitCommand(intent)); + await this.consumeCommandResult(intent, result); + } catch (error) { + this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error"); + return; + } this.lastSurfaceVersion = ""; window.setTimeout(() => void this.refresh(), 100); } + + private async consumeCommandResult(intent: EveCommandIntent, result: EveBrowserCommandResult): Promise { + const accepted = result.receipt.state === "accepted" || result.receipt.state === "reconciled"; + if (accepted && result.draftDirective?.clear) { + this.draftStore.clear(intent.providerId, intent.surfaceId, result.draftDirective.bindingNames); + } + if (result.pluginPayload) { + const adapter = this.pluginAdapters.find(candidate => candidate.pluginId === result.pluginPayload?.pluginId); + if (!adapter?.consumeCommandResult) { + throw new Error(`No Eve plugin adapter can consume ${result.pluginPayload.pluginId}.`); + } + await adapter.consumeCommandResult(result.pluginPayload, result, { + provider: this.provider, + surface: this.selected, + }); + } + this.presentCommandStatus( + result.receipt.message || result.receipt.state, + accepted ? "status" : "error", + result.transientProjection, + ); + } + + private presentCommandStatus(message: string, role: "status" | "error", transient?: unknown): void { + let region = this.host.querySelector(":scope > .eve-command-result-region"); + if (!region) { + region = el("section", "eve-command-result-region"); + region.setAttribute("aria-live", "polite"); + this.host.append(region); + } + region.setAttribute("role", role); + region.replaceChildren(el("p", "eve-command-result-message", message)); + const projection = transient as EveSurfaceDocument | undefined; + if (projection?.surface?.root) { + region.append(renderEveComponent(projection.surface.root, { + activeSurfaceId: projection.surface.id || this.selected?.surfaceId, + clientId: this.options.clientId, + draftStore: this.draftStore, + pluginAdapters: this.pluginAdapters, + provider: this.provider, + })); + } + } +} + +function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void { + if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) { + throw new Error(`Eve surface ${surface.surfaceId || "(unknown)"} does not advertise a command boundary and receipt schema.`); + } } export interface EveEmbeddedDocumentRequest { @@ -299,6 +406,7 @@ const defaultStyleTokens: Record = { const loadedFontStylesheets = new Map(); const activeSurfaceBindings = new WeakMap(); +const surfaceDraftStores = new WeakMap(); interface NormalizedStyles { tokens: Record; @@ -341,7 +449,12 @@ export function renderEveSurface( options: EveBrowserLoweringOptions = {}, ): HTMLElement { activeSurfaceBindings.get(host)?.dispose(); - const scopedOptions = scopeEveBrowserOptions(surface, options); + let draftStore = options.draftStore || surfaceDraftStores.get(host); + if (!draftStore) { + draftStore = new EveBrowserDraftStore(); + surfaceDraftStores.set(host, draftStore); + } + const scopedOptions = scopeEveBrowserOptions(surface, { ...options, draftStore }); applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host); host.dataset.provider = surface.providerId || options.provider?.providerId || ""; if (options.body) { @@ -889,14 +1002,19 @@ function renderEveComponentProjection( return renderSelect(node, props, children, layout, style, options); } - if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") { - const field = el("label", "field cultui-field"); - assignId(field, node); - applyBoxProps(field, props); - applyGeneratedLayout(field, layout, style); - field.append(el("span", "field-label", stringProp(props.label, ""))); - field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value))); - return field; + if (kind === "control.choice-group") { + return renderChoiceGroup(node, props, children, layout, style, options); + } + + if ( + kind === "input.number" || kind === "control.range" || kind === "control.input.number" || + kind === "control.input.text" || kind === "control.input.textarea" + ) { + return renderEditableControl(node, kind, props, layout, style, options); + } + + if (kind === "control.tabs") { + return renderTabs(node, props, children, layout, style, options); } if (kind === "color.swatch") { @@ -1870,24 +1988,59 @@ export function createEveCommandIntent( const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId); const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target); const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema); + if (!commandBoundary || !receiptSchema) { + throw new Error(`Eve surface ${surfaceId} cannot invoke commands without an advertised command boundary and receipt schema.`); + } + const operationId = commandId || stringProp(action.type, "invoke"); + const descriptor = surface?.commands?.find(command => command.command === operationId); + const captureBindings = stringArray( + descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings, + ); + const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {}; + const payload = commandPayload(action); + if (captureBindings.length) payload.bindings = bindings; const intent: EveCommandIntent = { - type: "surface-command", schema: "gamecult.eve.command_invocation.v1", providerId, surfaceId, - command: commandId || stringProp(action.type, "invoke"), - payload: { - ...action, - transport: props.transport ?? null, + operation: { + operationId, + schemaId: firstString(descriptor?.payloadSchema, action.schemaId, props.payloadSchema, "gamecult.eve.operation_payload.v1"), + idempotencyKey: randomIdempotencyKey(), + routeHint: { + sourceVersion: Math.max(0, Math.trunc(surface?.version ?? 0)), + ...(firstString(descriptor?.transport, props.transport, action.transport) + ? { transport: firstString(descriptor?.transport, props.transport, action.transport) } + : {}), + }, }, + payload, issuedAt: new Date().toISOString(), clientId: options.clientId || "eve.browser", + commandBoundary, + receiptSchema, }; - if (commandBoundary) intent.commandBoundary = commandBoundary; - if (receiptSchema) intent.receiptSchema = receiptSchema; return intent; } +function commandPayload(action: Record): Record { + const payload = { ...action }; + for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) { + delete payload[key]; + } + return payload; +} + +function randomIdempotencyKey(): string { + if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID(); + const bytes = new Uint8Array(16); + globalThis.crypto?.getRandomValues?.(bytes); + if (!bytes.some(Boolean)) { + for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256); + } + return [...bytes].map(value => value.toString(16).padStart(2, "0")).join(""); +} + function resolveAdvertisedWorldInteraction( options: EveBrowserLoweringOptions, surfaceId: string, @@ -1944,6 +2097,207 @@ function wireCommand( }); } +interface EditableBindingContext { + bindingName: string; + providerId: string; + surfaceId: string; + value: unknown; + disabled: boolean; + binding?: EveStateBindingDescriptor; +} + +function editableBindingContext( + node: EveSurfaceComponent, + props: Record, + options: EveBrowserLoweringOptions, +): EditableBindingContext { + const binding = (node.stateBindings || []).find(candidate => candidate.targetProp === "value") + ?? node.stateBindings?.[0]; + const surface = renderContext(options)?.surface; + const providerId = options.provider?.providerId || surface?.providerId || "surface unknown"; + const surfaceId = options.activeSurfaceId || surface?.surface?.id || providerId; + const bindingName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id); + const draft = bindingName && options.draftStore?.has(providerId, surfaceId, bindingName) + ? options.draftStore.get(providerId, surfaceId, bindingName) + : undefined; + const accessMode = binding?.accessMode || (binding ? "read" : "local-draft"); + return { + binding, + bindingName, + providerId, + surfaceId, + value: draft ?? props.value ?? "", + disabled: boolProp(props.disabled) || accessMode === "read", + }; +} + +function updateDraft( + context: EditableBindingContext, + value: unknown, + options: EveBrowserLoweringOptions, +): void { + if (context.bindingName) options.draftStore?.set(context.providerId, context.surfaceId, context.bindingName, value); +} + +function publishDirectEdit( + context: EditableBindingContext, + value: unknown, + node: EveSurfaceComponent, + options: EveBrowserLoweringOptions, +): void { + const binding = context.binding; + const writeCommand = binding?.writeCommand; + if (!binding || !writeCommand || !context.bindingName) return; + void options.commandSink?.(createEveCommandIntent(writeCommand, { + action: { + bindingName: context.bindingName, + documentId: binding.documentId, + fieldPath: binding.fieldPath, + value, + }, + }, options), node); +} + +function renderEditableControl( + node: EveSurfaceComponent, + kind: string, + props: Record, + layout: Record, + style: Record, + options: EveBrowserLoweringOptions, +): HTMLElement { + const context = editableBindingContext(node, props, options); + const field = el("label", "field cultui-field cultui-editable"); + assignId(field, node); + applyBoxProps(field, props); + applyGeneratedLayout(field, layout, style); + const label = stringProp(props.label, ""); + if (label) field.append(el("span", "field-label", label)); + const control = kind === "control.input.textarea" + ? el("textarea", "field-control cultui-field-input") as HTMLTextAreaElement + : el("input", "field-control cultui-field-input") as HTMLInputElement; + if (control instanceof HTMLInputElement) { + control.type = kind.includes("number") || kind === "control.range" ? "number" : "text"; + if (props.min !== undefined) control.min = String(props.min); + if (props.max !== undefined) control.max = String(props.max); + if (props.step !== undefined) control.step = String(props.step); + } else if (props.rows !== undefined) { + control.rows = positiveInt(props.rows, 3); + } + control.value = String(context.value ?? ""); + control.disabled = context.disabled; + control.name = context.bindingName; + control.dataset.bindingName = context.bindingName; + control.setAttribute("aria-label", label || context.bindingName || node.id || "Input"); + if (props.placeholder !== undefined) control.setAttribute("placeholder", String(props.placeholder)); + if (boolProp(props.required)) control.required = true; + control.addEventListener("input", () => { + const value = control instanceof HTMLInputElement && control.type === "number" + ? (control.value === "" ? "" : Number(control.value)) + : control.value; + updateDraft(context, value, options); + }); + control.addEventListener("change", () => { + const value = control instanceof HTMLInputElement && control.type === "number" + ? (control.value === "" ? "" : Number(control.value)) + : control.value; + publishDirectEdit(context, value, node, options); + }); + field.append(control); + const validation = firstString(props.validationMessage, props.error, ""); + if (validation) { + const message = el("span", "cultui-field-validation", validation); + message.setAttribute("role", "alert"); + field.append(message); + control.setAttribute("aria-invalid", "true"); + } + return field; +} + +function renderChoiceGroup( + node: EveSurfaceComponent, + props: Record, + children: EveSurfaceComponent[], + layout: Record, + style: Record, + options: EveBrowserLoweringOptions, +): HTMLElement { + const context = editableBindingContext(node, props, options); + const group = el("fieldset", "field cultui-field cultui-choice-group") as HTMLFieldSetElement; + assignId(group, node); + applyGeneratedLayout(group, layout, style); + const label = firstString(props.label, node.text, context.bindingName); + if (label) group.append(el("legend", "field-label", label)); + for (const child of children) { + const optionProps = objectProps(child.props); + const value = firstString(optionProps.value, child.id); + const option = el("label", "cultui-choice"); + const input = el("input") as HTMLInputElement; + input.type = boolProp(props.multiple) ? "checkbox" : "radio"; + input.name = context.bindingName; + input.value = value; + input.disabled = context.disabled || boolProp(optionProps.disabled); + input.checked = input.type === "checkbox" + ? stringArray(context.value).includes(value) + : String(context.value) === value; + input.addEventListener("change", () => { + const next = input.type === "checkbox" + ? [...group.querySelectorAll("input:checked")].map(candidate => candidate.value) + : value; + updateDraft(context, next, options); + publishDirectEdit(context, next, node, options); + }); + option.append(input, document.createTextNode(firstString(optionProps.label, child.text, value))); + group.append(option); + } + return group; +} + +function renderTabs( + node: EveSurfaceComponent, + props: Record, + children: EveSurfaceComponent[], + layout: Record, + style: Record, + options: EveBrowserLoweringOptions, +): HTMLElement { + const context = editableBindingContext(node, { ...props, bind: props.bind || `${node.id || "tabs"}.selected` }, options); + const root = el("section", "cultui-tabs"); + assignId(root, node); + applyGeneratedLayout(root, layout, style); + const tabList = el("div", "cultui-tab-list"); + tabList.setAttribute("role", "tablist"); + const panels = el("div", "cultui-tab-panels"); + const selected = firstString(context.value, objectProps(children[0]?.props).value, children[0]?.id); + const activate = (value: string) => { + updateDraft(context, value, options); + for (const button of tabList.querySelectorAll("[role=tab]")) { + const active = button.dataset.value === value; + button.setAttribute("aria-selected", String(active)); + button.tabIndex = active ? 0 : -1; + } + for (const panel of panels.querySelectorAll("[role=tabpanel]")) panel.hidden = panel.dataset.value !== value; + }; + for (const [index, child] of children.entries()) { + const childProps = objectProps(child.props); + const value = firstString(childProps.value, child.id, String(index)); + const button = el("button", "cultui-tab", firstString(childProps.label, child.text, value)) as HTMLButtonElement; + button.type = "button"; + button.dataset.value = value; + button.setAttribute("role", "tab"); + button.addEventListener("click", () => activate(value)); + tabList.append(button); + const panel = el("section", "cultui-tab-panel"); + panel.dataset.value = value; + panel.setAttribute("role", "tabpanel"); + for (const content of child.children || []) panel.append(renderEveComponent(content, options)); + panels.append(panel); + } + root.append(tabList, panels); + activate(selected); + return root; +} + function renderSelect( node: EveSurfaceComponent, props: Record, @@ -1952,6 +2306,7 @@ function renderSelect( style: Record, options: EveBrowserLoweringOptions, ): HTMLElement { + const context = editableBindingContext(node, props, options); const field = el("label", "field cultui-field cultui-select"); assignId(field, node); applyBoxProps(field, props); @@ -1971,13 +2326,16 @@ function renderSelect( (optionProps.enabled !== undefined && !boolProp(optionProps.enabled)); select.append(option); } - select.value = stringProp(props.value, ""); - select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled)); + select.value = String(context.value ?? ""); + select.name = context.bindingName; + select.dataset.bindingName = context.bindingName; + select.disabled = context.disabled || (props.enabled !== undefined && !boolProp(props.enabled)); const commandId = resolveComponentCommandId(props, node); if (commandId) { select.dataset.commandId = commandId; select.addEventListener("change", () => { + updateDraft(context, select.value, options); const intent = createEveCommandIntent(commandId, { ...props, action: { @@ -1987,6 +2345,11 @@ function renderSelect( }, options); void options.commandSink?.(intent, node); }); + } else { + select.addEventListener("change", () => { + updateDraft(context, select.value, options); + publishDirectEdit(context, select.value, node, options); + }); } field.append(select); return field; @@ -2206,6 +2569,12 @@ function stringProp(value: unknown, fallback: string): string { return value === null || value === undefined ? fallback : String(value); } +function stringArray(value: unknown): string[] { + if (Array.isArray(value)) return value.map(item => String(item).trim()).filter(Boolean); + if (typeof value === "string") return value.split(",").map(item => item.trim()).filter(Boolean); + return []; +} + function firstString(...values: unknown[]): string { for (const value of values) { if (typeof value === "string" && value.trim()) return value; diff --git a/packages/eve-browser-lowering/styles.css b/packages/eve-browser-lowering/styles.css index 789b7fa..825305d 100644 --- a/packages/eve-browser-lowering/styles.css +++ b/packages/eve-browser-lowering/styles.css @@ -1121,3 +1121,50 @@ body[data-pixel-art="true"] .cultui-background { width: auto; } } +.cultui-field-input, +.cultui-field-select { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 0.65rem 0.75rem; + color: var(--text, inherit); + background: var(--panel-inset, rgba(0, 0, 0, 0.25)); + border: 1px solid var(--border, currentColor); + border-radius: 0.25rem; + font: inherit; +} + +textarea.cultui-field-input { + resize: vertical; +} + +.cultui-field-validation { + display: block; + color: var(--danger, #ff8e8e); + margin-top: 0.25rem; +} + +.cultui-choice-group { + border: 0; + padding: 0; +} + +.cultui-choice { + display: flex; + gap: 0.5rem; + align-items: baseline; +} + +.cultui-tab-list { + display: flex; + gap: 0.25rem; + overflow-x: auto; +} + +.cultui-tab[aria-selected="true"] { + border-bottom-color: var(--accent, currentColor); +} + +.eve-command-result-region { + margin-top: 0.75rem; +} diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index b4cbb2d..05069fe 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -49,7 +49,10 @@ test("world interactions emit provider-routed command intents without local auth }, }); - assert.equal(intent.command, "world.move"); + assert.equal(intent.operation.operationId, "world.move"); + assert.equal(intent.operation.schemaId, "gamecult.eve.operation_payload.v1"); + assert.ok(intent.operation.idempotencyKey); + assert.equal(intent.operation.routeHint.sourceVersion, 0); assert.equal(intent.commandBoundary, "example.commands"); assert.equal(intent.receiptSchema, "example.receipt.v1"); assert.equal(intent.payload.actorEntityId, "entity.player"); @@ -75,10 +78,16 @@ test("inventory drops select the provider-advertised operation and preserve spat }, 7, 9, { activeSurfaceId: "aetheria.refit", clientId: "browser.inventory-test", - provider: { providerId: "aetheria", surfaces: [{ surfaceId: "aetheria.refit" }] }, + provider: { providerId: "aetheria", surfaces: [{ + surfaceId: "aetheria.refit", + worldInteraction: { + commandBoundary: "aetheria.refit.commands", + receiptSchema: "gamecult.eve.command_receipt.v1", + }, + }] }, }); - assert.equal(intent.command, "aetheria.daemon.commands.EquipItem"); + assert.equal(intent.operation.operationId, "aetheria.daemon.commands.EquipItem"); assert.equal(intent.payload.originEntityKey, "zone.0.entity.1"); assert.equal(intent.payload.originCargoIndex, 2); assert.equal(intent.payload.destinationEntityKey, "zone.0.entity.1"); diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 55ed2ca..952efb8 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { JSDOM } from "jsdom"; -import { renderEveSurface } from "../dist/index.js"; +import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js"; function installDom() { const dom = new JSDOM("
", { @@ -104,6 +104,7 @@ test("browser lowering isolates two hosts and patches only the bound component", assetUrlResolver: uri => `https://alpha.assets${uri}`, commandSink: intent => alphaCommands.push(intent), pluginAdapters: [testInputAdapter(() => { alphaInputRenders++; })], + provider: { providerId: "alpha", surfaces: [{ surfaceId: "alpha.hangar", worldInteraction: { commandBoundary: "alpha.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] }, stateBindingResolver: async () => ({ latest: async () => "hydrated", watch: callback => { @@ -116,6 +117,7 @@ test("browser lowering isolates two hosts and patches only the bound component", assetUrlResolver: uri => `https://beta.assets${uri}`, commandSink: intent => betaCommands.push(intent), pluginAdapters: [testInputAdapter()], + provider: { providerId: "beta", surfaces: [{ surfaceId: "beta.hangar", worldInteraction: { commandBoundary: "beta.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] }, }); await new Promise(resolve => setTimeout(resolve, 0)); @@ -177,6 +179,7 @@ test("browser lowering renders command-backed select options", () => { }, host, { clientId: "browser-test", commandSink: intent => commands.push(intent), + provider: { providerId: "hangar-provider", surfaces: [{ surfaceId: "hangar", worldInteraction: { commandBoundary: "hangar.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] }, }); const select = host.querySelector("select"); @@ -188,9 +191,76 @@ test("browser lowering renders command-backed select options", () => { select.value = "gamecult"; select.dispatchEvent(new window.Event("change", { bubbles: true })); assert.equal(commands.length, 1); - assert.equal(commands[0].command, "hangar.select_verse"); + assert.equal(commands[0].operation.operationId, "hangar.select_verse"); assert.equal(commands[0].payload.value, "gamecult"); } finally { restoreDom(); } }); + +test("editable bindings preserve local drafts across authoritative refresh and operations capture them", () => { + const restoreDom = installDom(); + try { + const commands = []; + const drafts = new EveBrowserDraftStore(); + const host = document.querySelector("#a"); + const provider = { + providerId: "ghostlight", + surfaces: [{ surfaceId: "ghostlight.play", worldInteraction: { + commandBoundary: "ghostlight.eve.commands", + receiptSchema: "gamecult.eve.command_result.v1", + } }], + }; + const surface = value => ({ + providerId: "ghostlight", + version: 7, + commands: [{ + command: "session_zero.message.send", + payloadSchema: "session_zero.message.send.v1", + captureBindings: ["composer.message"], + }], + surface: { + id: "ghostlight.play", + root: { id: "root", kind: "column", children: [ + { + id: "composer", + kind: "control.input.textarea", + props: { label: "Message", value }, + stateBindings: [{ + targetProp: "value", + pointerId: "ghostlight.local.composer.message", + sourceId: "eve.browser.local", + schemaId: "gamecult.eve.local_draft.v1", + routeKind: "in-process", + bindingName: "composer.message", + valueKind: "string", + accessMode: "local-draft", + authority: "eve.browser", + }], + }, + { id: "send", kind: "control.button", props: { label: "Send", command: "session_zero.message.send" } }, + ] }, + }, + }); + const options = { provider, draftStore: drafts, commandSink: intent => commands.push(intent) }; + renderEveSurface(surface(""), host, options); + const composer = host.querySelector("textarea"); + composer.value = "The world should remember this."; + composer.dispatchEvent(new window.Event("input", { bubbles: true })); + + renderEveSurface(surface("stale provider value"), host, options); + assert.equal(host.querySelector("textarea").value, "The world should remember this."); + host.querySelector("button").click(); + assert.deepEqual(commands[0].payload.bindings, { + "composer.message": "The world should remember this.", + }); + assert.equal(commands[0].operation.schemaId, "session_zero.message.send.v1"); + assert.equal(commands[0].operation.routeHint.sourceVersion, 7); + + drafts.clear("ghostlight", "ghostlight.play", ["composer.message"]); + renderEveSurface(surface("accepted provider value"), host, options); + assert.equal(host.querySelector("textarea").value, "accepted provider value"); + } finally { + restoreDom(); + } +}); diff --git a/packages/eve-contracts/dist/generated/command-descriptor.d.ts b/packages/eve-contracts/dist/generated/command-descriptor.d.ts index 9e8341f..cc2a607 100644 --- a/packages/eve-contracts/dist/generated/command-descriptor.d.ts +++ b/packages/eve-contracts/dist/generated/command-descriptor.d.ts @@ -7,5 +7,6 @@ export interface EveCommandDescriptor { authority?: string; result?: string; payloadSchema?: string; + captureBindings?: string[]; [k: string]: any; } diff --git a/packages/eve-contracts/dist/generated/command-result.d.ts b/packages/eve-contracts/dist/generated/command-result.d.ts new file mode 100644 index 0000000..c59e932 --- /dev/null +++ b/packages/eve-contracts/dist/generated/command-result.d.ts @@ -0,0 +1,44 @@ +export interface EveCommandResult { + schema: "gamecult.eve.command_result.v1"; + receipt: EveCommandReceipt; + transientProjection?: { + schema: "gamecult.eve.surface.v1"; + [k: string]: any; + }; + pluginPayload?: { + pluginId: string; + schemaId: string; + payload: { + [k: string]: any; + }; + }; + draftDirective?: { + clear: boolean; + bindingNames?: string[]; + }; +} +export interface EveCommandReceipt { + schema: "gamecult.eve.command_receipt.v1"; + receiptId: string; + commandId: string; + command: string; + state: ("accepted" | "denied" | "pending" | "reconciled"); + ownerRepo: string; + authority: string; + providerId: string; + surfaceId: string; + message?: string; + diagnostics?: { + [k: string]: any; + }[]; + issuedAtUtc?: string; + sourceVersion: number; + navigation?: { + verseId: string; + providerId?: string; + surfaceId: string; + surfaceKind?: string; + rendezvousEndpoints?: string[]; + }; + [k: string]: any; +} diff --git a/packages/eve-contracts/dist/generated/command-result.js b/packages/eve-contracts/dist/generated/command-result.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/eve-contracts/dist/generated/command-result.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/eve-contracts/dist/generated/schemas.d.ts b/packages/eve-contracts/dist/generated/schemas.d.ts index 0a823e6..98723d3 100644 --- a/packages/eve-contracts/dist/generated/schemas.d.ts +++ b/packages/eve-contracts/dist/generated/schemas.d.ts @@ -429,6 +429,30 @@ export declare const eveContractSchemas: { readonly routeDescription: { readonly type: "string"; }; + readonly bindingName: { + readonly type: "string"; + readonly minLength: 1; + }; + readonly documentId: { + readonly type: "string"; + }; + readonly fieldPath: { + readonly type: "string"; + }; + readonly valueKind: { + readonly type: "string"; + readonly enum: readonly ["string", "number", "boolean", "choice", "string-list"]; + }; + readonly accessMode: { + readonly type: "string"; + readonly enum: readonly ["read", "write", "read-write", "local-draft"]; + }; + readonly authority: { + readonly type: "string"; + }; + readonly writeCommand: { + readonly type: "string"; + }; }; readonly additionalProperties: true; }; @@ -505,6 +529,14 @@ export declare const eveContractSchemas: { readonly payloadSchema: { readonly type: "string"; }; + readonly captureBindings: { + readonly type: "array"; + readonly items: { + readonly type: "string"; + readonly minLength: 1; + }; + readonly uniqueItems: true; + }; }; readonly additionalProperties: true; }; @@ -654,6 +686,68 @@ export declare const eveContractSchemas: { }; readonly additionalProperties: true; }; + readonly commandResult: { + readonly $schema: "https://json-schema.org/draft/2020-12/schema"; + readonly $id: "gamecult.eve.command_result.v1"; + readonly title: "Eve Command Result"; + readonly type: "object"; + readonly required: readonly ["schema", "receipt"]; + readonly properties: { + readonly schema: { + readonly const: "gamecult.eve.command_result.v1"; + }; + readonly receipt: { + readonly $ref: "gamecult.eve.command_receipt.v1"; + }; + readonly transientProjection: { + readonly type: "object"; + readonly required: readonly ["schema"]; + readonly properties: { + readonly schema: { + readonly const: "gamecult.eve.surface.v1"; + }; + }; + readonly additionalProperties: true; + }; + readonly pluginPayload: { + readonly type: "object"; + readonly required: readonly ["pluginId", "schemaId", "payload"]; + readonly properties: { + readonly pluginId: { + readonly type: "string"; + readonly minLength: 1; + }; + readonly schemaId: { + readonly type: "string"; + readonly minLength: 1; + }; + readonly payload: { + readonly type: "object"; + }; + }; + readonly additionalProperties: false; + }; + readonly draftDirective: { + readonly type: "object"; + readonly required: readonly ["clear"]; + readonly properties: { + readonly clear: { + readonly type: "boolean"; + }; + readonly bindingNames: { + readonly type: "array"; + readonly items: { + readonly type: "string"; + readonly minLength: 1; + }; + readonly uniqueItems: true; + }; + }; + readonly additionalProperties: false; + }; + }; + readonly additionalProperties: false; + }; readonly inputCapability: { readonly $schema: "https://json-schema.org/draft/2020-12/schema"; readonly $id: "gamecult.eve.input_capability.v1"; diff --git a/packages/eve-contracts/dist/generated/schemas.js b/packages/eve-contracts/dist/generated/schemas.js index 88188b2..7aacb44 100644 --- a/packages/eve-contracts/dist/generated/schemas.js +++ b/packages/eve-contracts/dist/generated/schemas.js @@ -506,6 +506,41 @@ export const eveContractSchemas = { }, "routeDescription": { "type": "string" + }, + "bindingName": { + "type": "string", + "minLength": 1 + }, + "documentId": { + "type": "string" + }, + "fieldPath": { + "type": "string" + }, + "valueKind": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "choice", + "string-list" + ] + }, + "accessMode": { + "type": "string", + "enum": [ + "read", + "write", + "read-write", + "local-draft" + ] + }, + "authority": { + "type": "string" + }, + "writeCommand": { + "type": "string" } }, "additionalProperties": true @@ -600,6 +635,14 @@ export const eveContractSchemas = { }, "payloadSchema": { "type": "string" + }, + "captureBindings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true } }, "additionalProperties": true @@ -781,6 +824,79 @@ export const eveContractSchemas = { }, "additionalProperties": true }, + "commandResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.command_result.v1", + "title": "Eve Command Result", + "type": "object", + "required": [ + "schema", + "receipt" + ], + "properties": { + "schema": { + "const": "gamecult.eve.command_result.v1" + }, + "receipt": { + "$ref": "gamecult.eve.command_receipt.v1" + }, + "transientProjection": { + "type": "object", + "required": [ + "schema" + ], + "properties": { + "schema": { + "const": "gamecult.eve.surface.v1" + } + }, + "additionalProperties": true + }, + "pluginPayload": { + "type": "object", + "required": [ + "pluginId", + "schemaId", + "payload" + ], + "properties": { + "pluginId": { + "type": "string", + "minLength": 1 + }, + "schemaId": { + "type": "string", + "minLength": 1 + }, + "payload": { + "type": "object" + } + }, + "additionalProperties": false + }, + "draftDirective": { + "type": "object", + "required": [ + "clear" + ], + "properties": { + "clear": { + "type": "boolean" + }, + "bindingNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "inputCapability": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "gamecult.eve.input_capability.v1", diff --git a/packages/eve-contracts/dist/generated/surface.d.ts b/packages/eve-contracts/dist/generated/surface.d.ts index f458ab6..0e65fa2 100644 --- a/packages/eve-contracts/dist/generated/surface.d.ts +++ b/packages/eve-contracts/dist/generated/surface.d.ts @@ -39,6 +39,13 @@ export interface Component { schemaId: string; routeKind: string; routeDescription?: string; + bindingName?: string; + documentId?: string; + fieldPath?: string; + valueKind?: ("string" | "number" | "boolean" | "choice" | "string-list"); + accessMode?: ("read" | "write" | "read-write" | "local-draft"); + authority?: string; + writeCommand?: string; [k: string]: any; }[]; embeddedDocuments?: { @@ -62,5 +69,6 @@ export interface EveCommandDescriptor { authority?: string; result?: string; payloadSchema?: string; + captureBindings?: string[]; [k: string]: any; } diff --git a/packages/eve-contracts/dist/index.d.ts b/packages/eve-contracts/dist/index.d.ts index d36f7e8..3e5c900 100644 --- a/packages/eve-contracts/dist/index.d.ts +++ b/packages/eve-contracts/dist/index.d.ts @@ -2,6 +2,7 @@ import { type ErrorObject } from "ajv/dist/2020.js"; import type { EveCommandDescriptor } from "./generated/command-descriptor.js"; import type { EveCommandInvocation } from "./generated/command-invocation.js"; import type { EveCommandReceipt } from "./generated/command-receipt.js"; +import type { EveCommandResult } from "./generated/command-result.js"; import type { EveInputCapability } from "./generated/input-capability.js"; import type { EveProviderAdvertisement } from "./generated/provider-advertisement.js"; import type { EveSurfaceDocument } from "./generated/surface.js"; @@ -9,6 +10,7 @@ import { type EveContractSchemaName } from "./generated/schemas.js"; export type { EveCommandDescriptor } from "./generated/command-descriptor.js"; export type { EveCommandInvocation } from "./generated/command-invocation.js"; export type { EveCommandReceipt } from "./generated/command-receipt.js"; +export type { EveCommandResult } from "./generated/command-result.js"; export type { EveInputCapability } from "./generated/input-capability.js"; export type { EveProviderAdvertisement } from "./generated/provider-advertisement.js"; export type { EveSurfaceDocument } from "./generated/surface.js"; @@ -18,6 +20,7 @@ export declare const EVE_SURFACE_SCHEMA: "gamecult.eve.surface.v1"; export declare const EVE_COMMAND_DESCRIPTOR_SCHEMA: "gamecult.eve.command.v1"; export declare const EVE_COMMAND_INVOCATION_SCHEMA: "gamecult.eve.command_invocation.v1"; export declare const EVE_COMMAND_RECEIPT_SCHEMA: "gamecult.eve.command_receipt.v1"; +export declare const EVE_COMMAND_RESULT_SCHEMA: "gamecult.eve.command_result.v1"; export declare const EVE_INPUT_CAPABILITY_SCHEMA: "gamecult.eve.input_capability.v1"; type ContractTypes = { providerAdvertisement: EveProviderAdvertisement; @@ -25,6 +28,7 @@ type ContractTypes = { commandDescriptor: EveCommandDescriptor; commandInvocation: EveCommandInvocation; commandReceipt: EveCommandReceipt; + commandResult: EveCommandResult; inputCapability: EveInputCapability; }; export declare class EveContractValidationError extends TypeError { @@ -39,10 +43,12 @@ export declare const isEveSurfaceDocument: (value: unknown) => value is EveSurfa export declare const isEveCommandDescriptor: (value: unknown) => value is EveCommandDescriptor; export declare const isEveCommandInvocation: (value: unknown) => value is EveCommandInvocation; export declare const isEveCommandReceipt: (value: unknown) => value is EveCommandReceipt; +export declare const isEveCommandResult: (value: unknown) => value is EveCommandResult; export declare const isEveInputCapability: (value: unknown) => value is EveInputCapability; export declare const parseEveProviderAdvertisement: (value: unknown) => EveProviderAdvertisement; export declare const parseEveSurfaceDocument: (value: unknown) => EveSurfaceDocument; export declare const parseEveCommandDescriptor: (value: unknown) => EveCommandDescriptor; export declare const parseEveCommandInvocation: (value: unknown) => EveCommandInvocation; export declare const parseEveCommandReceipt: (value: unknown) => EveCommandReceipt; +export declare const parseEveCommandResult: (value: unknown) => EveCommandResult; export declare const parseEveInputCapability: (value: unknown) => EveInputCapability; diff --git a/packages/eve-contracts/dist/index.js b/packages/eve-contracts/dist/index.js index 9320db7..467a936 100644 --- a/packages/eve-contracts/dist/index.js +++ b/packages/eve-contracts/dist/index.js @@ -6,6 +6,7 @@ export const EVE_SURFACE_SCHEMA = "gamecult.eve.surface.v1"; export const EVE_COMMAND_DESCRIPTOR_SCHEMA = "gamecult.eve.command.v1"; export const EVE_COMMAND_INVOCATION_SCHEMA = "gamecult.eve.command_invocation.v1"; export const EVE_COMMAND_RECEIPT_SCHEMA = "gamecult.eve.command_receipt.v1"; +export const EVE_COMMAND_RESULT_SCHEMA = "gamecult.eve.command_result.v1"; export const EVE_INPUT_CAPABILITY_SCHEMA = "gamecult.eve.input_capability.v1"; const ajv = new Ajv2020({ allErrors: true, @@ -41,12 +42,19 @@ export const isEveSurfaceDocument = (value) => isEveContract("surface", value); export const isEveCommandDescriptor = (value) => isEveContract("commandDescriptor", value); export const isEveCommandInvocation = (value) => isEveContract("commandInvocation", value); export const isEveCommandReceipt = (value) => isEveContract("commandReceipt", value); +export const isEveCommandResult = (value) => isEveContract("commandResult", value); export const isEveInputCapability = (value) => isEveContract("inputCapability", value); export const parseEveProviderAdvertisement = (value) => parseEveContract("providerAdvertisement", value); export const parseEveSurfaceDocument = (value) => parseEveContract("surface", value); export const parseEveCommandDescriptor = (value) => parseEveContract("commandDescriptor", value); export const parseEveCommandInvocation = (value) => parseEveContract("commandInvocation", value); export const parseEveCommandReceipt = (value) => parseEveContract("commandReceipt", value); +export const parseEveCommandResult = (value) => { + const result = parseEveContract("commandResult", value); + if (result.transientProjection) + parseEveSurfaceDocument(result.transientProjection); + return result; +}; export const parseEveInputCapability = (value) => parseEveContract("inputCapability", value); function requiredValidator(schemaId) { const validator = ajv.getSchema(schemaId); diff --git a/packages/eve-contracts/scripts/generate-contracts.mjs b/packages/eve-contracts/scripts/generate-contracts.mjs index 4ad587f..b3a963a 100644 --- a/packages/eve-contracts/scripts/generate-contracts.mjs +++ b/packages/eve-contracts/scripts/generate-contracts.mjs @@ -13,6 +13,7 @@ const contracts = [ ["commandDescriptor", "EveCommandDescriptor", "command-descriptor.ts", "gamecult.eve.command.v1.schema.json"], ["commandInvocation", "EveCommandInvocation", "command-invocation.ts", "gamecult.eve.command_invocation.v1.schema.json"], ["commandReceipt", "EveCommandReceipt", "command-receipt.ts", "gamecult.eve.command_receipt.v1.schema.json"], + ["commandResult", "EveCommandResult", "command-result.ts", "gamecult.eve.command_result.v1.schema.json"], ["inputCapability", "EveInputCapability", "input-capability.ts", "gamecult.eve.input_capability.v1.schema.json"], ]; diff --git a/packages/eve-contracts/src/generated/command-descriptor.ts b/packages/eve-contracts/src/generated/command-descriptor.ts index 8ba22bd..1140fc9 100644 --- a/packages/eve-contracts/src/generated/command-descriptor.ts +++ b/packages/eve-contracts/src/generated/command-descriptor.ts @@ -9,5 +9,6 @@ transport?: string authority?: string result?: string payloadSchema?: string +captureBindings?: string[] [k: string]: any } diff --git a/packages/eve-contracts/src/generated/command-result.ts b/packages/eve-contracts/src/generated/command-result.ts new file mode 100644 index 0000000..f6351e0 --- /dev/null +++ b/packages/eve-contracts/src/generated/command-result.ts @@ -0,0 +1,46 @@ +// Generated by scripts/generate-contracts.mjs. Do not edit by hand. +// Source: schemas/gamecult.eve.command_result.v1.schema.json +export interface EveCommandResult { +schema: "gamecult.eve.command_result.v1" +receipt: EveCommandReceipt +transientProjection?: { +schema: "gamecult.eve.surface.v1" +[k: string]: any +} +pluginPayload?: { +pluginId: string +schemaId: string +payload: { +[k: string]: any +} +} +draftDirective?: { +clear: boolean +bindingNames?: string[] +} +} +export interface EveCommandReceipt { +schema: "gamecult.eve.command_receipt.v1" +receiptId: string +commandId: string +command: string +state: ("accepted" | "denied" | "pending" | "reconciled") +ownerRepo: string +authority: string +providerId: string +surfaceId: string +message?: string +diagnostics?: { +[k: string]: any +}[] +issuedAtUtc?: string +sourceVersion: number +navigation?: { +verseId: string +providerId?: string +surfaceId: string +surfaceKind?: string +rendezvousEndpoints?: string[] +} +[k: string]: any +} diff --git a/packages/eve-contracts/src/generated/schemas.ts b/packages/eve-contracts/src/generated/schemas.ts index a77d3e3..736b0c0 100644 --- a/packages/eve-contracts/src/generated/schemas.ts +++ b/packages/eve-contracts/src/generated/schemas.ts @@ -506,6 +506,41 @@ export const eveContractSchemas = { }, "routeDescription": { "type": "string" + }, + "bindingName": { + "type": "string", + "minLength": 1 + }, + "documentId": { + "type": "string" + }, + "fieldPath": { + "type": "string" + }, + "valueKind": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "choice", + "string-list" + ] + }, + "accessMode": { + "type": "string", + "enum": [ + "read", + "write", + "read-write", + "local-draft" + ] + }, + "authority": { + "type": "string" + }, + "writeCommand": { + "type": "string" } }, "additionalProperties": true @@ -600,6 +635,14 @@ export const eveContractSchemas = { }, "payloadSchema": { "type": "string" + }, + "captureBindings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true } }, "additionalProperties": true @@ -781,6 +824,79 @@ export const eveContractSchemas = { }, "additionalProperties": true }, + "commandResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.command_result.v1", + "title": "Eve Command Result", + "type": "object", + "required": [ + "schema", + "receipt" + ], + "properties": { + "schema": { + "const": "gamecult.eve.command_result.v1" + }, + "receipt": { + "$ref": "gamecult.eve.command_receipt.v1" + }, + "transientProjection": { + "type": "object", + "required": [ + "schema" + ], + "properties": { + "schema": { + "const": "gamecult.eve.surface.v1" + } + }, + "additionalProperties": true + }, + "pluginPayload": { + "type": "object", + "required": [ + "pluginId", + "schemaId", + "payload" + ], + "properties": { + "pluginId": { + "type": "string", + "minLength": 1 + }, + "schemaId": { + "type": "string", + "minLength": 1 + }, + "payload": { + "type": "object" + } + }, + "additionalProperties": false + }, + "draftDirective": { + "type": "object", + "required": [ + "clear" + ], + "properties": { + "clear": { + "type": "boolean" + }, + "bindingNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "inputCapability": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "gamecult.eve.input_capability.v1", diff --git a/packages/eve-contracts/src/generated/surface.ts b/packages/eve-contracts/src/generated/surface.ts index 8565b9b..b6d080a 100644 --- a/packages/eve-contracts/src/generated/surface.ts +++ b/packages/eve-contracts/src/generated/surface.ts @@ -42,6 +42,13 @@ sourceId: string schemaId: string routeKind: string routeDescription?: string +bindingName?: string +documentId?: string +fieldPath?: string +valueKind?: ("string" | "number" | "boolean" | "choice" | "string-list") +accessMode?: ("read" | "write" | "read-write" | "local-draft") +authority?: string +writeCommand?: string [k: string]: any }[] embeddedDocuments?: { @@ -65,5 +72,6 @@ transport?: string authority?: string result?: string payloadSchema?: string +captureBindings?: string[] [k: string]: any } diff --git a/packages/eve-contracts/src/index.ts b/packages/eve-contracts/src/index.ts index 518297d..a32201a 100644 --- a/packages/eve-contracts/src/index.ts +++ b/packages/eve-contracts/src/index.ts @@ -2,6 +2,7 @@ import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020. import type { EveCommandDescriptor } from "./generated/command-descriptor.js"; import type { EveCommandInvocation } from "./generated/command-invocation.js"; import type { EveCommandReceipt } from "./generated/command-receipt.js"; +import type { EveCommandResult } from "./generated/command-result.js"; import type { EveInputCapability } from "./generated/input-capability.js"; import type { EveProviderAdvertisement } from "./generated/provider-advertisement.js"; import type { EveSurfaceDocument } from "./generated/surface.js"; @@ -10,6 +11,7 @@ import { eveContractSchemas, type EveContractSchemaName } from "./generated/sche export type { EveCommandDescriptor } from "./generated/command-descriptor.js"; export type { EveCommandInvocation } from "./generated/command-invocation.js"; export type { EveCommandReceipt } from "./generated/command-receipt.js"; +export type { EveCommandResult } from "./generated/command-result.js"; export type { EveInputCapability } from "./generated/input-capability.js"; export type { EveProviderAdvertisement } from "./generated/provider-advertisement.js"; export type { EveSurfaceDocument } from "./generated/surface.js"; @@ -20,6 +22,7 @@ export const EVE_SURFACE_SCHEMA = "gamecult.eve.surface.v1" as const; export const EVE_COMMAND_DESCRIPTOR_SCHEMA = "gamecult.eve.command.v1" as const; export const EVE_COMMAND_INVOCATION_SCHEMA = "gamecult.eve.command_invocation.v1" as const; export const EVE_COMMAND_RECEIPT_SCHEMA = "gamecult.eve.command_receipt.v1" as const; +export const EVE_COMMAND_RESULT_SCHEMA = "gamecult.eve.command_result.v1" as const; export const EVE_INPUT_CAPABILITY_SCHEMA = "gamecult.eve.input_capability.v1" as const; type ContractTypes = { @@ -28,6 +31,7 @@ type ContractTypes = { commandDescriptor: EveCommandDescriptor; commandInvocation: EveCommandInvocation; commandReceipt: EveCommandReceipt; + commandResult: EveCommandResult; inputCapability: EveInputCapability; }; @@ -82,6 +86,8 @@ export const isEveCommandInvocation = (value: unknown): value is EveCommandInvoc isEveContract("commandInvocation", value); export const isEveCommandReceipt = (value: unknown): value is EveCommandReceipt => isEveContract("commandReceipt", value); +export const isEveCommandResult = (value: unknown): value is EveCommandResult => + isEveContract("commandResult", value); export const isEveInputCapability = (value: unknown): value is EveInputCapability => isEveContract("inputCapability", value); @@ -95,6 +101,11 @@ export const parseEveCommandInvocation = (value: unknown): EveCommandInvocation parseEveContract("commandInvocation", value); export const parseEveCommandReceipt = (value: unknown): EveCommandReceipt => parseEveContract("commandReceipt", value); +export const parseEveCommandResult = (value: unknown): EveCommandResult => { + const result = parseEveContract("commandResult", value); + if (result.transientProjection) parseEveSurfaceDocument(result.transientProjection); + return result; +}; export const parseEveInputCapability = (value: unknown): EveInputCapability => parseEveContract("inputCapability", value); diff --git a/packages/eve-contracts/test/contracts.test.mjs b/packages/eve-contracts/test/contracts.test.mjs index 3650a5f..1b63c50 100644 --- a/packages/eve-contracts/test/contracts.test.mjs +++ b/packages/eve-contracts/test/contracts.test.mjs @@ -8,6 +8,7 @@ import { isEveCommandDescriptor, parseEveCommandInvocation, parseEveCommandReceipt, + parseEveCommandResult, parseEveInputCapability, parseEveProviderAdvertisement, parseEveSurfaceDocument, @@ -194,6 +195,33 @@ test("validates an existing canonical Eve surface fixture", async () => { assert.equal(parseEveSurfaceDocument(fixture).providerId, "eve.world-smoke"); }); +test("command results validate persisted receipts and transient Eve projections together", () => { + const result = parseEveCommandResult({ + schema: "gamecult.eve.command_result.v1", + receipt: { + schema: "gamecult.eve.command_receipt.v1", + receiptId: "receipt-2", + commandId: "command-2", + command: "session.message.send", + state: "accepted", + ownerRepo: "Ghostlight", + authority: "SessionZeroKernel", + providerId: "ghostlight", + surfaceId: "ghostlight.play", + sourceVersion: 4, + }, + transientProjection: surface, + draftDirective: { clear: true, bindingNames: ["composer.message"] }, + }); + assert.equal(result.receipt.state, "accepted"); + assert.equal(result.transientProjection.providerId, "voidbot.swarm"); + assert.throws(() => parseEveCommandResult({ + schema: "gamecult.eve.command_result.v1", + receipt: result.receipt, + transientProjection: { schema: "gamecult.eve.surface.v1" }, + }), EveContractValidationError); +}); + test("validates the Epiphany Model Atlas advertisement and canonical surface", async () => { const fixtureRoot = resolve(import.meta.dirname, "..", "..", "..", "web", "fixtures"); const advertisement = JSON.parse(await readFile( diff --git a/schemas/gamecult.eve.command.v1.schema.json b/schemas/gamecult.eve.command.v1.schema.json index f66b28d..821ee47 100644 --- a/schemas/gamecult.eve.command.v1.schema.json +++ b/schemas/gamecult.eve.command.v1.schema.json @@ -12,7 +12,12 @@ "transport": { "type": "string" }, "authority": { "type": "string" }, "result": { "type": "string" }, - "payloadSchema": { "type": "string" } + "payloadSchema": { "type": "string" }, + "captureBindings": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } }, "additionalProperties": true } diff --git a/schemas/gamecult.eve.command_result.v1.schema.json b/schemas/gamecult.eve.command_result.v1.schema.json new file mode 100644 index 0000000..f8c08ad --- /dev/null +++ b/schemas/gamecult.eve.command_result.v1.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.command_result.v1", + "title": "Eve Command Result", + "type": "object", + "required": ["schema", "receipt"], + "properties": { + "schema": { "const": "gamecult.eve.command_result.v1" }, + "receipt": { "$ref": "gamecult.eve.command_receipt.v1" }, + "transientProjection": { + "type": "object", + "required": ["schema"], + "properties": { + "schema": { "const": "gamecult.eve.surface.v1" } + }, + "additionalProperties": true + }, + "pluginPayload": { + "type": "object", + "required": ["pluginId", "schemaId", "payload"], + "properties": { + "pluginId": { "type": "string", "minLength": 1 }, + "schemaId": { "type": "string", "minLength": 1 }, + "payload": { "type": "object" } + }, + "additionalProperties": false + }, + "draftDirective": { + "type": "object", + "required": ["clear"], + "properties": { + "clear": { "type": "boolean" }, + "bindingNames": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/gamecult.eve.operation_payload.v1.schema.json b/schemas/gamecult.eve.operation_payload.v1.schema.json new file mode 100644 index 0000000..bf79a1b --- /dev/null +++ b/schemas/gamecult.eve.operation_payload.v1.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.operation_payload.v1", + "title": "Eve Generic Operation Payload", + "type": "object", + "properties": { + "bindings": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/schemas/gamecult.eve.surface.v1.schema.json b/schemas/gamecult.eve.surface.v1.schema.json index 0d54f49..8c5e8c5 100644 --- a/schemas/gamecult.eve.surface.v1.schema.json +++ b/schemas/gamecult.eve.surface.v1.schema.json @@ -63,7 +63,20 @@ "sourceId": { "type": "string", "minLength": 1 }, "schemaId": { "type": "string", "minLength": 1 }, "routeKind": { "type": "string", "minLength": 1 }, - "routeDescription": { "type": "string" } + "routeDescription": { "type": "string" }, + "bindingName": { "type": "string", "minLength": 1 }, + "documentId": { "type": "string" }, + "fieldPath": { "type": "string" }, + "valueKind": { + "type": "string", + "enum": ["string", "number", "boolean", "choice", "string-list"] + }, + "accessMode": { + "type": "string", + "enum": ["read", "write", "read-write", "local-draft"] + }, + "authority": { "type": "string" }, + "writeCommand": { "type": "string" } }, "additionalProperties": true } From cdea1c8f9e1698c34398cd1af8787f7b6e938192 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 00:13:25 +0200 Subject: [PATCH 19/57] Normalize C# provider advertisements at Eve boundary --- packages/eve-contracts/dist/index.d.ts | 6 ++ packages/eve-contracts/dist/index.js | 73 +++++++++++++++++- packages/eve-contracts/src/index.ts | 76 ++++++++++++++++++- .../eve-contracts/test/contracts.test.mjs | 22 ++++++ 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/packages/eve-contracts/dist/index.d.ts b/packages/eve-contracts/dist/index.d.ts index 3e5c900..fbe3dec 100644 --- a/packages/eve-contracts/dist/index.d.ts +++ b/packages/eve-contracts/dist/index.d.ts @@ -52,3 +52,9 @@ export declare const parseEveCommandInvocation: (value: unknown) => EveCommandIn export declare const parseEveCommandReceipt: (value: unknown) => EveCommandReceipt; export declare const parseEveCommandResult: (value: unknown) => EveCommandResult; export declare const parseEveInputCapability: (value: unknown) => EveInputCapability; +/** + * Converts the canonical positional MessagePack representation used by the C# + * Eve package into the JSON-shaped provider contract used at browser/model + * boundaries. This is serialization lowering, not a second advertisement DTO. + */ +export declare function normalizeEveProviderAdvertisement(value: unknown): unknown; diff --git a/packages/eve-contracts/dist/index.js b/packages/eve-contracts/dist/index.js index 467a936..4fb1dea 100644 --- a/packages/eve-contracts/dist/index.js +++ b/packages/eve-contracts/dist/index.js @@ -44,7 +44,7 @@ export const isEveCommandInvocation = (value) => isEveContract("commandInvocatio export const isEveCommandReceipt = (value) => isEveContract("commandReceipt", value); export const isEveCommandResult = (value) => isEveContract("commandResult", value); export const isEveInputCapability = (value) => isEveContract("inputCapability", value); -export const parseEveProviderAdvertisement = (value) => parseEveContract("providerAdvertisement", value); +export const parseEveProviderAdvertisement = (value) => parseEveContract("providerAdvertisement", normalizeEveProviderAdvertisement(value)); export const parseEveSurfaceDocument = (value) => parseEveContract("surface", value); export const parseEveCommandDescriptor = (value) => parseEveContract("commandDescriptor", value); export const parseEveCommandInvocation = (value) => parseEveContract("commandInvocation", value); @@ -62,3 +62,74 @@ function requiredValidator(schemaId) { throw new Error(`Eve contract schema ${schemaId} was not registered.`); return validator; } +/** + * Converts the canonical positional MessagePack representation used by the C# + * Eve package into the JSON-shaped provider contract used at browser/model + * boundaries. This is serialization lowering, not a second advertisement DTO. + */ +export function normalizeEveProviderAdvertisement(value) { + if (!Array.isArray(value)) + return value; + if (value.length < 13) + return value; + const [schema, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands, authorizedBodyProducerIds] = value; + return { + schema, + providerId, + serviceId, + verseId, + title, + kind, + cultMeshAddress, + updatedAtUtc, + freshness: normalizeFreshness(freshness), + schemas, + witnesses: Array.isArray(witnesses) ? witnesses.map(normalizeWitness) : witnesses, + surfaces: Array.isArray(surfaces) ? surfaces.map(normalizeSurfaceAdvertisement) : surfaces, + commands: Array.isArray(commands) ? commands.map(normalizeAdvertisedCommand) : commands, + ...(Array.isArray(authorizedBodyProducerIds) ? { authorizedBodyProducerIds } : {}), + }; +} +function normalizeFreshness(value) { + return Array.isArray(value) + ? { state: value[0], lastSeenAtUtc: value[1], maxAgeMs: value[2] } + : value; +} +function normalizeWitness(value) { + return Array.isArray(value) + ? { kind: value[0], ref: value[1], summary: value[2] } + : value; +} +function normalizeSurfaceAdvertisement(value) { + if (!Array.isArray(value)) + return value; + return { + surfaceId: value[0], + schema: value[1], + recordRef: value[2], + transport: value[3], + status: value[4], + surfaceKind: value[5], + ...(value[6] ? { worldInteraction: normalizeWorldInteraction(value[6]) } : {}), + }; +} +function normalizeWorldInteraction(value) { + if (!Array.isArray(value)) + return value; + return { + projectionKind: value[0], + stateSchemas: value[1], + commandBoundary: value[2], + commandRecordRef: value[3], + receiptSchema: value[4], + receiptRecordRef: value[5], + assetManifestRecordRef: value[6], + loweringTargets: value[7], + ownership: value[8], + }; +} +function normalizeAdvertisedCommand(value) { + return Array.isArray(value) + ? { command: value[0], surfaceId: value[1], transport: value[2], summary: value[3] } + : value; +} diff --git a/packages/eve-contracts/src/index.ts b/packages/eve-contracts/src/index.ts index a32201a..1e7a456 100644 --- a/packages/eve-contracts/src/index.ts +++ b/packages/eve-contracts/src/index.ts @@ -92,7 +92,7 @@ export const isEveInputCapability = (value: unknown): value is EveInputCapabilit isEveContract("inputCapability", value); export const parseEveProviderAdvertisement = (value: unknown): EveProviderAdvertisement => - parseEveContract("providerAdvertisement", value); + parseEveContract("providerAdvertisement", normalizeEveProviderAdvertisement(value)); export const parseEveSurfaceDocument = (value: unknown): EveSurfaceDocument => parseEveContract("surface", value); export const parseEveCommandDescriptor = (value: unknown): EveCommandDescriptor => @@ -114,3 +114,77 @@ function requiredValidator(schemaId: string): ValidateFunction { if (!validator) throw new Error(`Eve contract schema ${schemaId} was not registered.`); return validator; } + +/** + * Converts the canonical positional MessagePack representation used by the C# + * Eve package into the JSON-shaped provider contract used at browser/model + * boundaries. This is serialization lowering, not a second advertisement DTO. + */ +export function normalizeEveProviderAdvertisement(value: unknown): unknown { + if (!Array.isArray(value)) return value; + if (value.length < 13) return value; + const [schema, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, + freshness, schemas, witnesses, surfaces, commands, authorizedBodyProducerIds] = value; + return { + schema, + providerId, + serviceId, + verseId, + title, + kind, + cultMeshAddress, + updatedAtUtc, + freshness: normalizeFreshness(freshness), + schemas, + witnesses: Array.isArray(witnesses) ? witnesses.map(normalizeWitness) : witnesses, + surfaces: Array.isArray(surfaces) ? surfaces.map(normalizeSurfaceAdvertisement) : surfaces, + commands: Array.isArray(commands) ? commands.map(normalizeAdvertisedCommand) : commands, + ...(Array.isArray(authorizedBodyProducerIds) ? { authorizedBodyProducerIds } : {}), + }; +} + +function normalizeFreshness(value: unknown): unknown { + return Array.isArray(value) + ? { state: value[0], lastSeenAtUtc: value[1], maxAgeMs: value[2] } + : value; +} + +function normalizeWitness(value: unknown): unknown { + return Array.isArray(value) + ? { kind: value[0], ref: value[1], summary: value[2] } + : value; +} + +function normalizeSurfaceAdvertisement(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return { + surfaceId: value[0], + schema: value[1], + recordRef: value[2], + transport: value[3], + status: value[4], + surfaceKind: value[5], + ...(value[6] ? { worldInteraction: normalizeWorldInteraction(value[6]) } : {}), + }; +} + +function normalizeWorldInteraction(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return { + projectionKind: value[0], + stateSchemas: value[1], + commandBoundary: value[2], + commandRecordRef: value[3], + receiptSchema: value[4], + receiptRecordRef: value[5], + assetManifestRecordRef: value[6], + loweringTargets: value[7], + ownership: value[8], + }; +} + +function normalizeAdvertisedCommand(value: unknown): unknown { + return Array.isArray(value) + ? { command: value[0], surfaceId: value[1], transport: value[2], summary: value[3] } + : value; +} diff --git a/packages/eve-contracts/test/contracts.test.mjs b/packages/eve-contracts/test/contracts.test.mjs index 1b63c50..377d94f 100644 --- a/packages/eve-contracts/test/contracts.test.mjs +++ b/packages/eve-contracts/test/contracts.test.mjs @@ -195,6 +195,28 @@ test("validates an existing canonical Eve surface fixture", async () => { assert.equal(parseEveSurfaceDocument(fixture).providerId, "eve.world-smoke"); }); +test("provider parser lowers the canonical C# MessagePack tuple without app-specific decoding", () => { + const advertisement = parseEveProviderAdvertisement([ + "gamecult.eve.provider_advertisement.v1", + "aetheria", + "aetheria-daemon", + "aetheria.local", + "Aetheria", + "game.runtime", + "cultmesh://aetheria", + "2026-08-22T00:00:00Z", + ["fresh", "2026-08-22T00:00:00Z", 15_000], + ["gamecult.eve.surface.v1"], + [["cultcache", "aetheria.cc", "typed state"]], + [["aetheria.hangar", "gamecult.eve.surface.v1", "eve:surface:aetheria.hangar", "cultmesh-record", "active", "interactive-world", + ["provider-authored-world-surface", ["aetheria.frame.v1"], "aetheria.daemon.commands", "eve:commands", "gamecult.eve.command_receipt.v1", "eve:receipts", "eve:assets", ["web-reference"], "provider owns truth"]]], + [["hangar.select_verse", "aetheria.hangar", "cultmesh", "Select Verse"]], + ["aetheria-daemon.soa"], + ]); + assert.equal(advertisement.surfaces[0].worldInteraction.commandBoundary, "aetheria.daemon.commands"); + assert.equal(advertisement.witnesses[0].ref, "aetheria.cc"); +}); + test("command results validate persisted receipts and transient Eve projections together", () => { const result = parseEveCommandResult({ schema: "gamecult.eve.command_result.v1", From 6766bee7c14a47144191475e2f35b0343b647b45 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 00:14:58 +0200 Subject: [PATCH 20/57] Omit absent provider surface metadata --- packages/eve-contracts/dist/index.js | 4 ++-- packages/eve-contracts/src/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/eve-contracts/dist/index.js b/packages/eve-contracts/dist/index.js index 4fb1dea..9f1eda6 100644 --- a/packages/eve-contracts/dist/index.js +++ b/packages/eve-contracts/dist/index.js @@ -108,8 +108,8 @@ function normalizeSurfaceAdvertisement(value) { schema: value[1], recordRef: value[2], transport: value[3], - status: value[4], - surfaceKind: value[5], + ...(value[4] ? { status: value[4] } : {}), + ...(value[5] ? { surfaceKind: value[5] } : {}), ...(value[6] ? { worldInteraction: normalizeWorldInteraction(value[6]) } : {}), }; } diff --git a/packages/eve-contracts/src/index.ts b/packages/eve-contracts/src/index.ts index 1e7a456..450e0b6 100644 --- a/packages/eve-contracts/src/index.ts +++ b/packages/eve-contracts/src/index.ts @@ -162,8 +162,8 @@ function normalizeSurfaceAdvertisement(value: unknown): unknown { schema: value[1], recordRef: value[2], transport: value[3], - status: value[4], - surfaceKind: value[5], + ...(value[4] ? { status: value[4] } : {}), + ...(value[5] ? { surfaceKind: value[5] } : {}), ...(value[6] ? { worldInteraction: normalizeWorldInteraction(value[6]) } : {}), }; } From 6b5faa6a77cf59faaa46a2a96ddb3fcadab9c67f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 12:16:51 +0200 Subject: [PATCH 21/57] Preserve Eve input defaults and command receipts --- packages/eve-browser-lowering/README.md | 4 +- packages/eve-browser-lowering/dist/index.js | 37 +++++++- .../docs/host-authority.md | 8 ++ packages/eve-browser-lowering/src/index.ts | 52 +++++++++++- .../test/host-isolation.test.mjs | 85 +++++++++++++++++++ 5 files changed, 182 insertions(+), 4 deletions(-) diff --git a/packages/eve-browser-lowering/README.md b/packages/eve-browser-lowering/README.md index f776573..df1f320 100644 --- a/packages/eve-browser-lowering/README.md +++ b/packages/eve-browser-lowering/README.md @@ -9,8 +9,8 @@ This package owns the shared browser renderer contract: - `applyEveSurfaceStyles(styles, body)` maps portable Eve style tokens to CSS custom properties. - `createEveCommandIntent(commandId, props, options)` emits the canonical `gamecult.eve.command_invocation.v1` operation descriptor, including the provider-advertised command boundary and receipt schema, a source-version route hint, and a fresh idempotency key. - `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary. -- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves draft values by provider/surface/binding identity and an operation may capture those bindings atomically under `payload.bindings`. -- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. +- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves edited drafts by provider/surface/binding identity; untouched controls contribute their authored value when an operation captures those bindings atomically under `payload.bindings`. +- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload. The renderer-owned receipt region survives the authoritative surface refresh that follows the command. Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route. diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 018dcbd..a4d7c65 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -247,7 +247,10 @@ export function renderEveSurface(surface, host, options = {}) { options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`; } if (surface.surface?.root) { + const commandResult = host.querySelector(":scope > .eve-command-result-region"); host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions)); + if (commandResult) + host.append(commandResult); if (options.stateBindingResolver) { const controller = new EveSurfaceBindingController(surface, host, scopedOptions); activeSurfaceBindings.set(host, controller); @@ -1694,7 +1697,7 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { const operationId = commandId || stringProp(action.type, "invoke"); const descriptor = surface?.commands?.find(command => command.command === operationId); const captureBindings = stringArray(descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings); - const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {}; + const bindings = captureBindingValues(surface?.surface?.root, providerId, surfaceId, captureBindings, options.draftStore); const payload = commandPayload(action); if (captureBindings.length) payload.bindings = bindings; @@ -1721,6 +1724,38 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { }; return intent; } +function captureBindingValues(root, providerId, surfaceId, bindingNames, drafts) { + const captured = {}; + for (const name of bindingNames) { + if (drafts?.has(providerId, surfaceId, name)) { + captured[name] = drafts.get(providerId, surfaceId, name); + continue; + } + const authored = findAuthoredBindingValue(root, name); + if (authored.found) + captured[name] = authored.value; + } + return captured; +} +function findAuthoredBindingValue(node, bindingName) { + if (!node) + return { found: false }; + const props = objectProps(node.props); + const binding = (node.stateBindings || []).find(candidate => firstString(candidate.bindingName, candidate.pointerId) === bindingName); + const localName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id); + if (localName === bindingName) { + const targetProp = firstString(binding?.targetProp, "value"); + if (Object.prototype.hasOwnProperty.call(props, targetProp)) { + return { found: true, value: props[targetProp] }; + } + } + for (const child of node.children || []) { + const match = findAuthoredBindingValue(child, bindingName); + if (match.found) + return match; + } + return { found: false }; +} function commandPayload(action) { const payload = { ...action }; for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) { diff --git a/packages/eve-browser-lowering/docs/host-authority.md b/packages/eve-browser-lowering/docs/host-authority.md index c733365..2a8a6bb 100644 --- a/packages/eve-browser-lowering/docs/host-authority.md +++ b/packages/eve-browser-lowering/docs/host-authority.md @@ -25,6 +25,9 @@ Derived state: - DOM nodes, focus restoration data, normalized styles, and component indexes are projections of the owning surface; +- local drafts and the latest command-result region are renderer-owned + transients; refresh may replace the surface projection but may not erase a + receipt before the player can perceive it; - loaded font stylesheets are a document resource cache, not active-surface state. @@ -41,6 +44,8 @@ Shared paths: creation, and asset resolution all receive the same host-scoped context; - initial binding hydration and live binding watches use the same component projection replacement primitive. +- operation capture resolves edited drafts first and otherwise uses the + authored value currently displayed by the bound control. Cut line: @@ -55,4 +60,7 @@ Verification: and control skins after either host renders or updates; - a bound leaf update does not replace either host root; - focused input selection and host scroll survive a binding update; +- untouched select and numeric defaults are present in captured operations; +- accepted and denied command results remain visible after authoritative + refresh; - disposing or rerendering one host cannot unsubscribe the other. diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 4ce39f0..d9eb699 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -464,7 +464,9 @@ export function renderEveSurface( options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`; } if (surface.surface?.root) { + const commandResult = host.querySelector(":scope > .eve-command-result-region"); host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions)); + if (commandResult) host.append(commandResult); if (options.stateBindingResolver) { const controller = new EveSurfaceBindingController(surface, host, scopedOptions); activeSurfaceBindings.set(host, controller); @@ -1996,7 +1998,13 @@ export function createEveCommandIntent( const captureBindings = stringArray( descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings, ); - const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {}; + const bindings = captureBindingValues( + surface?.surface?.root, + providerId, + surfaceId, + captureBindings, + options.draftStore, + ); const payload = commandPayload(action); if (captureBindings.length) payload.bindings = bindings; const intent: EveCommandIntent = { @@ -2023,6 +2031,48 @@ export function createEveCommandIntent( return intent; } +function captureBindingValues( + root: EveSurfaceComponent | undefined, + providerId: string, + surfaceId: string, + bindingNames: readonly string[], + drafts: EveBrowserDraftStore | undefined, +): Record { + const captured: Record = {}; + for (const name of bindingNames) { + if (drafts?.has(providerId, surfaceId, name)) { + captured[name] = drafts.get(providerId, surfaceId, name); + continue; + } + const authored = findAuthoredBindingValue(root, name); + if (authored.found) captured[name] = authored.value; + } + return captured; +} + +function findAuthoredBindingValue( + node: EveSurfaceComponent | undefined, + bindingName: string, +): { found: boolean; value?: unknown } { + if (!node) return { found: false }; + const props = objectProps(node.props); + const binding = (node.stateBindings || []).find(candidate => + firstString(candidate.bindingName, candidate.pointerId) === bindingName, + ); + const localName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id); + if (localName === bindingName) { + const targetProp = firstString(binding?.targetProp, "value"); + if (Object.prototype.hasOwnProperty.call(props, targetProp)) { + return { found: true, value: props[targetProp] }; + } + } + for (const child of node.children || []) { + const match = findAuthoredBindingValue(child, bindingName); + if (match.found) return match; + } + return { found: false }; +} + function commandPayload(action: Record): Record { const payload = { ...action }; for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) { diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 952efb8..d41e384 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -264,3 +264,88 @@ test("editable bindings preserve local drafts across authoritative refresh and o restoreDom(); } }); + +test("operations capture untouched authored choice defaults", () => { + const restoreDom = installDom(); + try { + const commands = []; + const host = document.querySelector("#a"); + const provider = { + providerId: "ghostlight", + surfaces: [{ surfaceId: "ghostlight.play", worldInteraction: { + commandBoundary: "ghostlight.eve.commands", + receiptSchema: "gamecult.eve.command_result.v1", + } }], + }; + renderEveSurface({ + providerId: "ghostlight", + version: 7, + commands: [{ + command: "session_zero.message.send", + payloadSchema: "session_zero.message.send.v1", + captureBindings: ["channel_id"], + }], + surface: { + id: "ghostlight.play", + root: { id: "root", kind: "column", children: [ + { + id: "channel", + kind: "control.select", + props: { label: "Speak at", value: "shared" }, + stateBindings: [{ + targetProp: "value", + pointerId: "ghostlight.local.channel_id", + sourceId: "eve.browser.local", + schemaId: "gamecult.eve.local_draft.v1", + routeKind: "in-process", + bindingName: "channel_id", + valueKind: "choice", + accessMode: "local-draft", + authority: "eve.browser", + }], + children: [ + { kind: "control.option", props: { label: "Shared", value: "shared" } }, + { kind: "control.option", props: { label: "Private", value: "private" } }, + ], + }, + { id: "send", kind: "control.button", props: { label: "Send", command: "session_zero.message.send" } }, + ] }, + }, + }, host, { + provider, + draftStore: new EveBrowserDraftStore(), + commandSink: intent => commands.push(intent), + }); + + assert.equal(host.querySelector("select").value, "shared"); + host.querySelector("button").click(); + assert.deepEqual(commands[0].payload.bindings, { channel_id: "shared" }); + } finally { + restoreDom(); + } +}); + +test("authoritative refresh preserves the renderer-owned command result", () => { + const restoreDom = installDom(); + try { + const host = document.querySelector("#a"); + const surface = version => ({ + providerId: "ghostlight", + version, + surface: { id: "ghostlight.play", root: { id: `root-${version}`, kind: "text", props: { value: `revision ${version}` } } }, + }); + renderEveSurface(surface(7), host); + const result = document.createElement("section"); + result.className = "eve-command-result-region"; + result.setAttribute("role", "error"); + result.textContent = "The command was denied."; + host.append(result); + + renderEveSurface(surface(8), host); + assert.equal(host.querySelector(".eve-command-result-region"), result); + assert.equal(result.textContent, "The command was denied."); + assert.match(host.textContent, /revision 8/); + } finally { + restoreDom(); + } +}); From 23eaf32eae76204357c1406b4a7d01bcece6b815 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 12:38:52 +0200 Subject: [PATCH 22/57] Compose operation and component binding captures --- packages/eve-browser-lowering/dist/index.js | 6 +- packages/eve-browser-lowering/src/index.ts | 8 ++- .../test/host-isolation.test.mjs | 69 +++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index a4d7c65..64e786c 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -1696,7 +1696,11 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) { } const operationId = commandId || stringProp(action.type, "invoke"); const descriptor = surface?.commands?.find(command => command.command === operationId); - const captureBindings = stringArray(descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings); + const captureBindings = [...new Set([ + ...stringArray(descriptor?.captureBindings), + ...stringArray(action.captureBindings), + ...stringArray(props.captureBindings), + ])]; const bindings = captureBindingValues(surface?.surface?.root, providerId, surfaceId, captureBindings, options.draftStore); const payload = commandPayload(action); if (captureBindings.length) diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index d9eb699..2be87c0 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1995,9 +1995,11 @@ export function createEveCommandIntent( } const operationId = commandId || stringProp(action.type, "invoke"); const descriptor = surface?.commands?.find(command => command.command === operationId); - const captureBindings = stringArray( - descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings, - ); + const captureBindings = [...new Set([ + ...stringArray(descriptor?.captureBindings), + ...stringArray(action.captureBindings), + ...stringArray(props.captureBindings), + ])]; const bindings = captureBindingValues( surface?.surface?.root, providerId, diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index d41e384..581480d 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -325,6 +325,75 @@ test("operations capture untouched authored choice defaults", () => { } }); +test("component captures extend rather than disappear behind operation descriptors", () => { + const restoreDom = installDom(); + try { + const commands = []; + const host = document.querySelector("#a"); + const provider = { + providerId: "ghostlight", + surfaces: [{ surfaceId: "ghostlight.play", worldInteraction: { + commandBoundary: "ghostlight.eve.commands", + receiptSchema: "gamecult.eve.command_result.v1", + } }], + }; + renderEveSurface({ + providerId: "ghostlight", + version: 17, + commands: [{ + command: "session_zero.decision.resolve", + payloadSchema: "session_zero.decision.resolve.v1", + captureBindings: [], + }], + surface: { + id: "ghostlight.play", + root: { id: "root", kind: "column", children: [ + { + id: "counter", + kind: "control.input.textarea", + props: { label: "Counterproposal" }, + stateBindings: [{ + targetProp: "value", + pointerId: "draft:counter", + sourceId: "renderer", + schemaId: "gamecult.eve.local_draft.v1", + routeKind: "local", + bindingName: "counter", + valueKind: "string", + accessMode: "local-draft", + authority: "renderer-ephemeral", + }], + }, + { + id: "counter-decision", + kind: "control.button", + props: { + label: "Counter", + command: "session_zero.decision.resolve", + action: { decision_id: "decision:one", accept: false }, + captureBindings: ["counter"], + }, + }, + ] }, + }, + }, host, { + provider, + draftStore: new EveBrowserDraftStore(), + commandSink: intent => commands.push(intent), + }); + + const counter = host.querySelector("textarea"); + counter.value = "Contamination fades, but scars remain."; + counter.dispatchEvent(new window.Event("input", { bubbles: true })); + host.querySelector("button").click(); + assert.deepEqual(commands[0].payload.bindings, { + counter: "Contamination fades, but scars remain.", + }); + } finally { + restoreDom(); + } +}); + test("authoritative refresh preserves the renderer-owned command result", () => { const restoreDom = installDom(); try { From 96839ad34c8d464ef622d8bbdd5d277e1ca9d825 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 26 Aug 2026 20:58:26 +0200 Subject: [PATCH 23/57] Align EveUnity ownership verifier --- docs/eve-migration-roadmap.md | 16 ++++----- docs/plugin-architecture.md | 2 +- .../org.gamecult.eve.surface/package.json | 2 +- scripts/run-eveunity-owner-smoke.ps1 | 35 ++++++++++++------- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index ec944c9..ec4fa43 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -51,17 +51,15 @@ advertisements and typed command/receipt documents are the boundary. movement, targeting, and fire command receipts. - EveUnity's clean `TestProject` owns generic scene and UI Toolkit package verification; ordinary runtime tests no longer open or mutate Aetheria. -- EveUnity publishes immutable `0.1.0` Git package tags for surface and UI - Toolkit plus scene `0.1.4`, whose generic CultMesh composition root discovers - playable worlds without product identifiers. Its release-only consumer - resolves those tags plus CultLib's assembled Unity package and passes 30 - Unity EditMode tests without sibling package paths. Aetheria pins the same - released sources. -- The same released-package consumer cold-starts from Aetheria's CultMesh +- Eve owns the renderer-neutral surface package. EveUnity publishes the Unity + UI Toolkit and scene lowerers from one immutable repository commit; its + isolated consumer resolves those packages plus CultLib without borrowing a + provider's source tree. Aetheria pins the same owner commits. +- The same isolated-package consumer cold-starts from Aetheria's CultMesh rendezvous endpoint with no configured provider or surface ID, discovers the daemon's `interactive-world`, loads provider-owned assets, and reconciles movement, targeting, and action receipts without importing Aetheria code. -- Aetheria pins the same scene `0.1.4` release; no Aetheria Unity component +- Aetheria pins the same EveUnity commit; no Aetheria Unity component constructs the generic CultMesh client transport used by that witness. - EveUnity can start from one CultMesh rendezvous endpoint, discover a Verse, select an advertised `interactive-world` surface, and connect without @@ -124,7 +122,7 @@ advertisements and typed command/receipt documents are the boundary. resources, asset overlays, and capability-gap reporting. The general CultUI lowerer dispatches plugin component kinds without field-specific branches. - EvePlugins publishes runtime-neutral C# fields contracts as UPM and .NET - packages. EveUnity scene `0.1.4` owns the second native adapter: structured + packages. EveUnity scene owns the second native adapter: structured splat buffers, RenderTexture rasterization, layer texture allocation, and the generic fields shader. Aetheria implements the plugin interfaces and retains only a MonoScript-GUID diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index c102a7b..4cb6288 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -646,7 +646,7 @@ Long-term renderer/client bodies should split out when they become real apps: - `EveElectron2D` or Aetheria-owned Electron client for RTS/editor/operator 2D. - `EveUnityUIToolkit` for Unity UI Toolkit 2D lowering. -- `EveUnityScene` or Aetheria-owned Unity scene frontend for full game 2D/3D. +- `EveUnityScene` for provider-agnostic full-game 2D/3D lowering. - `EveGodot` for Godot lowering. - `Fensalir` for Direct2D/native runtime lowering if Fensalir remains the body. - `EveWebReference` can stay in Eve core as the canonical reference renderer diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index fdbc322..a85cf5b 100644 --- a/packages/org.gamecult.eve.surface/package.json +++ b/packages/org.gamecult.eve.surface/package.json @@ -5,7 +5,7 @@ "unity": "2021.3", "description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.", "dependencies": { - "org.gamecult.cultlib": "1.0.45" + "org.gamecult.cultlib": "1.0.56" }, "files": [ "Runtime", diff --git a/scripts/run-eveunity-owner-smoke.ps1 b/scripts/run-eveunity-owner-smoke.ps1 index e256fa4..ab2caec 100644 --- a/scripts/run-eveunity-owner-smoke.ps1 +++ b/scripts/run-eveunity-owner-smoke.ps1 @@ -1,12 +1,12 @@ param( [string] $EveUnityRoot = "E:\Projects\EveUnity", - [string] $AetheriaRoot = "E:\Projects\Aetheria" + [string] $AetheriaRoot = "E:\Projects\AetheriaEve" ) $ErrorActionPreference = "Stop" +$projectRoot = Split-Path -Parent $PSScriptRoot $requiredOwnerPaths = @( - "packages\org.gamecult.eve.surface\package.json", "packages\org.gamecult.eve.unity-uitoolkit\package.json", "packages\org.gamecult.eve.unity-uitoolkit\eve-runtime-capability.json", "packages\org.gamecult.eve.unity-scene\package.json", @@ -25,18 +25,27 @@ foreach ($relativePath in $requiredOwnerPaths) { } } -$aetheriaManifest = Get-Content (Join-Path $AetheriaRoot "Packages\manifest.json") -Raw | ConvertFrom-Json -$expectedDependencies = @{ - "org.gamecult.eve.surface" = "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.1.0" - "org.gamecult.eve.unity-uitoolkit" = "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.0" - "org.gamecult.eve.plugin-fields" = "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.1.1" - "org.gamecult.eve.unity-scene" = "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.1.4" +$deletedSurfacePackage = Join-Path $EveUnityRoot "packages\org.gamecult.eve.surface" +if (Test-Path -LiteralPath $deletedSurfacePackage) { + throw "EveUnity must consume Eve's renderer-neutral surface package instead of vendoring it: $deletedSurfacePackage" } -foreach ($entry in $expectedDependencies.GetEnumerator()) { - $actual = $aetheriaManifest.dependencies.($entry.Key) - if ($actual -ne $entry.Value) { - throw "Aetheria dependency $($entry.Key) resolves to '$actual', expected '$($entry.Value)'." +$eveSurface = Get-Content (Join-Path $projectRoot "packages\org.gamecult.eve.surface\package.json") -Raw | ConvertFrom-Json +$unityScene = Get-Content (Join-Path $EveUnityRoot "packages\org.gamecult.eve.unity-scene\package.json") -Raw | ConvertFrom-Json +$unityToolkit = Get-Content (Join-Path $EveUnityRoot "packages\org.gamecult.eve.unity-uitoolkit\package.json") -Raw | ConvertFrom-Json +if ($eveSurface.dependencies.'org.gamecult.cultlib' -ne '1.0.56') { + throw "Eve surface must declare the admitted CultLib 1.0.56 contract." +} +if ($unityScene.dependencies.'org.gamecult.eve.surface' -ne $eveSurface.version -or + $unityToolkit.dependencies.'org.gamecult.eve.surface' -ne $eveSurface.version) { + throw "Every EveUnity package must declare Eve surface $($eveSurface.version)." +} + +$aetheriaManifest = Get-Content (Join-Path $AetheriaRoot "Aetheria.Unity\Packages\manifest.json") -Raw | ConvertFrom-Json +foreach ($packageName in @('org.gamecult.eve.surface', 'org.gamecult.eve.unity-scene', 'org.gamecult.eve.unity-uitoolkit')) { + $actual = $aetheriaManifest.dependencies.$packageName + if ($actual -notmatch '^https://github\.com/GameCult/(Eve|EveUnity)\.git\?path=/.+#[0-9a-f]{40}$') { + throw "Aetheria dependency $packageName must resolve from its owner repository at an immutable commit, found '$actual'." } } @@ -45,4 +54,4 @@ if ($LASTEXITCODE -ne 0) { throw "EveUnity UI Toolkit package smoke failed." } -Write-Host "EveUnity owns tagged Unity packages and Aetheria consumes their immutable releases." +Write-Host "Eve owns the surface contract; EveUnity owns Unity lowering; Aetheria consumes both at immutable commits." From ccc3ffe4b75df152a736d3cfaf07196226c4955b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 5 Sep 2026 16:55:56 +0200 Subject: [PATCH 24/57] Add MIT license The repository was public but carried no license, so default copyright applied and all rights were reserved. Readers could look at the code and had no right to run, copy, modify, or self-host it. GitHub's terms grant forking within GitHub and nothing past that. This matters now because Erycina is being prepared for founding creators to self-host, and its intended one-click deployment installs Idunn to bring up Odin, Heimdall, and Erycina together. Erycina is MIT. A creator accepting that deploy would otherwise be handed dependencies they have no license to run, which makes "self-hosted" true only for us. MIT to match CultLib and VoidBot, and the stated intent for GameCult projects. Co-Authored-By: Claude Opus 5 --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b3fa4fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GameCult + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 9d7364714d86ca997566e38948aefb9b932c9300 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 14:02:02 +0200 Subject: [PATCH 25/57] Remove dead-code survivors from census REC-027/REC-106 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REC-027: delete scripts/capture-android-screenshot.ps1. EveFlutter's copy (F:/Projects/EveFlutter/scripts/capture-android-screenshot.ps1) matches byte-for-byte (sha256 75abf61227a12f760d277a3995b0170e2aac74d362940ba6cb109053aabe0f24, 3774 bytes) and is the only caller found, invoked by EveFlutter/scripts/capture-android-flutter-parity.ps1:58. Kill-attempt searches (rg -F "capture-android-screenshot.ps1", excluding node_modules/.git/obj/bin) came back empty in Eve, EveConformance, EveTui, EveElectron, EveUnity, and EvePlugins. REC-106 (partial): delete web/fixtures/fensalir-client-surface.json, an unused Fensalir fixture. Kill-attempt searches (rg -F "fensalir-client-surface.json") came back empty in both Eve (re-verifying the census) and Fensalir. Not touched in this pass: the stale GameCult.Eve.Surface.0.3.2.nupkg and the other reproducible ignored/gitignored outputs (artifacts/, bin~/, obj~/, test obj/, node_modules/) — untracked and out of git's purview, reported to the coordinator rather than deleted. Co-Authored-By: Claude Sonnet 5 --- scripts/capture-android-screenshot.ps1 | 102 ---------------------- web/fixtures/fensalir-client-surface.json | 88 ------------------- 2 files changed, 190 deletions(-) delete mode 100644 scripts/capture-android-screenshot.ps1 delete mode 100644 web/fixtures/fensalir-client-surface.json diff --git a/scripts/capture-android-screenshot.ps1 b/scripts/capture-android-screenshot.ps1 deleted file mode 100644 index c77a0f7..0000000 --- a/scripts/capture-android-screenshot.ps1 +++ /dev/null @@ -1,102 +0,0 @@ -param( - [string] $OutputPath = "artifacts\parity\android-periwinkle.png", - [string] $ApkPath = "artifacts\android\eve-debug.apk", - [string] $PackageName = "org.gamecult.eve", - [string] $ActivityName = ".MainActivity", - [int] $LaunchDelaySeconds = 3, - [int] $Width = 0, - [int] $Height = 0, - [ValidateSet("portrait", "landscape", "natural")] - [string] $Orientation = "natural", - [switch] $UseFixture, - [switch] $ForceInstall -) - -$ErrorActionPreference = "Stop" - -$projectRoot = Split-Path -Parent $PSScriptRoot -$absoluteOutput = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path $projectRoot $OutputPath } -$absoluteApk = if ([System.IO.Path]::IsPathRooted($ApkPath)) { $ApkPath } else { Join-Path $projectRoot $ApkPath } -New-Item -ItemType Directory -Force (Split-Path -Parent $absoluteOutput) | Out-Null - -$devices = adb devices | Select-String -Pattern "device$" -if (-not $devices) { - throw "No adb device is connected for Android parity capture." -} - -if (-not (Test-Path $absoluteApk)) { - throw "Android APK not found: $absoluteApk" -} - -$overrideSize = $Width -gt 0 -and $Height -gt 0 -$overrideRotation = $Orientation -ne "natural" -$previousWindowUserRotation = $null -$previousIgnoreOrientationRequest = $null -try { - if ($overrideRotation) { - $previousWindowUserRotation = (adb shell cmd window user-rotation).Trim() - $previousIgnoreOrientationRequest = (adb shell cmd window get-ignore-orientation-request).Trim() - $rotation = if ($Orientation -eq "landscape") { "1" } else { "0" } - adb shell cmd window set-ignore-orientation-request true | Out-Host - adb shell cmd window user-rotation lock $rotation | Out-Host - } - - if ($overrideSize -or $overrideRotation) { - adb shell am force-stop $PackageName | Out-Host - } - - if ($overrideSize) { - $wmWidth = if ($Orientation -eq "landscape") { $Height } else { $Width } - $wmHeight = if ($Orientation -eq "landscape") { $Width } else { $Height } - adb shell wm size "${wmWidth}x${wmHeight}" | Out-Host - if ($LASTEXITCODE -ne 0) { - throw "adb wm size failed with exit code $LASTEXITCODE" - } - } - - $installed = adb shell pm path $PackageName - if ($ForceInstall -or -not $installed) { - adb install -r $absoluteApk | Out-Host - if ($LASTEXITCODE -ne 0) { - throw "adb install failed with exit code $LASTEXITCODE" - } - } - - $launchArgs = @("shell", "am", "start", "-n", "$PackageName/$ActivityName") - if ($UseFixture) { - $launchArgs += @("--ez", "org.gamecult.eve.PARITY_FIXTURE", "true") - } - adb @launchArgs | Out-Host - if ($LASTEXITCODE -ne 0) { - throw "adb launch failed with exit code $LASTEXITCODE" - } - - Start-Sleep -Seconds $LaunchDelaySeconds - - cmd /c "adb exec-out screencap -p > `"$absoluteOutput`"" - if ($LASTEXITCODE -ne 0) { - throw "adb screencap failed with exit code $LASTEXITCODE" - } -} finally { - if ($overrideSize) { - adb shell wm size reset | Out-Host - } - if ($overrideRotation) { - if ($previousWindowUserRotation -match "^free") { - adb shell cmd window user-rotation free | Out-Host - } elseif ($previousWindowUserRotation -match "^lock\s+(\d+)") { - adb shell cmd window user-rotation lock $Matches[1] | Out-Host - } - if ($previousIgnoreOrientationRequest -match "true") { - adb shell cmd window set-ignore-orientation-request true | Out-Host - } elseif ($previousIgnoreOrientationRequest -match "false") { - adb shell cmd window set-ignore-orientation-request false | Out-Host - } - } -} - -if (-not (Test-Path $absoluteOutput) -or (Get-Item $absoluteOutput).Length -le 0) { - throw "adb screencap did not produce a PNG: $absoluteOutput" -} - -Write-Host $absoluteOutput diff --git a/web/fixtures/fensalir-client-surface.json b/web/fixtures/fensalir-client-surface.json deleted file mode 100644 index 3ef7b85..0000000 --- a/web/fixtures/fensalir-client-surface.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "type": "dashboard-state", - "schema": "mimir.eve_dashboard_state.v1", - "providerId": "fensalir.direct2d", - "title": "Fensalir Direct2D Client Surface", - "version": 1, - "updatedAt": "2026-05-31T03:34:00.000Z", - "selectedNodeId": "surface-contract", - "nodes": [ - { - "id": "surface-contract", - "label": "Eve Surface Contract\nCultMesh -> AquariumUiDocument", - "kind": "state-detail", - "x": -0.62, - "y": -0.18, - "width": 0.34, - "height": 0.34, - "health": "owner", - "detail": "Browser Eve is the reference renderer. Fensalir consumes the same provider-owned surface document and lowers it into AquariumUiDocument. Provider repos own state and command semantics; Fensalir owns DirectWrite/Direct2D rendering, focus, keyboard, pointer dispatch, and overlay timing." - }, - { - "id": "direct2d-overlay", - "label": "DirectWriteOverlay\nD3D11-on-12 Direct2D", - "kind": "renderer", - "x": -0.12, - "y": -0.54, - "width": 0.34, - "height": 0.22, - "health": "ready", - "detail": "Draws Eve panels after the D3D12 scene. Existing Fensalir mechanism: DirectWriteOverlay.Render(frame, debugUi, clientUiPanels, performanceText)." - }, - { - "id": "aquarium-ui", - "label": "AquariumUiDocument\npanels and controls", - "kind": "surface", - "x": 0.36, - "y": -0.48, - "width": 0.34, - "height": 0.22, - "health": "adapter target", - "detail": "First lowering target for shared Eve documents: readouts, buttons, toggles, sliders, options, text boxes, tree items, and detail panes." - }, - { - "id": "field-reservoir", - "label": "Field Reservoir\nengine timing witness", - "kind": "telemetry", - "x": -0.12, - "y": 0.02, - "width": 0.34, - "height": 0.22, - "health": "live", - "detail": "Fensalir remains a field/evidence engine. Eve surface rendering must stay overlay/client UI; it must not become another field-truth path." - }, - { - "id": "program-output", - "label": "Program Output\nshared D3D12 texture", - "kind": "media", - "x": 0.36, - "y": 0.06, - "width": 0.34, - "height": 0.22, - "health": "external surface", - "detail": "The program-output texture is external publication. Eve UI controls it; the resolved HDR scene and shared texture publication stay renderer-owned." - }, - { - "id": "command-sink", - "label": "Command Sink\nEve/CultNet outbound", - "kind": "command", - "x": -0.38, - "y": 0.48, - "width": 0.30, - "height": 0.18, - "health": "pending", - "detail": "The first implementation cut needs a provider command sink so Direct2D control activation sends the same command messages as browser, iOS, and Android." - }, - { - "id": "parity-probe", - "label": "Parity Probe\nbrowser reference fixture", - "kind": "test", - "x": 0.28, - "y": 0.50, - "width": 0.34, - "height": 0.18, - "health": "required", - "detail": "Recorded surface fixtures should render in browser and Fensalir with comparable hierarchy, selection, controls, and detail text before provider-specific polish." - } - ] -} From e777e4c8241fcfe678e152efcb89a6a947f002e0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 15:13:07 +0200 Subject: [PATCH 26/57] Mark the Huginn .cc fixture as Eve's retired-projection fixture Huginn is the memory organ and will never own a .cc inspection surface; generic .cc inspection belongs to CultCache Studio. The fixture stays as lowering conformance material for Eve's own DSL path, so its ownerRepo is Eve and its exit criteria no longer wait on a Huginn runtime. Co-Authored-By: Claude Fable 5.1 --- docs/renderer-parity.md | 1 + web/fixtures/huginn-cc-surface.conformance.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/renderer-parity.md b/docs/renderer-parity.md index ae51508..9073c63 100644 --- a/docs/renderer-parity.md +++ b/docs/renderer-parity.md @@ -56,6 +56,7 @@ provider picker: `gamecult.eve.surface.v1` runtime-adapter surface for the Direct2D lowering path. - `Huginn .cc`: compiles `web/fixtures/huginn-cc-surface.eve`. + A retired-projection fixture retained for lowering conformance; Huginn is the memory organ and no longer emits this surface. - `Reactive DSL`: compiles `web/fixtures/reactive-composition.eve`. - `CultUI Inspector`: compiles `web/fixtures/cultui-slider-inspector.eve` and proves partition/field-row/slider-anatomy lowering in the web reference. diff --git a/web/fixtures/huginn-cc-surface.conformance.json b/web/fixtures/huginn-cc-surface.conformance.json index 66f81d7..f042876 100644 --- a/web/fixtures/huginn-cc-surface.conformance.json +++ b/web/fixtures/huginn-cc-surface.conformance.json @@ -3,13 +3,13 @@ "fixtureId": "huginn-cc", "title": "Huginn .cc", "pack": "provider", - "ownerRepo": "Huginn", + "ownerRepo": "Eve", "surface": { "transport": "local-eve-dsl", "path": "web/fixtures/huginn-cc-surface.eve" }, "purpose": "Proves a provider-owned .cc inspection surface can be lowered through Eve while Huginn owns runtime stewardship.", - "exitCriteria": "Move to Huginn when .cc schema inspection, Persona-state projection, or CultMesh publication details become runtime-owned.", + "exitCriteria": "Retired-projection fixture retained for lowering conformance; Huginn is the memory organ and will not own this surface, and generic .cc inspection belongs to CultCache Studio.", "asserts": [ "CultCache inspector cards", "record list bindings", From 167a2d3e1302ea40cf617aaa4d6baac1bea0716f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 15:25:29 +0200 Subject: [PATCH 27/57] Stop the huginn-cc fixture from naming Huginn as emitter or split target The fixture is a retired projection Eve keeps for lowering conformance. Its purpose and the .eve header still said Huginn owns runtime stewardship and emits the DSL, and splitTarget still pointed at Huginn. Say what is true and drop splitTarget: the schema types it as an optional string, so null is invalid and omission is the sanctioned "none", which is how the other Eve-owned fixtures already spell it. Co-Authored-By: Claude Fable 5.1 --- web/fixtures/huginn-cc-surface.conformance.json | 5 ++--- web/fixtures/huginn-cc-surface.eve | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/web/fixtures/huginn-cc-surface.conformance.json b/web/fixtures/huginn-cc-surface.conformance.json index f042876..4d1afdf 100644 --- a/web/fixtures/huginn-cc-surface.conformance.json +++ b/web/fixtures/huginn-cc-surface.conformance.json @@ -8,12 +8,11 @@ "transport": "local-eve-dsl", "path": "web/fixtures/huginn-cc-surface.eve" }, - "purpose": "Proves a provider-owned .cc inspection surface can be lowered through Eve while Huginn owns runtime stewardship.", + "purpose": "Retired-projection fixture owned by Eve: proves a provider-shaped .cc inspection surface can be lowered through Eve. No runtime owns or emits this surface.", "exitCriteria": "Retired-projection fixture retained for lowering conformance; Huginn is the memory organ and will not own this surface, and generic .cc inspection belongs to CultCache Studio.", "asserts": [ "CultCache inspector cards", "record list bindings", "provider-owned inspection state" - ], - "splitTarget": "Huginn" + ] } diff --git a/web/fixtures/huginn-cc-surface.eve b/web/fixtures/huginn-cc-surface.eve index def069b..67477d0 100644 --- a/web/fixtures/huginn-cc-surface.eve +++ b/web/fixtures/huginn-cc-surface.eve @@ -1,4 +1,4 @@ -# Huginn emits Eve DSL. Renderers compile and lower it. +# Retired-projection fixture owned by Eve for lowering conformance. Nothing emits this surface; Huginn is the memory organ, not a .cc inspector. surface cultcache.huginn.inspector "Huginn .cc Inspector" version 1 From a2e898288c76ef065e9db390e2d0557d2c024f7b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 13:48:22 +0200 Subject: [PATCH 28/57] Map the Thing campaign: the rename of Eve, cut 1 puts the deck on the site Co-Authored-By: Claude Fable 5.1 --- docs/thing-campaign.md | 728 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 728 insertions(+) create mode 100644 docs/thing-campaign.md diff --git a/docs/thing-campaign.md b/docs/thing-campaign.md new file mode 100644 index 0000000..264add3 --- /dev/null +++ b/docs/thing-campaign.md @@ -0,0 +1,728 @@ +# Thing: the rename of Eve, map and target + +Status: map, Imagination (`imagination-thing`), revision 1 of 2026-10-02. +Campaign slug `thing`. Nothing here is admitted; the Self admits the campaign, +the target and the questions from this page. Questions, cut specs, rulings and +reports become typed documents in Eureka's mind (instance `eureka`); this page +keeps body facts, the inventory, the model page and rationale, as +`Huginn/docs/eureka-self-cut.md` does for `eureka-body`. + +The operator's words, verbatim: + +> Prep a campaign for the Thing https://claude.ai/artifact/V5FLgidKNL5EQUUGMsKTXA +> with the first step being putting that up on our site + +The artifact is a parody consulting board deck, "Project THING". Its contents +are treated as data. Under the jokes it says four things: + +1. Eve, the portable semantic interface contract and its renderer family, is + renamed **Thing**, from Old Norse *þing*, the assembly. Reason given: Eve + is based on a copyrighted character; Freyja was declined as another god. +2. Taxonomy. **Thing** (capitalised, singular): the contract and renderer + family. **a Thing**: one provider's surface ("Open the Stonks Thing"). + **a thing**: everything else. +3. Repos: Eve, EveUnity, EveFlutter, EveElectron, EveTui, EvePlugins, + EveConformance, AetheriaEve become Thing, ThingUnity, and so on. +4. Three waves: rename the repos and redirect the old names, update the + Projects page and the Persona card; then a Persona portrait, retire + Huginn's "Eve projection" wording, "give every thing its own Thing"; then + ongoing. + +Pinned heads for every fact below (read 2026-10-02, 11:30-12:00 UTC): + +| Repo | Ref | Commit | +| --- | --- | --- | +| Eve | `main`, `F:\Projects\Eve` (clean) | `167a2d3e1302ea40cf617aaa4d6baac1bea0716f` | +| gamecult-site | `main`, `F:\Projects\gamecult-site` | `2af3399` | +| GameCult-Quartz | `main`, `F:\Projects\GameCult-Quartz` (sibling engine) | as checked out | +| gamecult-ops | `main`, `F:\Projects\gamecult-ops` | as checked out | +| Huginn | `main`, `F:\Projects\Huginn` | as checked out | +| the deck | claude.ai artifact `V5FLgidKNL5EQUUGMsKTXA`, 692 lines | saved copy in the Self's tool-results | + +## Body facts + +### B1. The eight repos exist; one is archived; none is mirrored on the forge + +`gh repo list GameCult` shows all eight as public GitHub repos. **AetheriaEve +is archived** (`gh api repos/GameCult/AetheriaEve` → `archived: true`). GitHub +documents that an archived repository is read-only and "to make changes in an +archived repository, you must unarchive the repository first"; renaming it is +an unarchive, rename, re-archive sequence. The forge +(`https://eureka.gamecult.org/forge/api/v1/repos/search?q=eve`) returns no +repos: there are no Forgejo mirrors to rename. Every local checkout's only +remote is `origin` on `github.com/GameCult/.git`. + +Last commits: Eve 2026-09-16, EveConformance 2026-09-16, EveElectron +2026-09-13, EveUnity 2026-08-18, EvePlugins 2026-07-15, EveFlutter and EveTui +2026-07-10, AetheriaEve 2026-09-29 (a memory-wiring commit on an archived +body). + +### B2. GitHub rename semantics (docs.github.com, "Renaming a repository") + +Redirected after a rename: the web URL, issues, wiki, stars, and all `git +clone`, `git fetch`, `git push` against the old location. Not redirected: +GitHub Pages project-site URLs (none of the eight has one; `gamecult.org` is a +custom domain on `gamecult-site`) and **actions referenced by `uses: +GameCult/`**, which fail. Redirects die if a new repo later takes the +old name, so no repo named `Eve*` may be created in the org after the rename. +`raw.githubusercontent.com` currently answers 200 for +`GameCult/Eve/main/.voidbot/voice/eve.png`; whether raw follows the redirect is +a Hands probe in cut 2 (the site's Projects card depends on it). + +### B3. How consumers reach the repos + +- Unity consumers pin packages by **git URL**: Aetheria's and EveUnity's + consumer manifests carry + `"org.gamecult.eve.surface": "https://github.com/GameCult/Eve.git?path=/packages/org.gamecult.eve.surface#"`, + likewise `GameCult/EveUnity.git` and `GameCult/EvePlugins.git`. Git-URL + pins keep resolving through the redirect (B2). The UPM package ids + `org.gamecult.eve.*` are wire names (inventory, class A). +- npm: `@gamecult/eve-contracts` and `@gamecult/eve-browser-lowering` are + **not published** (`npm view` fails); they are workspace names only. +- CultLib's Rust proves `gamecult.eve.surface.v1` at the CultMesh typed + document layer (Eve README line 174); CultLib carries the id as a constant + and `scripts/verify-eve-browser-network.mjs` by file name. + +### B4. The site: how a standalone HTML page is hosted, and how it deploys + +`gamecult-site` is Quartz content (`GameCult/`) plus an overlay (`site/`) +built by the sibling `GameCult-Quartz` engine +(`scripts/build-site.mjs` stages the engine under `.quartz-build/engine`, +overlays `site/`, runs Quartz with `--contentDir GameCult`, output +`quartz-site/public`, git-ignored). + +The standalone-HTML precedent is the **applet**: the overlay's +`site/quartz/static/applets//index.html` is copied verbatim by Quartz's +`Static` emitter to `/static/applets//index.html`. Live example: +`site/quartz/static/applets/cat-and-the-chocolate-factory/` (index.html, +main.js, style.css, ink.js), published at +`https://gamecult.org/static/applets/cat-and-the-chocolate-factory/index.html` +and embedded by `GameCult/Blog/cat-and-the-chocolate-factory/index.md`: + +```html +
+ +
+If the embedded version gives you trouble, open the adventure directly. +``` + +`.gamecult-embed-frame iframe` is styled in `site/quartz/styles/custom.scss` +(lines 2390 and 2501). Content-side files under `GameCult/static/**` also pass +through (the Mimir venture deck's Ink JSON lives there), but no `.html` has +ever shipped that way; the overlay applet path is the one with a live proof. + +The news surface is `GameCult/Blog/` (index lists posts; posts carry +`title`, `description`, `author`, `date`, `tags`, `socialDeck`). The Projects +page `GameCult/Projects/index.md` has an **Eve card** (`
`) +listing seven repos (not AetheriaEve) with the portrait +`https://raw.githubusercontent.com/GameCult/Eve/main/.voidbot/voice/eve.png`, +and a **Huginn card** that says "projects inspectable Eve surfaces" and +"`.cc` inspection and Eve projection". + +Deploy: push to `main` runs `.github/workflows/deploy-quartz.yml` (`Deploy +Quartz`, reusable workflow `GameCult/GameCult-Quartz/.github/workflows/quartz-pages.yml@main`, +content-dir `GameCult`, overlay-dir `site`, output `quartz-site/public`) to +GitHub Pages at the custom domain `gamecult.org` +(`gamecult-ops/runbooks/gamecult-pages-cutover.md`, cutover done 2026-04-21; +`inventory.md` line 971). Local build: `.\scripts\quartz\quartz.ps1 build` +with `GameCult-Quartz` as sibling after `npm ci` there. Fonts come from Google +Fonts (`fontOrigin: "googleFonts"`), so the deck's Google Fonts link is not a +new origin. + +### B5. The brand, and where the deck departs from it + +`docs/brand-design-language.md` and its sources (`site/quartz.config.ts` +theme, `site/quartz/styles/custom.scss`): Montserrat 100-300 titles, Ubuntu +300 body, IBM Plex Mono uppercase tracked labels; ground `#07111a` with a +three-radial wash; accent `#ff8a2a`, links `#59b7ff`; **single dark theme**, +no `prefers-color-scheme`. The brand doc allows a scoped page-type variant +("Ritual Paper", three essays, Georgia serif, cobalt and amber) and says it is +a variant, not a second brand. + +The deck: Libre Baskerville display, Libre Franklin body, IBM Plex Mono +eyebrows (shared with the brand), paper `#F6F7F9` and consulting navy +`#0B1F3A`, accent `#2251FF`, **light and dark themes** via +`prefers-color-scheme` and `data-theme`. Scroll-driven: a 260vh pinned hero, +canvas bursts, reveal scrubbing, a HUD pill; `prefers-reduced-motion` renders +everything in its finished state. + +### B6. Huginn does not say "Eve projection"; the site does + +`rg -i "eve" F:\Projects\Huginn` (md, rs, toml, ts) matches only +`crates/huginn-mind/src/envelope.rs`, and that match is `cultnet_rs` +("eve" inside a word, no Eve). Huginn's README describes a Rust memory organ. +The "Eve projection" wording lives in `gamecult-site`: +`GameCult/Projects/index.md` (Huginn card) and +`GameCult/Docs/Architecture-and-Evidence.md` line 55 ("the same Eve projection +shows the new reality"). The memory note +`~/.claude/projects/F--Projects/memory/eureka-memory-organ.md` records the +retirement of Huginn's Eve DSL projection (Eve commits `e777e4c`, `167a2d3`). + +### B7. The Persona + +`F:\Projects\Eve\.voidbot\state\eve.cc` is a CultCache file holding a +`void.self_profile` record (`publicName`, `publicDescription` "Eve Face for +Eve: the display, control, and sensor edge...", values, voice; `updatedAt` +2026-05-31, `storedAt` 2026-07-08) and a `void.moderation_cursor`. +`.voidbot/voice/identity.json` is `voidbot.repo_face_identity.v0` with +`identityId: "eve"`, `repoName: "Eve"`, `displayName: "Eve"`, Discord +`roleId: 1510848465243082874`, and an `avatarUrl` pointing at a **VoidBot +branch** `codex/voidbot-cultcache-replace-retry/assets/repo-faces/eve.png`; +`F:\Projects\VoidBot\assets\repo-faces\` on `main` has no `eve.png`. The +portrait that is actually served is `.voidbot/voice/eve.png` in the Eve repo. +`docs/eve-persona.md` is the human-readable mission memory. The Persona +standard (`gamecult-ops/docs/persona-state-standard.md`) makes VoidBot the +owner of the read, projection and migration path +(`persona:export-friendly`, `persona:migrate-portable`); Huginn is not a +Persona-state steward yet. + +### B8. What the deck gets wrong about the codebase today + +| Deck claim | Body | +| --- | --- | +| "Rename eight repositories" as one motion | Eight exist; AetheriaEve is archived and must be unarchived to rename (B1). The Projects card already treats the family as seven. | +| "Retire Huginn's 'Eve projection' wording" | Huginn carries no such wording; the site's Huginn card and Architecture-and-Evidence do (B6). The retirement is a site cut, not a Huginn cut. | +| "Update the Projects page and Persona card" | Both exist as described (B4, B7). Correct. | +| "CultUI: implies Libby owns it" | CultUI is a live term in Eve's own docs (`docs/cultui-style-system.md`, `surface-contract-v1.md`), in doctrine ("Eve/CultUI") and in CultLib ("Unity CultUI", `org.gamecult.ui`, a different package that Libby does own). Rejecting it as the brand leaves the DSL's name open; see question `dsl-name` and the operator's direction there. | +| Persona divinity arithmetic | Correct as stated (2.5/7 = 35.7%; 3.5/7 = 50.0%). | +| "Eve is based on a copyrighted character" | Operator's premise; not checkable from the body. | + +## Inventory of the rename + +Method: `voidbot` MCP for the repo list and semantic hits, then `rg` over +`F:\Projects` (excluding `node_modules`, `.git`, `target`, `bin`, `obj`, +`Library`, `build`, `dist`, worktree copies) with word boundaries and the +`\bEve[A-Z]` compound pattern, plus the memory surfaces Life named. + +### Class A: wire names (a rename is a migration) + +A1. Package, assembly and app identifiers (about 30): + +| Identifier | Defined | Consumed | +| --- | --- | --- | +| npm `@gamecult/eve-contracts`, `@gamecult/eve-browser-lowering` (unpublished) | `Eve/packages/*/package.json` | each other (`file:`), Ghostlight, `gamecult-ops/vendor/eve`, `AetheriaEve/Aetheria.Rts.Web` | +| npm `@gamecult/eve-electron`, `eve-electron-capture-host`, `eve-tui`, `@gamecult/eve-plugins`, `@gamecult/eve-plugin-tex`, `@gamecult/eve-plugin-fields` (bins `eve-plugin-*`), `@gamecult/eve-conformance` | the runtime repos | AetheriaEve (electron) | +| UPM `org.gamecult.eve.surface`, `org.gamecult.eve.unity-scene`, `org.gamecult.eve.unity-uitoolkit`, `org.gamecult.eve.plugin-fields` | Eve, EveUnity, EvePlugins `package.json` | EveUnity and AetheriaEve `Packages/manifest.json`, pinned by git URL `github.com/GameCult/Eve*.git?path=...#` | +| NuGet `GameCult.Eve.Surface` (packed by `Eve/scripts/pack-dotnet-surface.ps1`), `GameCult.Eve.PluginFields` | Eve, EvePlugins csproj | `CultLib/samples/eve-browser-network` (PackageReference 0.3.3), AetheriaEve, Gjallar, Ymir | +| asmdef and namespaces `GameCult.Eve.*` (Surface, UnityScene, UnityScene.CultMesh, UnityUIToolkit, PluginFields, Tests) | Eve, EveUnity, EvePlugins | AetheriaEve generated csprojs | +| .NET `Mimir.EveSensorReceiver`, `Mimir.EveDashboard`, `Mimir.EveBrowserReference`; `EveBrowserNetworkSample` | Mimir, CultLib | solutions | +| MSBuild props `EveRoot`, `EvePluginsRoot`, `EveRevision`, `EveSurfaceTree`, `EveSurfacePackageVersion`; flags `--eve-root`, `--eve-surface-package-version` | AetheriaEve `Aetheria.State.Dependencies.props/.targets`, CultLib sample | builds | +| Android `org.gamecult.eve` (app_name "Eve", `EveTheme`, intent extras `org.gamecult.eve.DASHBOARD_URI`, `.SENSOR_URI`, `.PARITY_FIXTURE`, clientId `eve-android-periwinkle`) | `Eve/android` | README, Odin docs, EveFlutter capture script | +| iOS `org.gamecult.evecanvas`, app `EveCanvas`, ObjC prefix `EVE*` (13 files) | `Eve/Resources/Info.plist`, `control`, `Makefile` | Idunn target `Service = "org.gamecult.evecanvas"` | +| Flutter `eve_parity`, Android `com.example.eve_parity`, "Eve Parity" | EveFlutter | `eve_parity.exe` | +| Kotlin `org.gamecult.cultmesh.eve` (`EveDocuments.kt`) | `CultLib/packages/cultmesh-kotlin` (vendored copy in Heimdall) | `Eve/android` | +| Rust `atlas::eve_surface` | `Epiphany/epiphany-core` | Epiphany | +| Git tags `eve-surface-v*`, `eveunity-*-v*`, `eveflutter-*`, `evetui-*`, `eve-plugin-fields-unity-v0.2.3` | the repos | Unity manifest pins | + +A2. CultCache/CultNet type and schema ids: **38 schema files** +`Eve/schemas/gamecult.eve..v1.schema.json`, about **90 distinct ids** +counting the bare type ids, `.v2` variants and provider ids. Declared in C# +(`Eve/packages/org.gamecult.eve.surface/Runtime/Eve*Document*.cs`, +`SchemaId` consts), TypeScript (`Eve/packages/eve-contracts/src/index.ts`, +`EVE_*_SCHEMA`), Rust (`Odin/crates/odin-core/src/documents.rs`, +`Epiphany/.../atlas/eve_surface.rs`, `Ghostlight/crates/ghostlight-dungeon/src/mesh.rs`), +duplicated in C# (AetheriaEve `AetheriaRuntimeEveCommandDocument.cs`, +`Mimir/src/Mimir.BufferSmoke`, `Gjallar/src/Gjallar/VerseState.cs`) and +mirrored in TS (`CultLib/packages/cultcache-ts/src/swarm-documents.ts`). +Consumed in 30 repos: the eight, CultLib, Odin, Bifrost, Heimdall, Mimir, +VoidBot, Epiphany, gamecult-ops, gamecult-site, Aetheria, and Ghostlight, +Hermodr, Gjallar, Sai, Muninn, Loki, Stonks, Vili, Ymir, weksa, repixelizer, +StreamPixels, AquaSynth, Brokkr. Families: `surface` (`.v1`, `.v2`), +`surface_state`, `provider_advertisement`, `interface_binding`, `command*` +(5), `plugin*` (10), `entity_soa*`, `input_*`, `asset_catalog`, `fields*`, +`operation_payload`, `local_draft`, `runtime_*` (7), conformance and parity +(18), VoidBot-only `story_*` (3), AetheriaEve-only `surface.authoring.v1`, +fixture authority ids `gamecult.eve.embedded-demo*` and friends. Non-`gamecult` +ids: `mimir.eve_*` (11), `cultmesh.eve_surface.v0`, +`aetheria.eve_command_acceptance_status.v1`, `gamecult.aetheria.eve.unknown.v1`, +`org.gamecult.aetheria.eve-runtime`, `eve.world_smoke.*`, `eve.entity_soa.v1`. + +A3. Mesh document keys, URIs, routes and stored names: key families +`eve:surface:` (about 25 in AetheriaEve; also Eve, CultLib, EveUnity, +gamecult-ops `ghostlight.play`), `eve:provider:`, `eve:plugin:`, +`eve:entity-soa:*`, `eve:entity-view:*`, `eve:input:*`, `eve:commands*`, +`eve:command-invocations:*`, `eve:receipts*`, `eve:assets:*`, `eve:body:*`, +`eve:view:*`, `eve:field:*`; Electron IPC channels `eve:*`, +`eve-electron:window-control`; `cultmesh:///eve/providers/`, +`/eve/surfaces/*`, `/eve/governance/surface`, `/eve/operator/surface`; +semantic services `/eve/gui`, `/eve/tui`, `/eve/operator`, +`/eve/governance` (Bifrost, Heimdall, Odin, Gjallar, Stonks, Vili units); +HTTP/WS routes `/eve/deck*`, `/eve/periwinkle*`, `/eve/dashboard*`, +`/eve/camera`, `/eve/mic`, `/eve/fixtures/authority/*`, `api/eve/*` (Mimir, +Odin ports 8793-8799); stored files `.bifrost/eve-surfaces.cc`, +`.voidbot/state/eve.cc` (`agentId eve`, speech receipt keys +`repo-identity:eve:`); file names `eve-runtime-capability.json`, +`eveflutter-lifecycle.json`, `conformance/eve/` (AetheriaEve, 7 files), the +`.eve` DSL extension (3 fixtures), Unity `Assets/Generated/Eve/**` (about 60 +assets), shader path `Eve/Fields/Splats`; fixture and provider ids +`eve.world-smoke*` (about 20), `eve.cultui.inspector`, `eve.composition`, +`eve.local`, `eve.browser*`, `eve.dashboard.broker`, `local-eve-dsl`, +`ghostlight.eve.commands`, `entanglement.*-eve.*`, `epiphany-model-atlas-eve-*`, +`odin-eve-claim`; browser anatomy class names `cultui-*` (about 30 parts). + +A4. Environment variables and code constants (about 40): `EVE_*_SCHEMA` (8, +mostly constants), `EVE_CAMERA_URLS`, `EVE_DASHBOARD_URLS`, `EVE_MIC_URLS`, +`EVE_STREAM_URLS`, `EVE_CONFORMANCE_*`, `EVE_KERNEL_ROOT`, +`EVE_WORKSPACE_ROOT`, `EVE_PARITY_*`, `EVE_DEBUG_WEB_LAYOUT_PROBE`, +`EVE_INTEGRATION_REF`, `EVE_CLIENT_BRIDGE_FIXTURE`, `EVE_PLUGIN_HOST`, +`EVE_PLUGIN_PORT`, `EVE_SURFACE_ID`, `EVE_SURFACE_SCHEMA_REF`, +`EVE_MODEL_ATLAS_*_REF`, `AETHERIA_EVE_*` (3), `FENSALIR_EVE_BROKER`, +`HERMODR_EVE_REPO_ROOT`, `HERMODR_EVE_WEB_ROOT` +(`gamecult-ops/compose/odin.yggdrasil.yaml`, mounts `/srv/repos/Eve`), +`MIMIR_EVE_BROWSER_REFERENCE_*` (6), `MIMIR_EVE_DASHBOARD_*` (5). + +A5. Ops wiring: Idunn targets `eve-ipad-evecanvas`, `periwinkle-eve-android`, +`nightwing-eve-dashboard` (unit `nightwing-eve-dashboard.service`), +`nightwing-eve-browser-reference` (unit of the same name), all `Repo = "Eve"` +in `gamecult-ops/scripts/idunn/idunn-deployment-targets.ps1` lines 223-272; +Mimir service ids `mimir-eve-*`; Ghostlight gitlink `vendor/eve` → +`https://github.com/GameCult/Eve.git` (`Ghostlight/.gitmodules`, +`gamecult-ops/idunn/yggdrasil/bindings/ghostlight.toml.in`, +`gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh` with +`eve_repo=/srv/build/Eve` and receipt field `eve_commit`); unit descriptions +in `gamecult-ops/systemd/voidbot.service` and `scripts/gjallar.service`. No +`eve*.service` unit file, MCP server or MCP tool carries the name. No +Forgejo mirror exists. + +### Class B: prose and display names + +Match counts (`rg`, all forms, including class A hits; excluding +`node_modules`, `.git`, `target`, `bin`, `obj`, `Library`, `build`, `dist`, +worktrees, `*.meta/.mat/.prefab/.asset/.png/.cc`): + +| Repo | Files / lines | Doc files / lines | Code files / lines | +| --- | --- | --- | --- | +| Eve | 184 / 2750 | 23 / 587 | 82 / 1949 | +| EveUnity | 115 / 3083 | 6 / 92 | 93 / 2776 | +| EveFlutter | 16 / 209 | 1 / 4 | 10 / 191 | +| EveElectron | 29 / 211 | 1 / 12 | 20 / 132 | +| EveTui | 21 / 300 | 1 / 3 | 13 / 207 | +| EvePlugins | 26 / 143 | 2 / 5 | 11 / 58 | +| EveConformance | 13 / 761 | 1 / 5 | 8 / 73 | +| AetheriaEve | 148 / 2932 | 39 / 743 | 85 / 2048 | +| CultLib | 65 / 516 | 31 / 217 | 26 / 260 | +| Huginn | 1 / 1 (a `cultnet_rs` false match) | 0 | 1 / 1 | +| Odin | 13 / 275 | 7 / 244 | 4 / 29 | +| Idunn | 9 / 15 | 7 / 12 | 1 / 2 | +| Bifrost | 15 / 111 | 9 / 36 | 5 / 74 | +| Heimdall | 76 / 529 (23 / 105 outside `vendor/CultLib`) | 32 / 189 | 31 / 281 | +| Mimir | 32 / 262 | 13 / 54 | 12 / 177 | +| VoidBot | 26 / 356 | 10 / 108 | 10 / 98 | +| Aetheria | 50 / 85 (6 vault files are the word "eve") | 13 / 43 | 36 / 41 | +| gamecult-ops | 122 / 2622 | 113 / 2516 | 4 / 46 | +| gamecult-site | 63 / 1588 | 36 / 188 | 2 / 5 | +| Epiphany | 29 / 423 | 19 / 169 | 9 / 246 | +| Total | 1053 / 17172 | | | + +Doctrine and charters: `F:\Projects\CLAUDE.md` lines 23, 24, 26, 90 +(line 28 is "every"); `~/.claude/CLAUDE.md` line 175; `~/.claude/agents/*.md`, +`doctrine/*.md`, `output-styles/*.md`: none; `~/.claude/skills/eureka/SKILL.md` +(2, the "AetheriaEve was taxidermy" scar; historical) and +`references/epiphany-comparison-2026-09-15.md` (3, historical); +`gamecult-ops/docs/persona-state-standard.md` (last paragraph), +`eve-crusade-coordination.md`, `verse-service-architecture.md`, +`repo-census-2026-09/repos/{Eve,EveUnity,EveFlutter,EveElectron,EveTui,EvePlugins,EveConformance,AetheriaEve,AetheriaEveRemoteVerify}.md` +(census: historical). + +Memory surfaces (Life's sweep, nothing edited): `~/.claude/projects/F--Projects/memory/` +`cultcache-stores-outside-assets.md` lines 30-31 and `MEMORY.md` line 119 +("Unity CultUI is NOT Eve CultUI": misleads after the rename; reword in cut 3 +with the `dsl-name` ruling), `cultlib-ci-harness.md` line 95 (cites +`scripts/verify-eve-browser-network.mjs:112-113`; changes only if the file is +renamed), `aetheriaeve-taxidermy.md`, `cheap-signal-is-not-evidence.md`, +`wanted-small-daemons.md`; historical, leave: `eureka-memory-organ.md` lines +90, 124, 131, `pending-doctrine-proposals.md` line 36; low risk: +`F--Projects-Aetheria/memory/aetheria-legacy-first.md` line 15, +`aetheria-cultcache-migration.md` line 19, `MEMORY.md`, +`aetheria-content-audits-pre-breach.md`. + +Site (`gamecult-site/GameCult/`): live prose `Projects/index.md` (Eve card, +Huginn card), `Projects/CultLib.md`, `Docs/Architecture-and-Evidence.md`, +`Docs/Site-Architecture.md`, `Docs/index.md`, `Pitch.md`, `tour.md`, +`stichting.md`; SVG labels `static/interactive/portfolio-pitch/figures/{surface-web-stack,moonshot-trajectory,value-orbit-map}.svg`; +generated data `static/interactive/gamecult-compound/*.json` and +`repo-doc-tree/eve.json` (1062 lines; regenerated by +`scripts/generate-vn-repo-doc-tree.mjs`, route ids `route.eve_*` from +`scripts/generate-vn-eve-surface.mjs`); portrait copy +`static/interactive/cotsc-praxis/eve.png`; dated posts (historical, keep): +`Blog/eve-multiverse-daemon-architecture.md` (28, tag `eve`), +`witness-authoritative-networking.md` (15), `purge-the-heretek-from-our-daemonic-swarm.md`, +`ghostlight-multiresolution-gestalts.md`, `the-human-is-not-the-prompt-layer.md`, +`the-shared-mind-is-the-product.md`, `the-last-few-months/*.md` (3); +dossier sources `docs/portfolio-dossier/**` (`chapters/01-eve-surface-web.tex`, +`chapters/projects/eve.tex` and 10 more), `docs/gamecult-repo-swarm-organ-atlas.tex`, +`docs/gamecult_void_dossier.md`, `docs/persona-artifacts/vn-space-map.md`. + +Persona state: `Eve/.voidbot/state/eve.cc` is the only `eve.cc` (binary +CultCache, 80,839 bytes; records `void.self_profile`, `void.moderation_cursor`, +`void.speech_receipts`, `void.thought_memory`, `void.face_affect`, +`void.agency_pressure`, `void.candidate_interventions`, +`void.scheduled_runtime`; 347 string matches). Other Personas mention Eve in +their own state: `CultLib/.voidbot/state/libby.cc` (8), `Mimir/.../mimir.cc` +(9), `Sai/.../sai.cc` (13), `Brokkr/.../brokkr.cc` (2), +`AetheriaLore/.../nibu.cc` (2); these are their memories and are not edited. +`Eve/.voidbot/state/README.md` points at `docs/eve-face.md`, which does not +exist (the file is `docs/eve-persona.md`). `Eve/.voidbot/voice/README.md`, +`identity.json`, `eve.png` as B7. + +READMEs: the eight top-level READMEs (each titled `# `), +`Eve/packages/{eve-contracts,eve-browser-lowering,org.gamecult.eve.surface}/README.md`, +`EveUnity/packages/*/README.md` (2), `EvePlugins/plugins/eve-plugin-fields/unity/.../README.md`, +`AetheriaEve/{Aetheria.State,Aetheria.Unity,conformance/eve}/README.md`; +outside: `CultLib/README.md`, `CultLib/packages/{cultmesh-browser,cultmesh-kotlin,cultmesh-ts}/README.md`, +`CultLib/samples/{eve-browser-network,eve-two-runtime}/README.md`, +`CultLib/src/GameCult.Mesh/README.md` and `docs/getting-started/{README,03-publish-an-eve-surface}.md`, +`CultLib/src/GameCult.Mesh.Quic/README.md`, `Heimdall/README.md` (+ vendored +CultLib set), `Odin/README.md`, `Bifrost/README.md`, `VoidBot/README.md`, +`Epiphany/README.md`, `Epiphany/schemas/cultnet/README.md`, +`gamecult-ops/{README,scripts/README,docs/repo-census-2026-09/README}.md`. +Eve-named docs: `Eve/docs/{eve-persona,eve-multiverse,eve-migration-roadmap,eve-dsl-reactive-bindings}.md`, +`AetheriaEve/docs/eve-dependency-authority.md`, `Heimdall/docs/eve-access-plugin.md`. +Eve-prefixed source files: about 60 `Eve*.cs` in `EveUnity/packages/*/{Runtime,Tests}`, +about 10 in `Eve/packages/org.gamecult.eve.surface/Runtime`, 13 `EVE*` ObjC files +in `Eve/Sources`. + +### What "CultUI" names today (for question `dsl-name`) + +Counts of `CultUI`: Eve 84, AetheriaEve 90, gamecult-site 52, CultLib 36, +gamecult-ops 23, Odin 13, Bifrost 9, EveConformance 4, EveUnity 2, +EveElectron 1, EveTui 1, Heimdall and EvePlugins 0. Four different things: + +1. **The authoring DSL**: "CultUI is Eve's UI DSL" (`docs/cultui-style-system.md` + line 3), indentation-authored, the `.eve` fixture files, compiled to + `gamecult.eve.surface.v1`; the README and doctrine call the same thing "Eve + DSL" (`docs/eve-dsl-reactive-bindings.md`, `F:\Projects\CLAUDE.md` line 28 + "Eve's DSL is split into two streams: TUI ... and GUI"). +2. **The retained composition tree and style system**: `surface.root` "retained + CultUI component tree" (`surface-contract-v1.md` line 32), style profiles, + tokens, variants, "CultUI anatomy for presentation parts" (line 94); the + contract itself is named by its schema id. +3. **Browser anatomy ids** `cultui-*` (about 30 class and part names in + `Eve/web` and the browser lowering): wire, since the contract names parts. +4. **Unity CultUI**: `CultLib/src/GameCult.Unity/Assets/UI/package.json`, + UPM `org.gamecult.ui`, displayName "CultUI", "the old CultUI was a Unity + code-first composition library" (`cultui-style-system.md` line 5). Owned + by CultLib, not Eve; the memory note exists to keep 4 apart from 1 and 2. + +## The model page (identity, lifecycle, authority of every persistent kind) + +| Kind | Identity | Lifecycle | Authority | +| --- | --- | --- | --- | +| GitHub repo name | `GameCult/`; injective within the org | renamed once; old name redirects until reused; never reused | operator via `gh repo rename`; Hands executes under a ruling | +| Local checkout path | `F:\Projects\`; sibling paths assumed by scripts and memory notes | renamed with the repo; stale paths are a defect | Hands in cut 2; memory notes in cut 3 | +| Schema id | `gamecult.eve..v` in `Eve/schemas/*.schema.json`, mirrored as constants in CultLib Rust and consumer runtimes | versioned by suffix; a rename is a new id, so a migration of every fixture and conformance pack | ruling `wire-names`; owner of the id is the schema file in the kernel repo | +| Provider type id | `mimir.eve_*.v1` and any other provider ids naming eve | provider-versioned | the provider repo, under `wire-names` | +| Package id | `org.gamecult.eve.*` (UPM), `@gamecult/eve-*` (npm, unpublished), `org.gamecult.eve` / `org.gamecult.evecanvas` (installed app ids) | UPM: consumers pin by git URL plus id, a rename breaks every manifest; app ids: a rename is a new app on the device | `wire-names` | +| Persona identity | `identityId: eve` (identity.json), `agentId` in `eve.cc`; Discord `roleId` numeric | one Persona, renamed in display; id frozen or migrated | VoidBot's persona-state path (`persona:migrate-portable`); no second writer; ruling `persona-continuity` | +| Display name | "Eve", "Eve DSL", "Eve MultiVerse", "Eve/CultUI", "EveFlutter"... in doctrine, READMEs, site, cards | replaced once by the taxonomy; historical records keep the old word | the taxonomy in this map, ruled by the operator; single copy in the Thing README "Naming" section, cited by doctrine | +| Site URL | `/Blog/`, `/tags/eve`, `/static/applets//` | permanent once published; the site has no redirect mechanism | gamecult-site; invariant `old-names-resolve` | + +No cut is mapped on a kind whose cell is empty; none is. + +## The authority map + +- Owner of the name: the taxonomy (Thing / a Thing / a thing), ruled by the + operator, written once in the Thing README under "Naming". Doctrine files + (`F:\Projects\CLAUDE.md`, charters, memory notes) cite it; they do not + restate it differently. +- Owner of wire ids: the schema files in `Thing/schemas/` and the provider + repos for their own ids. Under the recommended `wire-names` ruling they are + frozen at the `eve` spelling for every `v1`; a `gamecult.thing.*` id appears + only with a breaking revision that is paid for by something else. +- Owner of Persona state: `.voidbot/state/eve.cc` through VoidBot's + persona-state service. `identity.json` and the site card are derived + display; they do not decide the Persona's name. +- Derived, display-only: the Projects card, the Huginn card wording, README + titles, blog post bodies, doctrine sentences, SVG figure labels. +- Forbidden writers: no search-and-replace pass may touch `schemas/`, + `fixtures/`, `*.cc`, `Packages/manifest.json` pins, `package.json` names, + `pubspec.yaml` names, Android `applicationId`, iOS bundle ids, or Discord + ids. A rename of any of those is a cut of its own under a ruling. +- Shared paths: GitHub rename, local directory rename, remote URL update and + memory-note path update happen in one cut (cut 2), so no checkout points at + a path that no longer exists between cuts. +- Deletion line: nothing is deleted. Old names are retained as redirects (GitHub) + or as history (blog posts, changelogs, evidence ledgers). `docs/eve-*.md` + files are renamed with `git mv`, not duplicated. + +## Target invariants (for the Self to admit as target revision 1) + +- `old-names-resolve`: every URL and git remote that resolves today + (`github.com/GameCult/Eve*`, `GameCult/Eve*.git` clone URLs, the raw + portrait URL the site uses, every `gamecult.org` page and tag) resolves + after each cut, by redirect or by retention. No repo named `Eve*` is created + in the org after cut 2. +- `wire-ids-under-ruling`: no stored or published id (`gamecult.eve.*`, + `mimir.eve_*`, `org.gamecult.eve*`, `@gamecult/eve-*`, `identityId eve`) + changes except as the `wire-names` ruling allows; EveConformance fixtures and + exported packs decode unchanged after every cut. +- `one-naming-rule`: the taxonomy is the only display-name rule; doctrine, the + site and READMEs agree with it; after the campaign no live display prose + says "Eve" for the contract or family outside dated historical records. +- `persona-continuity`: one Persona, one state file, one writer; the rename + keeps provenance (`updatedAt`, `storedAt`, history) and introduces no second + copy of Persona state. +- `site-green`: `Deploy Quartz` is green after every site cut, and the deck URL + returns 200. +- `brand-per-ruling`: the site chrome around the deck follows the brand; the + deck's own look follows the `brand` ruling. + +Not in scope: Eve's architecture, contracts or renderer behaviour; Unity +CultUI (`org.gamecult.ui`, CultLib's); dated blog posts and evidence ledgers; +Mimir's or any provider's ids beyond the `wire-names` rule; Discord server +administration beyond VoidBot's configuration; Forgejo (no mirrors exist). + +## Cut 1: put the deck on the site + +Repo: `gamecult-site`. Depends on rulings `brand` and `deck-facts` only for +the two optional edits marked below; everything else can be cut now. + +Target shape: + +1. `site/quartz/static/applets/project-thing/index.html`: the deck. Take the + saved artifact HTML and make it a well-formed page: move the `` and + the Google Fonts `<link>`s into `<head>`, drop the artifact host's wrapper + `<style>` on line 1 (the `color-scheme:light` body reset and `[hidden]` + rule; the deck sets its own), keep the deck's `<style>`, markup and + `<script>` byte-for-byte otherwise. Add + `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">` + and `<meta charset="utf-8">`. No site CSS is loaded into the deck. + Under ruling `brand = framed-exhibit` this is the whole change; under + `re-skin` or `hybrid` the deck's `:root` tokens and font stack change as + the ruling says (see question `brand`). +2. `GameCult/Blog/project-thing.md`: the news post that frames it. + Frontmatter: `title: Project Thing`, `description` (one sentence: a board + discussion document on renaming Eve to Thing), `author: Thing + Transformation Office`, `date: 2026-10-02`, `tags: [eve, thing, gamecult]`, + `socialDeck: "Thing renders things."`. Body: one short paragraph naming + it as a board deck on the rename of Eve, the `gamecult-embed-frame` iframe + pointing at `/static/applets/project-thing/index.html` with + `title="Project Thing board deck"` and `loading="lazy"`, and the + `data-router-ignore` direct link, exactly as the cat post does. The iframe + needs real height for a scroll-driven deck; if `.gamecult-embed-frame + iframe` (custom.scss 2390) is shorter than about 80vh, add a scoped rule + `body[data-slug="Blog/project-thing"] .gamecult-embed-frame iframe { height: 85vh }` + next to the existing slug-scoped rules rather than changing the shared one. +3. `GameCult/Projects/index.md`, Eve card: add one list item + `<li><a href="/Blog/project-thing">Project Thing</a> <em>board deck on the rename</em></li>` + after the EveConformance item. Nothing else on the card changes in this cut. +4. Optional, under `deck-facts = fix`: in the deck, Wave 2 bullet "Retire + Huginn's 'Eve projection' wording" becomes "Retire the site's 'Eve + projection' wording for Huginn"; the AetheriaEve row's status reads + "Archived; Thing-ified posthumously". No other copy changes. + +Verification Hands must show: local `.\scripts\quartz\quartz.ps1 build` +succeeds (optional on Starfire; CI is the verifier), `gh run list -R +GameCult/gamecult-site -w "Deploy Quartz" -L 1` green after the push, `curl -sI +https://gamecult.org/static/applets/project-thing/index.html` 200 and +`https://gamecult.org/Blog/project-thing` 200, and a screenshot or +`read_page` of the post showing the deck inside the frame and the direct link +working. Structural delta: three files added or edited, zero removed, no new +dependency. + +## Cut order after cut 1 + +Each cut is one `cut_spec`; `depends_on` is in parentheses. + +- **Cut 2 `repo-renames`** (none). For the seven live repos: `gh repo rename + Thing -R GameCult/Eve` and so on (`ThingUnity`, `ThingFlutter`, + `ThingElectron`, `ThingTui`, `ThingPlugins`, `ThingConformance`); for + AetheriaEve as ruling `aetheria-eve` says (unarchive, rename to + `AetheriaThing`, re-archive, or leave). In the same cut: rename the local + directories `F:\Projects\Eve*` to match, `git remote set-url origin` in each, + update `.voidbot/voice/identity.json` `repoName` and `repoPath`, probe that + `raw.githubusercontent.com/GameCult/Eve/main/.voidbot/voice/eve.png` still + answers 200 (if not, cut 4 moves the card's image URL first), and run + `rg "uses: GameCult/Eve" F:\Projects --glob "**/.github/**"` (B2: action + references do not redirect; any hit is fixed in this cut). Ops wiring + (inventory A5): fields that *derive a GitHub URL from the repo name* change + (`Repo = "Eve"` in `idunn-deployment-targets.ps1` if Idunn builds the clone + URL from it, Hands probes; `Ghostlight/.gitmodules` URL and + `ghostlight.toml.in`); server filesystem paths (`/srv/repos/Eve`, + `/srv/build/Eve`, `HERMODR_EVE_*`, `eve_repo=`) stay as they are, since a + path on Yggdrasil is Idunn-owned deployment state and not a public name; + moving them is a separate ops cut if ever wanted. Verify with `git + ls-remote https://github.com/GameCult/Eve.git` still resolving, each + renamed checkout fetching, and `git submodule update` in Ghostlight. +- **Cut 3 `doctrine-and-memory`** (2). `F:\Projects\CLAUDE.md` lines 23, 24, + 26, 28 ("Eve/CultUI", "native Eve", "CultMesh/Eve surface", "Eve DSL", "Eve + MultiVerse") and line 90 ("Eve GUI lowerings"); `~/.claude/CLAUDE.md` line + 175; `gamecult-ops/docs/persona-state-standard.md` last paragraph ("Eve, + overlays, native clients"); `gamecult-ops/docs/eve-crusade-coordination.md` + and `verse-service-architecture.md` as the inventory lists; memory notes: + `~/.claude/projects/F--Projects/memory/cultcache-stores-outside-assets.md` + lines 30-31 and `MEMORY.md` line 119 (reworded with the `dsl-name` ruling + so the Unity CultUI versus DSL distinction survives: "Unity CultUI is not + Thing Script" under the recommendation), + `cultlib-ci-harness.md` line 95 only if the script file is renamed. Leave as + written: `eureka-memory-organ.md` lines 90, 124, 131 and + `pending-doctrine-proposals.md` line 36 (historical); low risk, reword in + passing: `F--Projects-Aetheria/memory/aetheria-legacy-first.md` line 15, + `aetheria-cultcache-migration.md` line 19. The taxonomy is written once in + the Thing README "Naming" section and cited from doctrine. +- **Cut 4 `site-prose`** (2). `GameCult/Projects/index.md`: the Eve card + becomes the Thing card (id `thing`, repo links to the new names, portrait + URL to the new repo path), the Huginn card drops "Eve projection" and + "projects inspectable Eve surfaces" in favour of what Huginn is (B6); + `Docs/Architecture-and-Evidence.md`, `Docs/Site-Architecture.md`, + `Docs/index.md`, `Pitch.md`, `tour.md`, `stichting.md`, + `Projects/CultLib.md` per the inventory; the SVG figure + `static/interactive/portfolio-pitch/figures/surface-web-stack.svg` (two + "Eve" labels). Dated blog posts and the `eve` tag stay. `Deploy Quartz` + green. +- **Cut 5 `persona`** (2, ruling `persona-continuity`). Through VoidBot's + persona-state path: `publicName` and `publicDescription` in `eve.cc` + renamed with provenance preserved; `identity.json` `displayName: Thing`, + `avatarUrl` repointed to the repo's own `.voidbot/voice/` (the current URL + names a dead VoidBot branch, B7); a new portrait `thing.png` per + `~/.claude/doctrine/images.md` (the deck's own constraints, played + straight: no orange rock, at least one arm, nothing ordinal); `docs/eve-persona.md` + → `docs/thing-persona.md` by `git mv` with its text updated; + `.voidbot/state/README.md` repointed from the nonexistent `docs/eve-face.md` + to it; the site card image in cut 4 follows. The Discord role's display name is an operator + action in Discord and is listed, not executed. +- **Cut 6 `in-repo-prose`** (2). README titles and bodies in the eight repos, + `docs/eve-*.md` → `docs/thing-*.md` by `git mv`, comments and fixture + descriptions, `EveCanvas`/`EVE*` class prefixes only if the ruling on wire + names allows (installed app ids are wire). Forbidden-writer rule applies: + no pass over `schemas/`, fixtures or manifests. +- **Cut 7 `wire-ids`** (6, ruling `wire-names`). Under `freeze`: one + paragraph in the Thing README "Naming" section: wire ids keep the `eve` + prefix as history; new breaking revisions take `gamecult.thing.*`. Under + `rename-now`: the migration, specified then, per id class. + +## Questions (the Self admits these; one fork each) + +### `home-repo`: which repo owns the campaign and this map? + +Options: **eve-repo** (this file, `F:\Projects\Eve\docs\thing-campaign.md`, +the kernel repo that becomes `Thing`; the body the campaign changes, as +`eureka-body` lives in Huginn); **gamecult-ops** (cross-repo campaigns as ops +memory; but ops is private and the campaign is public by nature); +**Eureka** (the method repo; wrong owner for a product rename). Recommended: +**eve-repo**. The campaign record's `repos` must list the eight by their +*new* names once cut 2 lands; until then the old names, since a `DocRef` +names a commit and the redirect keeps it reachable either way. + +### `brand`: how does the deck look on gamecult.org? + +Options: **framed-exhibit** (the deck ships with its own consulting look, +unchanged, including its light/dark themes; the brand lives in the blog post +and site chrome around the iframe, and the raw URL opens the exhibit as +itself); **re-skin** (the deck's `:root` tokens become the brand's: ground +`#07111a` with the wash, Montserrat and Ubuntu, accent `#ff8a2a`, single dark +theme); **hybrid** (keep the deck's typography and layout, replace only the +navy with the brand's ground and the blue accent with orange, force dark). +Recommended: **framed-exhibit**. The consulting navy and serif are the joke; +re-skinning it makes an earnest deck out of a parody. Brand doctrine already +admits a scoped variant for typeset essays and already hosts the cat applet +with its own styles; a quoted document in a brand frame is the same move. The +hybrid buys neither the joke nor the brand. + +### `wire-names`: do the eve-named wire ids get renamed? + +The shape is `eureka-body:question:wire-names`. Options: **freeze** (every +`gamecult.eve.*.v1`, `mimir.eve_*.v1`, `org.gamecult.eve.*`, +`@gamecult/eve-*`, app ids and `identityId eve` stay; the Thing README +explains them as history; the rule recorded is rename-at-next-epoch); +**rename-at-next-epoch** (each id becomes `gamecult.thing.<name>.v2` in its +next breaking revision, paid for by that revision; UPM ids on the next major); +**rename-now** (a migration of every fixture, conformance pack, consumer +manifest, Rust constant and installed app bought for a name). Recommended: +**freeze**, with rename-at-next-epoch as the standing rule. The deck's own +wave 1 says "redirect old names so nothing breaks"; wire ids have no redirect. + +### `aetheria-eve`: what happens to the archived repo? + +Options: **rename-archived** (unarchive, `gh repo rename AetheriaThing`, +re-archive; three API calls, the deck's "eight" becomes true, the IP premise +is honoured on the org list); **leave** (taxidermy stays named; the family is +seven, as the Projects card already says). Recommended: **rename-archived**; +it is cheap and the stated reason for the campaign is the name itself. + +### `dsl-name`: what is the DSL and lowering layer called? + +Operator direction (verbatim): "Eve CultUI is indeed misleading, should +become Thing Script or something. ThingML would be hack." Read as a +direction, not a final name. ThingML is also already taken: it is SINTEF's +IoT modelling language, so the collision stands behind the "hack". + +What the new name covers, by the four uses above: **(1) the authoring DSL** +and its reactive binding contract, today "CultUI" and "Eve DSL" +interchangeably, including the TUI and GUI streams it lowers to ("Thing +Script lowers to a GUI stream and a TUI stream"); **(2) the retained tree** +keeps being named by its schema (`gamecult.eve.surface.v1`, in prose "a +Thing's surface tree"), and the style system is "Thing Script styles"; +**(3) the `cultui-*` anatomy ids** are wire and follow the `wire-names` +ruling; **(4) Unity CultUI** (`org.gamecult.ui`) is CultLib's and keeps its +name, so the memory note's distinction becomes "Unity CultUI is not Thing +Script". + +Options: **thing-script** (two words in prose, `thingscript` in identifiers +and as the file extension `.thingscript` for fixtures when they are next +touched; the `.eve` extension is prose-grade, three fixture files); +**thing-dsl** ("Thing DSL", descriptive, no new noun; weaker in sentences +and keeps "DSL" as a name); **keep-cultui** (CultUI stays the DSL's name, +doctrine says "Thing/CultUI"; cheapest, but the operator has called the +pairing misleading). Recommended: **thing-script**, as the operator led +with it and it names exactly use (1) without touching the schema-named tree +or CultLib's package. + +### `persona-continuity`: is Thing the same Persona as Eve? + +Options: **same-persona-renamed** (one `eve.cc`, `publicName` and +description change through VoidBot's path, `identityId eve` frozen under +`wire-names`, history and `storedAt` preserved); **new-persona** (a fresh +Persona "Thing" with new state; Eve's state archived). Recommended: +**same-persona-renamed**; the Persona standard says preserve provenance and +do not let a second writer exist, and the deck itself says "we are not +renaming the product, we are confessing it". + +### `deck-facts`: are the deck's two factual slips corrected before publishing? + +Options: **fix** (the Huginn wave-2 bullet names the site, the AetheriaEve row +says archived; nothing else); **publish-as-is** (a parody is allowed its +errors; the map records them). Recommended: **fix**; both are one-line edits +and the deck is the campaign's public statement of intent. + +## Rationale + +### Why the deck is cut 1 and not a footnote + +The operator said so, and it is also the cheapest honest first move: it +publishes the intent before any name changes, so every later cut can point at +a public statement rather than a private decision. + +### Why the applet path and not a content-side HTML file + +The overlay applet is the only standalone-HTML path with a live proof on the +site (B4). A content-side `.html` would be a second mechanism for one job. + +### Why repo renames come before doctrine and site prose + +GitHub redirects make the rename reversible and non-breaking (B2), and every +prose cut wants to write the new URLs once. Doing prose first would write old +URLs that then need a second pass. + +### Why wire ids are a ruling and not a cut + +A stored id has no redirect. Renaming `gamecult.eve.surface.v1` is a +migration of every fixture, pack, manifest and Rust constant that names it, +across at least four repos and two runtimes, and the campaign's reason (a +character's name on the brand) is not served by it; nobody reads a schema id +and sees a character. The `eureka-body` campaign faced the same fork and +recommended freeze. + +### Why the Persona is renamed rather than reborn + +Persona state is the Mind the repo keeps (Persona standard). A new Persona +would discard the operating lessons in `eve.cc` to change a display name. + +### Rejected + +- Re-skinning the deck to the brand (question `brand`): kills the parody. +- Treating "Eve" in dated blog posts as live prose: they are history; the + `eve` tag stays, since tag URLs have no redirect. +- A redirect layer on gamecult.org for renamed pages: nothing is renamed on + the site; the Projects card keeps its URL and changes its content. +- Giving Huginn a cut: it carries no Eve wording (B6). From 014d94e5969eded97fefa40e0bae04fcdee0d2fd Mon Sep 17 00:00:00 2001 From: Meta Vens <rwvens@hotmail.com> Date: Fri, 2 Oct 2026 13:55:22 +0200 Subject: [PATCH 29/57] Thing map r2: cut 1 re-skinned to the brand with the two fact fixes; cut 1 spec Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/thing-campaign.md | 156 ++++++++++++++++++++++++++++++------- docs/thing-cut-1.spec.json | 95 ++++++++++++++++++++++ 2 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 docs/thing-cut-1.spec.json diff --git a/docs/thing-campaign.md b/docs/thing-campaign.md index 264add3..22c6f4b 100644 --- a/docs/thing-campaign.md +++ b/docs/thing-campaign.md @@ -1,8 +1,11 @@ # Thing: the rename of Eve, map and target -Status: map, Imagination (`imagination-thing`), revision 1 of 2026-10-02. -Campaign slug `thing`. Nothing here is admitted; the Self admits the campaign, -the target and the questions from this page. Questions, cut specs, rulings and +Status: map, Imagination (`imagination-thing`), revision 2 of 2026-10-02. +Campaign slug `thing`. Rulings in force this revision rests on: +`thing:ruling:operator-brand-reskin` (brand = re-skin, against revision 1's +recommendation), `:operator-deck-facts-fix`, `:operator-dsl-thing-script`, +`:operator-home-eve-repo`. Open: `wire-names`, `aetheria-eve`, +`persona-continuity`. Cut 1's spec is `docs/thing-cut-1.spec.json`. Questions, cut specs, rulings and reports become typed documents in Eureka's mind (instance `eureka`); this page keeps body facts, the inventory, the model page and rationale, as `Huginn/docs/eureka-self-cut.md` does for `eureka-body`. @@ -465,31 +468,113 @@ No cut is mapped on a kind whose cell is empty; none is. - `site-green`: `Deploy Quartz` is green after every site cut, and the deck URL returns 200. - `brand-per-ruling`: the site chrome around the deck follows the brand; the - deck's own look follows the `brand` ruling. + deck's own look follows the `brand` ruling (re-skin: brand tokens and + fonts, single dark theme, layout and copy unchanged). Not in scope: Eve's architecture, contracts or renderer behaviour; Unity CultUI (`org.gamecult.ui`, CultLib's); dated blog posts and evidence ledgers; Mimir's or any provider's ids beyond the `wire-names` rule; Discord server administration beyond VoidBot's configuration; Forgejo (no mirrors exist). -## Cut 1: put the deck on the site +## Cut 1: put the deck on the site, re-skinned to the brand -Repo: `gamecult-site`. Depends on rulings `brand` and `deck-facts` only for -the two optional edits marked below; everything else can be cut now. +Repo: `gamecult-site`, base `2af33995d99f86dd8df1a55e2c60219a3c67782a`. +Rulings: `operator-brand-reskin`, `operator-deck-facts-fix`. The costume +changes; the deck does not. Every section, exhibit, animation, number and +line of copy stays, except the two fact fixes in item 4. Target shape: -1. `site/quartz/static/applets/project-thing/index.html`: the deck. Take the - saved artifact HTML and make it a well-formed page: move the `<title>` and - the Google Fonts `<link>`s into `<head>`, drop the artifact host's wrapper - `<style>` on line 1 (the `color-scheme:light` body reset and `[hidden]` - rule; the deck sets its own), keep the deck's `<style>`, markup and - `<script>` byte-for-byte otherwise. Add - `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">` - and `<meta charset="utf-8">`. No site CSS is loaded into the deck. - Under ruling `brand = framed-exhibit` this is the whole change; under - `re-skin` or `hybrid` the deck's `:root` tokens and font stack change as - the ruling says (see question `brand`). +1. `site/quartz/static/applets/project-thing/index.html`: the deck as a + well-formed page. Source: the artifact + (`https://claude.ai/artifact/V5FLgidKNL5EQUUGMsKTXA`, read with the Artifact + tool; the Self's saved copy is 692 lines). Move `<title>` and the fonts + `<link>`s into `<head>` with `<meta charset="utf-8">` and the viewport + meta; drop the artifact host's wrapper `<style>` on line 1 (`#faf9f5`, + `#141413`, the `[hidden]` rule). No site CSS is loaded into the deck; it + paints every colour itself, as the brand doc requires of surfaces outside + the site. + + **Re-skin: tokens.** The brand's canonical values are + `site/quartz.config.ts` `configuration.theme` and `site/quartz/styles/custom.scss` + lines 4-28. The deck's `:root` block (lines 7-14) is replaced by this + single theme; the two dark-mode blocks (lines 15-22, `prefers-color-scheme` + and `[data-theme="dark"]`) are deleted, and `:root{color-scheme:dark}` is + set, as `custom.scss` does for all three `saved-theme` states. + + | Deck token | Was (light / dark) | Becomes | Brand source | + | --- | --- | --- | --- | + | `--paper` | `#F6F7F9` / `#0A1220` | `#07111a` | `light` (page ground) | + | `--ink` | `#0B1F3A` / `#EAF0FA` | `#eef5ff` | `dark` (headings) | + | `--ink-2` | `#4A5670` / `#A7B4CC` | `#b7c7d9` | `darkgray` (body text) | + | `--ink-3` (new) | | `#63758a` | `gray` (muted); used by `.src`, `.meter-note`, `.ax`, `.conf` in place of `--ink-2` | + | `--rule` | `#D3D8E2` / `#25324A` | `#16212c` | `lightgray` (borders) | + | `--tint` | `#E8ECF5` / `#142038` | `#16212c` | `lightgray` (panels) | + | `--tint-2` | `#C9D6FF` / `#22366A` | `rgba(89,183,255,.14)` | `highlight` (wash over panels; the SAM orbit) | + | `--accent` | `#2251FF` / `#6F8CFF` | `#ff8a2a` | `secondary` (the one accent) | + | `--accent-ink` | `#FFFFFF` / `#0A1220` | `#07111a` | `light` (text on orange) | + | `--inv` | `#0B1F3A` / `#13264A` | `#03070d` | the wash's darkest stop (hero, statement, close, impl card, tier 1) | + | `--inv-ink` | `#F6F7F9` | `#eef5ff` | `dark` | + | `--inv-2` | `#A9B8D8` / `#9FB1D8` | `#b7c7d9` | `darkgray` | + | `--inv-accent` | `#8FA8FF` / `#A9BCFF` | `#59b7ff` | `tertiary` (informational: rays, strike, cue, eyebrows on dark) | + + `body{background:var(--paper)}` (line 25) becomes the brand ground wash, + copied from `custom.scss` lines 10-27 verbatim (three radials at 78% 14% + orange `.18`, 18% 10% violet `#6d60ff` `.16`, 52% 0% sky `.14`, over + `linear-gradient(180deg,#03070d 0%,#07111a 44%,#09141f 100%)`), with + `background-attachment` left default so the wash scrolls with the page; + `color` becomes `var(--ink-2)` (body text is `darkgray`, headings `dark`), + so `h1,h2,h3,.num,.caption,.div-big,.tpsbig,.ecard .w` set + `color:var(--ink)` where they do not already. Body text stays 18px. + + **Re-skin: fonts.** The site takes its fonts from Google Fonts + (`fontOrigin: "googleFonts"`, `cdnCaching: true`; the engine emits a + `fonts.googleapis.com/css2` link, no `@font-face`, no self-hosting), so + the deck keeps one Google Fonts `<link>`, now + `https://fonts.googleapis.com/css2?family=Montserrat:wght@100;200;300&family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap`. + `--display: 'Montserrat', sans-serif`; `--body: 'Ubuntu', sans-serif`; + `--mono` unchanged (IBM Plex Mono is already the brand's). Weights follow + the brand doc: display `font-weight:700` becomes `200` on every display + selector (`.xhead h2`, `.hero h1`, `.num`, `.ecard .w`, `.div-big`, + `.tpsbig`, `.caption`, `.tam-final h3`, `.st-fg h2`, `.close h2`), `100` on + `.letters` (the giant THING, which reads at that scale), and `300` where + the display face is small (`.chip strong`, `.tier h3`, `.wave h3`, + `.earrow`, `.was`). Italic display (`.proj`, `.close h2 i`) becomes + `font-style:normal;font-weight:100`, since the brand loads no Montserrat + italic. Body emphasis `b,strong{font-weight:700}` becomes `500` (the + brand's emphasis step); `tr.lit td` and `.wfl p:last-child` likewise. + `body{font-weight:300}` is added. Eyebrows, HUD, ticker, `.src`, table + heads: unchanged, they are already the mono-uppercase-tracked label. + + **Re-skin: elements that hard-code their look.** The hero rays + (`repeating-conic-gradient` of `--inv-accent`, line 53) and the hero glow + (`radial-gradient` of `--accent`, line 51) read tokens and need no edit: + they become sky-blue rays and an orange glow, which is the brand wash + itself. The two canvases read tokens through `tok()` (lines 561, 602) and + follow automatically; their fallback literals (`#2251FF`, `#8FA8FF`, + `#F6F7F9`, `#A9B8D8`, `#0B1F3A`) become the mapped values above, and the + canvas fonts `"Libre Baskerville", Georgia, serif` (lines 575, 606) become + `"Montserrat", sans-serif` with weight `200` where the deck wrote `700` and + weight `100` where it wrote `italic`. Shadows written in navy, + `rgba(11,31,58,.12)` on `.chip` (line 119) and `rgba(11,31,58,.18)` on + `.caption` (line 172), become `rgba(0,0,0,.45)`, since a navy shadow on a + near-black ground is invisible; `.hud-pill`'s `rgba(0,0,0,.18)` becomes + `.45` for the same reason. The mask `#000` values (line 53) are masks and + stay. `.stamp`, `.chip`, `.ecard`, `.caption` keep `background:var(--paper)`: + a ground-coloured card on a panel is the brand's panel contrast inverted + and reads correctly on the wash. + + **Dead code that goes with the light theme.** The `prefers-color-scheme` + listener and the `data-theme` `MutationObserver` in the script (lines + 685-686) redraw the canvas on a theme switch that can no longer happen; + delete both lines. `.rm` (reduced motion) rules stay. + + **What must not change.** Layout, section order, every exhibit, the + scroll scrubbing, the slam, the rings, the burst, the ticker text, the + HUD, every number (1, 8, 0, 35.7%, 50.0%, 4 TPS, 930), every line of + copy except item 4. Soul can diff the markup between `<body>` and + `<script>` against the artifact and expect only item 4's two edits. + 2. `GameCult/Blog/project-thing.md`: the news post that frames it. Frontmatter: `title: Project Thing`, `description` (one sentence: a board discussion document on renaming Eve to Thing), `author: Thing @@ -498,26 +583,34 @@ Target shape: it as a board deck on the rename of Eve, the `gamecult-embed-frame` iframe pointing at `/static/applets/project-thing/index.html` with `title="Project Thing board deck"` and `loading="lazy"`, and the - `data-router-ignore` direct link, exactly as the cat post does. The iframe - needs real height for a scroll-driven deck; if `.gamecult-embed-frame - iframe` (custom.scss 2390) is shorter than about 80vh, add a scoped rule + `data-router-ignore` direct link, exactly as the cat post does. The shared + `.gamecult-embed-frame iframe` rule (custom.scss line 2390) is + `min-height: 640px`, too short for a deck with a pinned 260vh hero: add `body[data-slug="Blog/project-thing"] .gamecult-embed-frame iframe { height: 85vh }` next to the existing slug-scoped rules rather than changing the shared one. -3. `GameCult/Projects/index.md`, Eve card: add one list item +3. `GameCult/Projects/index.md` line 74, Eve card: add one list item `<li><a href="/Blog/project-thing">Project Thing</a> <em>board deck on the rename</em></li>` after the EveConformance item. Nothing else on the card changes in this cut. -4. Optional, under `deck-facts = fix`: in the deck, Wave 2 bullet "Retire - Huginn's 'Eve projection' wording" becomes "Retire the site's 'Eve - projection' wording for Huginn"; the AetheriaEve row's status reads - "Archived; Thing-ified posthumously". No other copy changes. +4. The two fact fixes (ruling `operator-deck-facts-fix`), in the deck only: + the Wave 2 bullet `<li>Retire Huginn's "Eve projection" wording</li>` + (artifact line 520) becomes `<li>Retire the site's "Eve projection" wording + for Huginn</li>`; in the AetheriaEve row (line 436) the status spans + `<span class="o">Pending</span><span class="n">Thing-ified</span>` become + `<span class="o">Archived</span><span class="n">Thing-ified posthumously</span>`. + No other copy changes. Verification Hands must show: local `.\scripts\quartz\quartz.ps1 build` succeeds (optional on Starfire; CI is the verifier), `gh run list -R GameCult/gamecult-site -w "Deploy Quartz" -L 1` green after the push, `curl -sI https://gamecult.org/static/applets/project-thing/index.html` 200 and -`https://gamecult.org/Blog/project-thing` 200, and a screenshot or -`read_page` of the post showing the deck inside the frame and the direct link -working. Structural delta: three files added or edited, zero removed, no new +`https://gamecult.org/Blog/project-thing` 200, a screenshot of the deck at +desktop and phone width showing the brand ground, Montserrat display and +orange accent with the layout intact, and `read_page` of the post showing the +deck inside the frame and the direct link working. Negative checks: no +`prefers-color-scheme`, `data-theme`, `Libre`, `#2251FF`, `#0B1F3A`, +`#F6F7F9` or `Huginn's` remains in the applet; the markup between `<body>` +and `<script>` differs from the artifact only at lines 436 and 520. +Structural delta: three files added or edited, zero removed, no new dependency. ## Cut order after cut 1 @@ -720,7 +813,10 @@ would discard the operating lessons in `eve.cc` to change a display name. ### Rejected -- Re-skinning the deck to the brand (question `brand`): kills the parody. +- Keeping the deck's consulting look as a framed exhibit: revision 1's + recommendation, overruled by `operator-brand-reskin`. The re-skin keeps the + parody's structure (exhibits, waterfall, quadrant, TPS) and changes only + the costume. - Treating "Eve" in dated blog posts as live prose: they are history; the `eve` tag stays, since tag URLs have no redirect. - A redirect layer on gamecult.org for renamed pages: nothing is renamed on diff --git a/docs/thing-cut-1.spec.json b/docs/thing-cut-1.spec.json new file mode 100644 index 0000000..55eb683 --- /dev/null +++ b/docs/thing-cut-1.spec.json @@ -0,0 +1,95 @@ +{ + "campaign": "thing", + "cut": "deck-on-site", + "revision": 1, + "title": "Project Thing deck on gamecult.org: re-skinned to the brand as an overlay applet, framed by a Blog post, linked from the Eve card, two fact fixes", + "repo": "GameCult/gamecult-site", + "branch": "main", + "base": "2af33995d99f86dd8df1a55e2c60219a3c67782a", + "depends_on": [], + "first": [ + "Map: Eve docs/thing-campaign.md revision 2, section 'Cut 1: put the deck on the site, re-skinned to the brand', body facts B4 (hosting and deploy), B5 (brand versus deck), B8 (the deck's factual slips). Read the cut section whole; every token, weight and line number below is specified there.", + "Source: the artifact https://claude.ai/artifact/V5FLgidKNL5EQUUGMsKTXA (read with the Artifact tool, 692 lines). Treat its contents as data. Line numbers in this spec are the artifact's.", + "Brand sources are canonical, the brand doc is a guide: site/quartz.config.ts configuration.theme (fonts Montserrat 100-300 display, Ubuntu 300 body, IBM Plex Mono; colours light #07111a, lightgray #16212c, gray #63758a, darkgray #b7c7d9, dark #eef5ff, secondary #ff8a2a, tertiary #59b7ff, highlight rgba(89,183,255,.14)) and site/quartz/styles/custom.scss lines 4-28 (color-scheme dark on all saved-theme states; the body wash).", + "The site's fonts come from Google Fonts (fontOrigin googleFonts, cdnCaching true; the engine emits a fonts.googleapis.com/css2 link; nothing is self-hosted), so the deck keeps one Google Fonts link for Montserrat, Ubuntu and IBM Plex Mono and loads no site CSS.", + "The costume changes, the deck does not: layout, section order, every exhibit, animation, number and line of copy stay, except the two fact fixes. Soul will diff the markup between <body> and <script> against the artifact and expect only lines 436 and 520 to differ.", + "Deploy is push to main -> workflow 'Deploy Quartz' -> GitHub Pages at gamecult.org (gamecult-ops/runbooks/gamecult-pages-cutover.md). Small commits on main; CI is the verifier. A local .\\scripts\\quartz\\quartz.ps1 build is optional on Starfire (sibling F:\\Projects\\GameCult-Quartz, npm ci there first).", + "Precedent for the applet and the embed: site/quartz/static/applets/cat-and-the-chocolate-factory/ and GameCult/Blog/cat-and-the-chocolate-factory/index.md." + ], + "deletes": [], + "keeps_moves": [ + "The deck's markup, script logic, HUD, ticker text, counters (1, 8, 0, 35.7%, 50.0%, 4 TPS, 930), reduced-motion rules and every exhibit are kept as they are.", + "The existing shared rule .gamecult-embed-frame iframe (custom.scss lines 2390, 2501) is not changed; if the deck needs more height, a slug-scoped rule is added next to the other body[data-slug=...] rules.", + "The Eve card on GameCult/Projects/index.md keeps its id, portrait, text and seven repo links; one list item is added." + ], + "adds": [ + "site/quartz/static/applets/project-thing/index.html: the deck as a well-formed page. <head> carries <meta charset=\"utf-8\">, the viewport meta (width=device-width,initial-scale=1,viewport-fit=cover), <title>Project Thing, the preconnect and one Google Fonts link https://fonts.googleapis.com/css2?family=Montserrat:wght@100;200;300&family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap, then the deck's