From 08da3eddd2af4f560386dcc58d38d198d7ce52be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 9 Jul 2026 15:46:55 +0100 Subject: [PATCH] Add Unity UI Toolkit projection capture evidence --- docs/eve-migration-roadmap.md | 18 + docs/parity-testing-harness.md | 8 +- .../README.md | 10 + .../eve-runtime-capability.json | 19 +- .../eveunity-split-handoff.json | 5 +- ....unity_uitoolkit_projection.v1.schema.json | 134 ++++++++ scripts/run-eveunity-lifecycle-smoke.ps1 | 5 + .../run-eveunity-uitoolkit-capture-smoke.ps1 | 76 +++++ scripts/run-parity-harness.ps1 | 1 + ...ntime-owner-conformance-consumer-smoke.ps1 | 4 +- .../eveunity-uitoolkit-capture-artifact.mjs | 321 ++++++++++++++++++ ...eunity-uitoolkit-capture-artifact.test.mjs | 60 ++++ tools/parity/parity-manifest.json | 33 +- 13 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json create mode 100644 scripts/run-eveunity-uitoolkit-capture-smoke.ps1 create mode 100644 tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs create mode 100644 tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 96d08b8..22369b5 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -575,6 +575,16 @@ Recently cut: `scripts/run-eveunity-capture-contract-smoke.ps1` proves that request path without pretending a Unity PNG has been captured. The actual capture artifact remains a split blocker for EveUnity. +- Unity UI Toolkit now also has schema-backed semantic projection evidence. + `tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs` lowers + `web/fixtures/aetheria-world-surface.json` into + `gamecult.eve.unity_uitoolkit_projection.v1`, writes + `artifacts/eveunity-uitoolkit-capture/latest/unity-uitoolkit-projection.json`, + and exports it as `runtime.captureArtifacts[]`. The builder also proves Sai, + Norn, and TeX stay sidecar plugin projections when a visual-novel surface is + lowered through UI Toolkit. The remaining EveUnity capture blocker is an + owner-repo Unity editor or batchmode PNG, not absence of a typed projection + artifact. - Unity scene capture now has the same semantic artifact boundary as Electron without pretending it is a rendered Unity frame. `tools/eveunity/eveunity-scene-capture-artifact.mjs` lowers `web/fixtures/aetheria-world-surface.json` into @@ -853,6 +863,14 @@ Recently cut: `scripts/run-split-target-conformance-consumer-smoke.ps1`, and `scripts/run-aetheria-conformance-consumer-smoke.ps1` assert that boundary from the manifest/export. +- Unity UI Toolkit now attaches a schema-backed semantic projection capture + artifact at + `artifacts/eveunity-uitoolkit-capture/latest/unity-uitoolkit-projection.json`. + Parity validates it against + `gamecult.eve.unity_uitoolkit_projection.v1`, exports it as + `runtime.captureArtifacts[]`, and the runtime-owner consumer smoke asserts it. + This proves the UI Toolkit command-surface projection is typed evidence while + leaving real Unity editor or batchmode PNG capture as the EveUnity blocker. - Unity UI Toolkit now has a first-party `norn.graph` projection-adapter proof for `embed.norn`. `NornGraphUiToolkitProjectionAdapter` owns the native embedded graph shell and graph command emission path, while the Norn sidecar diff --git a/docs/parity-testing-harness.md b/docs/parity-testing-harness.md index 734638d..df9fdc5 100644 --- a/docs/parity-testing-harness.md +++ b/docs/parity-testing-harness.md @@ -400,7 +400,13 @@ exist. The current lifecycle proof is deliberately split: - capture: `tools/eveunity/eveunity-capture-contract.mjs` builds a typed `gamecult.eve.runtime_capture_request.v1` request from the runtime capability manifest and Aetheria provider advertisement; Unity editor or batchmode PNG - capture remains a split blocker. + capture remains a split blocker. `tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs` + also writes a schema-backed + `gamecult.eve.unity_uitoolkit_projection.v1` JSON projection artifact at + `artifacts/eveunity-uitoolkit-capture/latest/unity-uitoolkit-projection.json`. + That artifact proves provider-advertised UI Toolkit projection, embedded + slots, and sidecar plugin projection metadata without pretending to be a + rendered Unity frame. TUI capture evidence is already typed even though EveTui remains incubating. `tools/evetui/evetui-capture-artifact.mjs` lowers the Aetheria world surface diff --git a/packages/org.gamecult.eve.unity-uitoolkit/README.md b/packages/org.gamecult.eve.unity-uitoolkit/README.md index 274b3cc..6a3556d 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/README.md +++ b/packages/org.gamecult.eve.unity-uitoolkit/README.md @@ -66,6 +66,10 @@ construction through `tools/eveunity/eveunity-capture-contract.mjs`. That request is derived from the runtime capability manifest and Aetheria's provider advertisement, then points at the PNG artifact EveUnity must produce later. It does not fake a screenshot; it makes the capture input contract executable. +`tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs` also emits a typed +`gamecult.eve.unity_uitoolkit_projection.v1` JSON projection artifact for the +Aetheria world surface. That artifact is runtime projection evidence, not +provider state and not a Unity frame capture. The manifest also declares `worldSurfaceLowering` target `unity-uitoolkit`. That means this package can lower provider-advertised `interactive-world` and @@ -100,6 +104,12 @@ graduate to `EveUnity`. The capture contract smoke is: powershell -ExecutionPolicy Bypass -File .\scripts\run-eveunity-capture-contract-smoke.ps1 ``` +The semantic projection capture smoke is: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\run-eveunity-uitoolkit-capture-smoke.ps1 +``` + The release contract smoke is: ```powershell diff --git a/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json index f431c49..e83b2e1 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json @@ -147,6 +147,9 @@ "tools/eveunity/eveunity-capture-contract.mjs", "tools/eveunity/eveunity-capture-contract.test.mjs", "scripts/run-eveunity-capture-contract-smoke.ps1", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs", + "scripts/run-eveunity-uitoolkit-capture-smoke.ps1", "web/fixtures/aetheria.provider-advertisement.json" ], "pendingProofs": [ @@ -155,7 +158,19 @@ } }, "capture": { - "status": "semantic", - "note": "Unity UI Toolkit lowers embedded document slots through a resolver-backed child surface host." + "status": "json-projection", + "note": "Unity UI Toolkit attaches a provider-advertisement-backed gamecult.eve.unity_uitoolkit_projection.v1 JSON projection artifact; Unity editor or batchmode PNG capture remains an EveUnity split blocker.", + "artifacts": [ + { + "kind": "json-projection", + "schema": "gamecult.eve.unity_uitoolkit_projection.v1", + "path": "artifacts/eveunity-uitoolkit-capture/latest/unity-uitoolkit-projection.json", + "runtimeId": "unity-uitoolkit", + "providerId": "aetheria", + "surfaceId": "aetheria.daemon.game", + "sourceSurfacePath": "web/fixtures/aetheria-world-surface.json", + "requestPath": "artifacts/eveunity-uitoolkit-capture/latest/capture-request.json" + } + ] } } diff --git a/packages/org.gamecult.eve.unity-uitoolkit/eveunity-split-handoff.json b/packages/org.gamecult.eve.unity-uitoolkit/eveunity-split-handoff.json index b02c913..a38a4ae 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/eveunity-split-handoff.json +++ b/packages/org.gamecult.eve.unity-uitoolkit/eveunity-split-handoff.json @@ -43,7 +43,10 @@ "currentPaths": [ "tools/eveunity/eveunity-capture-contract.mjs", "tools/eveunity/eveunity-capture-contract.test.mjs", - "scripts/run-eveunity-capture-contract-smoke.ps1" + "scripts/run-eveunity-capture-contract-smoke.ps1", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs", + "scripts/run-eveunity-uitoolkit-capture-smoke.ps1" ], "destinationOwner": "EveUnity", "replacementProof": "Unity batchmode or editor capture artifact attached to the runtime conformance report." diff --git a/schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json b/schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json new file mode 100644 index 0000000..397c46a --- /dev/null +++ b/schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.unity_uitoolkit_projection.v1", + "title": "Eve Unity UI Toolkit Projection", + "type": "object", + "required": [ + "type", + "schema", + "runtimeId", + "providerId", + "surfaceId", + "projectionKind", + "commandBoundary", + "receiptSchema", + "ownership", + "root" + ], + "properties": { + "type": { + "const": "unity-uitoolkit-projection" + }, + "schema": { + "const": "gamecult.eve.unity_uitoolkit_projection.v1" + }, + "runtimeId": { + "const": "unity-uitoolkit" + }, + "providerId": { + "type": "string", + "minLength": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "projectionKind": { + "type": "string", + "minLength": 1 + }, + "commandBoundary": { + "type": "string", + "minLength": 1 + }, + "receiptSchema": { + "type": "string", + "minLength": 1 + }, + "ownership": { + "type": "string", + "minLength": 1 + }, + "root": { + "type": "object", + "required": [ + "id", + "componentKind", + "visualElementKind", + "classNames", + "stateBindingCount", + "embeddedDocumentCount", + "children" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "componentKind": { + "type": "string" + }, + "visualElementKind": { + "type": "string", + "minLength": 1 + }, + "classNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "props": { + "type": "object" + }, + "layout": { + "type": "object" + }, + "style": { + "type": "object" + }, + "stateBindingCount": { + "type": "number" + }, + "embeddedDocumentCount": { + "type": "number" + }, + "embeddedDocuments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "slotId", + "documentId" + ], + "properties": { + "slotId": { + "type": "string", + "minLength": 1 + }, + "documentId": { + "type": "string", + "minLength": 1 + }, + "schemaId": { + "type": "string" + }, + "presentationKind": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "pluginProjection": { + "type": "object" + }, + "children": { + "type": "array" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/scripts/run-eveunity-lifecycle-smoke.ps1 b/scripts/run-eveunity-lifecycle-smoke.ps1 index ab7ec58..5e4b894 100644 --- a/scripts/run-eveunity-lifecycle-smoke.ps1 +++ b/scripts/run-eveunity-lifecycle-smoke.ps1 @@ -216,6 +216,11 @@ if ($LASTEXITCODE -ne 0) { throw "EveUnity capture contract smoke failed with exit code $LASTEXITCODE" } +& (Join-Path $projectRoot "scripts\run-eveunity-uitoolkit-capture-smoke.ps1") +if ($LASTEXITCODE -ne 0) { + throw "EveUnity UI Toolkit capture smoke failed with exit code $LASTEXITCODE" +} + & (Join-Path $projectRoot "scripts\run-aetheria-unity-package-smoke.ps1") if ($LASTEXITCODE -ne 0) { throw "Aetheria Unity package smoke failed with exit code $LASTEXITCODE" diff --git a/scripts/run-eveunity-uitoolkit-capture-smoke.ps1 b/scripts/run-eveunity-uitoolkit-capture-smoke.ps1 new file mode 100644 index 0000000..eb1ab03 --- /dev/null +++ b/scripts/run-eveunity-uitoolkit-capture-smoke.ps1 @@ -0,0 +1,76 @@ +param( + [string] $CapabilityManifestPath = "packages\org.gamecult.eve.unity-uitoolkit\eve-runtime-capability.json", + [string] $AdvertisementPath = "web\fixtures\aetheria.provider-advertisement.json", + [string] $SurfacePath = "web\fixtures\aetheria-world-surface.json", + [string] $OutputPath = "artifacts\eveunity-uitoolkit-capture\latest\unity-uitoolkit-projection.json", + [string] $RequestOutputPath = "artifacts\eveunity-uitoolkit-capture\latest\capture-request.json", + [string] $Stamp = "latest" +) + +$ErrorActionPreference = "Stop" + +$projectRoot = Split-Path -Parent $PSScriptRoot +function Resolve-RepoPath([string] $candidate) { + if ([System.IO.Path]::IsPathRooted($candidate)) { + return $candidate + } + return Join-Path $projectRoot $candidate +} + +$absoluteCapabilityPath = Resolve-RepoPath $CapabilityManifestPath +$absoluteAdvertisementPath = Resolve-RepoPath $AdvertisementPath +$absoluteSurfacePath = Resolve-RepoPath $SurfacePath +$absoluteOutputPath = Resolve-RepoPath $OutputPath +$absoluteRequestOutputPath = Resolve-RepoPath $RequestOutputPath + +foreach ($path in @($absoluteCapabilityPath, $absoluteAdvertisementPath, $absoluteSurfacePath)) { + if (-not (Test-Path -LiteralPath $path)) { + throw "EveUnity UI Toolkit capture input not found: $path" + } +} + +node --test (Join-Path $projectRoot "tools\eveunity\eveunity-uitoolkit-capture-artifact.test.mjs") +if ($LASTEXITCODE -ne 0) { + throw "EveUnity UI Toolkit capture artifact tests failed with exit code $LASTEXITCODE" +} + +node (Join-Path $projectRoot "tools\eveunity\eveunity-uitoolkit-capture-artifact.mjs") ` + --capability $absoluteCapabilityPath ` + --advertisement $absoluteAdvertisementPath ` + --surface $absoluteSurfacePath ` + --output $absoluteOutputPath ` + --request-output $absoluteRequestOutputPath ` + --stamp $Stamp +if ($LASTEXITCODE -ne 0) { + throw "EveUnity UI Toolkit capture artifact builder failed with exit code $LASTEXITCODE" +} + +foreach ($path in @($absoluteOutputPath, $absoluteRequestOutputPath)) { + if (-not (Test-Path -LiteralPath $path)) { + throw "EveUnity UI Toolkit capture artifact output missing: $path" + } +} + +$projection = Get-Content -LiteralPath $absoluteOutputPath -Raw | ConvertFrom-Json +foreach ($expectation in @( + @{ field = "schema"; value = "gamecult.eve.unity_uitoolkit_projection.v1" }, + @{ field = "runtimeId"; value = "unity-uitoolkit" }, + @{ field = "providerId"; value = "aetheria" }, + @{ field = "surfaceId"; value = "aetheria.daemon.game" }, + @{ field = "commandBoundary"; value = "aetheria.daemon.commands" }, + @{ field = "receiptSchema"; value = "aetheria.eve_command_acceptance_status.v1" }, + @{ field = "artifactKind"; value = "json-projection" }, + @{ field = "captureKind"; value = "unity-uitoolkit-projection-json" } +)) { + if ($projection.($expectation.field) -ne $expectation.value) { + throw "EveUnity UI Toolkit projection $($expectation.field) expected $($expectation.value) got $($projection.($expectation.field))" + } +} +if (-not $projection.root.id) { + throw "EveUnity UI Toolkit projection capture must contain a root UI node" +} +if (-not (@($projection.root.classNames) -contains "eve-component")) { + throw "EveUnity UI Toolkit projection capture root missing eve-component class" +} + +Write-Host "EveUnity UI Toolkit capture smoke passed: $absoluteOutputPath" diff --git a/scripts/run-parity-harness.ps1 b/scripts/run-parity-harness.ps1 index 28bc5b9..56e0f7f 100644 --- a/scripts/run-parity-harness.ps1 +++ b/scripts/run-parity-harness.ps1 @@ -20,6 +20,7 @@ try { -OutputPath "artifacts\web-reference-layout-probe\latest\sai-vn.json" ` -Port 8893 ` -SkipBuild + & (Join-Path $PSScriptRoot "run-eveunity-uitoolkit-capture-smoke.ps1") & (Join-Path $PSScriptRoot "run-evetui-capture-smoke.ps1") node .\tools\parity\run-parity.mjs & (Join-Path $PSScriptRoot "run-eveconformance-handoff-smoke.ps1") diff --git a/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 b/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 index 879c52a..f02af67 100644 --- a/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 +++ b/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 @@ -46,6 +46,7 @@ node $consumerScript $consumerExport ` --expect-schema gamecult.eve.runtime_lifecycle.v1 ` --expect-schema gamecult.eve.runtime_split_handoff.v1 ` --expect-schema gamecult.eve.electron_shell_projection.v1 ` + --expect-schema gamecult.eve.unity_uitoolkit_projection.v1 ` --expect-schema gamecult.eve.unity_scene_projection.v1 ` --expect-schema gamecult.eve.tui_grid.v1 ` --expect-schema gamecult.eve.web_layout_probe.v1 ` @@ -132,7 +133,8 @@ node $consumerScript $consumerExport ` --expect-runtime-capture-status windows-flutter:golden ` --expect-runtime-capture-status linux-flutter:ssh-golden ` --expect-runtime-capture-status android-flutter:adb-png ` - --expect-runtime-capture-status unity-uitoolkit:semantic ` + --expect-runtime-capture-status unity-uitoolkit:json-projection ` + --expect-runtime-capture-artifact unity-uitoolkit:json-projection:gamecult.eve.unity_uitoolkit_projection.v1:aetheria:aetheria.daemon.game ` --expect-runtime-capture-status unity-scene:json-projection ` --expect-runtime-capture-artifact unity-scene:json-projection:gamecult.eve.unity_scene_projection.v1:aetheria:aetheria.daemon.game ` --expect-runtime-capture-status electron-shell:json-projection ` diff --git a/tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs b/tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs new file mode 100644 index 0000000..19c4573 --- /dev/null +++ b/tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs @@ -0,0 +1,321 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildUnityCaptureRequest, loadJsonFile, writeCaptureRequest } from "./eveunity-capture-contract.mjs"; + +export function buildUnityUiToolkitCaptureArtifact({ + advertisement, + capabilityManifest, + surfaceDocument, + advertisementPath = "", + capabilityManifestPath = "", + stamp = "latest", +} = {}) { + const request = buildUnityCaptureRequest({ + advertisement, + capabilityManifest, + advertisementPath, + capabilityManifestPath, + stamp, + }); + const projection = buildUnityUiToolkitProjection(surfaceDocument, advertisement, request.surfaceId); + validateProjectionArtifact(projection, request); + + return { + request, + projection: { + ...projection, + artifactKind: "json-projection", + captureKind: "unity-uitoolkit-projection-json", + sourceAdvertisementPath: request.sourceAdvertisementPath, + sourceCapabilityManifestPath: request.sourceCapabilityManifestPath, + issuedAtUtc: request.issuedAtUtc, + }, + }; +} + +export function buildUnityUiToolkitProjection(surfaceDocument, providerAdvertisement, requestedSurfaceId = "") { + const selected = selectSurface(providerAdvertisement, requestedSurfaceId); + const document = normalizeSurfaceDocument(surfaceDocument); + if (document.surfaceId !== selected.surfaceId) { + throw new Error(`Surface document ${document.surfaceId} does not match advertised Unity UI Toolkit surface ${selected.surfaceId}.`); + } + + return { + type: "unity-uitoolkit-projection", + schema: "gamecult.eve.unity_uitoolkit_projection.v1", + runtimeId: "unity-uitoolkit", + providerId: selected.providerId, + surfaceId: selected.surfaceId, + projectionKind: selected.worldInteraction.projectionKind, + commandBoundary: selected.worldInteraction.commandBoundary, + receiptSchema: selected.worldInteraction.receiptSchema, + ownership: selected.worldInteraction.ownership, + root: buildUiNode(document.root), + }; +} + +export function writeUnityUiToolkitCaptureArtifact({ request, projection }, { outputPath, requestOutputPath = "" } = {}) { + if (!outputPath) throw new Error("outputPath is required"); + mkdirSync(path.dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(projection, null, 2)}\n`, "utf8"); + if (requestOutputPath) { + writeCaptureRequest(request, requestOutputPath); + } +} + +function selectSurface(providerAdvertisement, requestedSurfaceId = "") { + const provider = normalizeProviderAdvertisement(providerAdvertisement); + const surface = requestedSurfaceId + ? provider.surfaces.find(candidate => candidate.surfaceId === requestedSurfaceId) + : provider.surfaces[0]; + + if (!surface) { + throw new Error(`Provider ${provider.providerId} does not advertise surface ${requestedSurfaceId || "(first)"}`); + } + + return { + providerId: provider.providerId, + surfaceId: surface.surfaceId, + worldInteraction: normalizeWorldInteraction(surface.worldInteraction), + }; +} + +function normalizeProviderAdvertisement(providerAdvertisement) { + if (!providerAdvertisement || typeof providerAdvertisement !== "object") { + throw new Error("Provider advertisement is required."); + } + const providerId = firstString(providerAdvertisement.providerId); + if (!providerId) throw new Error("Provider advertisement missing providerId."); + const surfaces = Array.isArray(providerAdvertisement.surfaces) + ? providerAdvertisement.surfaces + .filter(surface => surface && typeof surface === "object" && surface.surfaceId) + .map(surface => ({ + ...surface, + surfaceId: String(surface.surfaceId), + })) + : []; + if (!surfaces.length) throw new Error(`Provider ${providerId} advertises no surfaces.`); + return { ...providerAdvertisement, providerId, surfaces }; +} + +function normalizeSurfaceDocument(surfaceDocument) { + if (!surfaceDocument || typeof surfaceDocument !== "object") { + throw new Error("Surface document is required."); + } + if (surfaceDocument.schema !== "gamecult.eve.surface.v1") { + throw new Error(`Unexpected surface schema: ${surfaceDocument.schema || ""}`); + } + const surface = objectValue(surfaceDocument.surface); + const surfaceId = firstString(surface.id); + if (!surfaceId) throw new Error("Surface document missing surface.id."); + const root = objectValue(surface.root); + if (!root.id) throw new Error(`Surface document ${surfaceId} missing surface.root.`); + return { ...surfaceDocument, surfaceId, root }; +} + +function buildUiNode(component) { + const source = objectValue(component); + const children = Array.isArray(source.children) ? source.children.map(buildUiNode) : []; + const embeddedDocuments = normalizeEmbeddedDocuments(source.embeddedDocuments); + const componentKind = firstString(source.kind); + const pluginProjection = buildPluginProjection(componentKind, source); + const classNames = [ + "eve-component", + `eve-kind-${safeClass(componentKind)}`, + ...pluginClassNames(pluginProjection), + ]; + return { + id: firstString(source.id), + componentKind, + visualElementKind: visualElementKind(componentKind), + classNames, + props: objectValue(source.props), + layout: objectValue(source.layout), + style: objectValue(source.style), + stateBindingCount: Array.isArray(source.stateBindings) ? source.stateBindings.length : 0, + embeddedDocumentCount: embeddedDocuments.length, + embeddedDocuments, + ...(pluginProjection ? { pluginProjection } : {}), + children, + }; +} + +function normalizeEmbeddedDocuments(value) { + return Array.isArray(value) + ? value + .filter(document => document && typeof document === "object") + .map(document => ({ + slotId: firstString(document.slotId, document.id), + documentId: firstString(document.documentId, document.href, document.url), + schemaId: firstString(document.schemaId, document.schema), + presentationKind: firstString(document.presentationKind, document.kind), + })) + .filter(document => document.slotId || document.documentId) + : []; +} + +function visualElementKind(componentKind) { + if (!componentKind) return "VisualElement"; + if (componentKind === "text" || componentKind === "label" || componentKind.startsWith("text.")) return "Label"; + if (componentKind === "control.button" || componentKind === "control.popup") return "Button"; + if (componentKind === "control.text" || componentKind === "control.input.text") return "TextField"; + if (componentKind === "vn.stage") return "SaiVisualNovelStageElement"; + if (componentKind === "panel.dialogue" || componentKind === "text.dialogue") return "SaiVisualNovelDialogueElement"; + if (componentKind === "rail.actions") return "SaiVisualNovelActionRail"; + if (componentKind === "embed.norn") return "NornGraphElement"; + if (componentKind === "embed.tex") return "TeXMathElement"; + return "VisualElement"; +} + +function pluginClassNames(pluginProjection) { + if (!pluginProjection) return []; + return [ + "eve-plugin-projection", + `eve-plugin-${safeClass(pluginProjection.pluginId)}`, + safeClass(pluginProjection.projectionKind), + ].filter(Boolean); +} + +function buildPluginProjection(componentKind, component) { + const props = objectValue(component.props); + if (componentKind === "vn.stage") { + return { + pluginId: "sai.vn", + projectionKind: "sai-vn-stage", + abiSchema: "gamecult.eve.plugin_abi.v1", + commandBoundary: "sidecar-advertised-plugin-abi", + capabilities: ["vn.stage", "story.choose", "story.continue", "story.jump"], + command: "story.choose", + documentId: firstString(props.storyId, component.id), + semanticOwner: "Sai", + }; + } + + if (componentKind === "embed.norn") { + return { + pluginId: "norn.graph", + projectionKind: "norn-graph", + abiSchema: "gamecult.eve.plugin_abi.v1", + commandBoundary: "sidecar-advertised-plugin-abi", + capabilities: ["embed.norn"], + command: "graph.focus", + documentId: firstString(props.sourceUri, props.documentId, component.id), + semanticOwner: "Norn", + }; + } + + if (componentKind === "embed.tex") { + return { + pluginId: "tex.math", + projectionKind: texProjectionKind(props), + abiSchema: "gamecult.eve.plugin_abi.v1", + commandBoundary: "sidecar-advertised-plugin-abi", + capabilities: ["embed.tex", "tex.inline", "tex.block"], + command: "", + documentId: firstString(props.sourceUri, props.source, component.id), + semanticOwner: "EvePlugins", + }; + } + + return null; +} + +function texProjectionKind(props) { + const display = firstString(props.display, "inline"); + return display === "block" ? "tex-math-block" : "tex-math-inline"; +} + +function normalizeWorldInteraction(value) { + const source = objectValue(value); + return { + projectionKind: firstString(source.projectionKind), + commandBoundary: firstString(source.commandBoundary), + receiptSchema: firstString(source.receiptSchema), + ownership: firstString(source.ownership), + }; +} + +function validateProjectionArtifact(projection, request) { + if (projection.schema !== "gamecult.eve.unity_uitoolkit_projection.v1") { + throw new Error(`Unexpected Unity UI Toolkit projection schema: ${projection.schema || ""}`); + } + for (const [field, expected] of [ + ["runtimeId", request.runtimeId], + ["providerId", request.providerId], + ["surfaceId", request.surfaceId], + ["projectionKind", request.projectionKind], + ["commandBoundary", request.commandBoundary], + ["receiptSchema", request.receiptSchema], + ]) { + if ((projection[field] || "") !== (expected || "")) { + throw new Error(`Unity UI Toolkit projection ${field} expected ${expected || ""} got ${projection[field] || ""}`); + } + } + if (!projection.root || !projection.root.id) { + throw new Error("Unity UI Toolkit projection artifact must contain a root UI node"); + } +} + +function safeClass(value) { + const text = firstString(value, "unknown"); + return text.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "unknown"; +} + +function objectValue(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + } + return ""; +} + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith("--")) continue; + const key = value.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + } else { + args[key] = next; + index += 1; + } + } + return args; +} + +function runCli() { + const args = parseArgs(process.argv.slice(2)); + for (const required of ["advertisement", "capability", "surface", "output"]) { + if (!args[required]) { + throw new Error(`missing required --${required}`); + } + } + const stamp = args.stamp || "latest"; + const artifact = buildUnityUiToolkitCaptureArtifact({ + advertisement: loadJsonFile(args.advertisement), + capabilityManifest: loadJsonFile(args.capability), + surfaceDocument: loadJsonFile(args.surface), + advertisementPath: args.advertisement, + capabilityManifestPath: args.capability, + stamp, + }); + writeUnityUiToolkitCaptureArtifact(artifact, { + outputPath: args.output, + requestOutputPath: args["request-output"] || "", + }); + process.stdout.write(`${args.output}\n`); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; +const modulePath = fileURLToPath(import.meta.url); +if (invokedPath === modulePath) { + runCli(); +} diff --git a/tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs b/tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs new file mode 100644 index 0000000..e74bc71 --- /dev/null +++ b/tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { buildUnityUiToolkitCaptureArtifact, buildUnityUiToolkitProjection } from "./eveunity-uitoolkit-capture-artifact.mjs"; + +const aetheriaAdvertisement = JSON.parse(readFileSync(new URL("../../web/fixtures/aetheria.provider-advertisement.json", import.meta.url), "utf8")); +const capabilityManifest = JSON.parse(readFileSync(new URL("../../packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json", import.meta.url), "utf8")); +const aetheriaSurfaceDocument = JSON.parse(readFileSync(new URL("../../web/fixtures/aetheria-world-surface.json", import.meta.url), "utf8")); +const saiAdvertisement = JSON.parse(readFileSync(new URL("../../web/fixtures/sai-vn.provider-advertisement.json", import.meta.url), "utf8")); +const saiSurfaceDocument = JSON.parse(readFileSync(new URL("../../web/fixtures/sai-vn-surface.json", import.meta.url), "utf8")); + +test("builds Unity UI Toolkit projection capture artifact from provider advertisement and surface", () => { + const { request, projection } = buildUnityUiToolkitCaptureArtifact({ + advertisement: aetheriaAdvertisement, + capabilityManifest, + surfaceDocument: aetheriaSurfaceDocument, + advertisementPath: "web/fixtures/aetheria.provider-advertisement.json", + capabilityManifestPath: "packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json", + stamp: "smoke", + }); + + assert.equal(request.artifactPath, "artifacts/eveunity-uitoolkit-capture/smoke/unity-uitoolkit.png"); + assert.equal(projection.schema, "gamecult.eve.unity_uitoolkit_projection.v1"); + assert.equal(projection.runtimeId, "unity-uitoolkit"); + assert.equal(projection.providerId, "aetheria"); + assert.equal(projection.surfaceId, "aetheria.daemon.game"); + assert.equal(projection.commandBoundary, "aetheria.daemon.commands"); + assert.equal(projection.receiptSchema, "aetheria.eve_command_acceptance_status.v1"); + assert.equal(projection.artifactKind, "json-projection"); + assert.equal(projection.captureKind, "unity-uitoolkit-projection-json"); + assert.equal(projection.root.id, "aetheria.daemon.game.root"); + assert.equal(projection.root.visualElementKind, "VisualElement"); + assert.deepEqual(projection.root.classNames.slice(0, 2), ["eve-component", "eve-kind-surface"]); +}); + +test("keeps Sai, Norn, and TeX semantics in sidecar plugin UI projections", () => { + const advertisement = structuredClone(saiAdvertisement); + advertisement.surfaces[0].worldInteraction = { + projectionKind: "provider-authored-visual-novel-surface", + commandBoundary: "sai.vn.plugin.commands", + receiptSchema: "gamecult.eve.command_receipt.v1", + loweringTargets: ["unity-uitoolkit"], + ownership: "provider-owns-story-state-plugin-sidecars-own-nested-semantics", + }; + + const projection = buildUnityUiToolkitProjection(saiSurfaceDocument, advertisement, "sai.visual_novel.surface"); + const graphNode = projection.root.children.find(child => child.id === "sai.graph"); + const texLayer = projection.root.children.find(child => child.id === "sai.embeds"); + const texNode = texLayer.children.find(child => child.id === "sai.tex.log-power"); + + assert.equal(projection.root.pluginProjection.pluginId, "sai.vn"); + assert.equal(projection.root.pluginProjection.semanticOwner, "Sai"); + assert.equal(projection.root.visualElementKind, "SaiVisualNovelStageElement"); + assert.equal(graphNode.pluginProjection.pluginId, "norn.graph"); + assert.equal(graphNode.pluginProjection.semanticOwner, "Norn"); + assert.equal(graphNode.visualElementKind, "NornGraphElement"); + assert.equal(texNode.pluginProjection.pluginId, "tex.math"); + assert.equal(texNode.pluginProjection.semanticOwner, "EvePlugins"); + assert.equal(texNode.visualElementKind, "TeXMathElement"); +}); diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index 53646d1..c29d1bd 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -30,6 +30,7 @@ "gamecult.eve.provider_handoff.v1": "schemas/gamecult.eve.provider_handoff.v1.schema.json", "gamecult.eve.conformance_handoff.v1": "schemas/gamecult.eve.conformance_handoff.v1.schema.json", "gamecult.eve.electron_shell_projection.v1": "schemas/gamecult.eve.electron_shell_projection.v1.schema.json", + "gamecult.eve.unity_uitoolkit_projection.v1": "schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json", "gamecult.eve.unity_scene_projection.v1": "schemas/gamecult.eve.unity_scene_projection.v1.schema.json", "gamecult.eve.tui_grid.v1": "schemas/gamecult.eve.tui_grid.v1.schema.json" }, @@ -1498,7 +1499,10 @@ "packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs", "packages/org.gamecult.eve.unity-uitoolkit/eveunity-split-handoff.json", "scripts/run-aetheria-unity-editmode-tests.ps1", - "scripts/run-eveunity-split-handoff-smoke.ps1" + "scripts/run-eveunity-split-handoff-smoke.ps1", + "scripts/run-eveunity-uitoolkit-capture-smoke.ps1", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs" ], "expectedSourceSymbols": [ { @@ -1524,6 +1528,14 @@ { "path": "packages/org.gamecult.eve.unity-uitoolkit/Tests/Editor/EveUiToolkitSurfaceLowererTests.cs", "contains": ["FindPluginProjectionAdapter", "SaiStageLowersThroughRuntimeProjectionAdapter", "NornGraphLowersThroughRuntimeProjectionAdapter", "TeXMathLowersThroughRuntimeProjectionAdapterAsSourceFallback", "EmbeddedDocumentSlotMountsResolverBackedChildSurface", "eve-plugin-projection"] + }, + { + "path": "tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs", + "contains": ["buildUnityUiToolkitCaptureArtifact", "buildUnityUiToolkitProjection", "gamecult.eve.unity_uitoolkit_projection.v1", "unity-uitoolkit-projection-json", "writeUnityUiToolkitCaptureArtifact", "request-output"] + }, + { + "path": "tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs", + "contains": ["builds Unity UI Toolkit projection capture artifact", "keeps Sai, Norn, and TeX semantics in sidecar plugin UI projections", "gamecult.eve.unity_uitoolkit_projection.v1", "unity-uitoolkit-projection-json"] } ], "commandTransportSmoke": { @@ -1623,6 +1635,9 @@ "tools/eveunity/eveunity-capture-contract.mjs", "tools/eveunity/eveunity-capture-contract.test.mjs", "scripts/run-eveunity-capture-contract-smoke.ps1", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.mjs", + "tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs", + "scripts/run-eveunity-uitoolkit-capture-smoke.ps1", "web/fixtures/aetheria.provider-advertisement.json" ], "pendingProofs": [ @@ -1631,8 +1646,20 @@ } }, "capture": { - "status": "semantic", - "note": "Unity UI Toolkit lowers embedded document slots through a resolver-backed child surface host." + "status": "json-projection", + "note": "Unity UI Toolkit attaches a provider-advertisement-backed gamecult.eve.unity_uitoolkit_projection.v1 JSON projection artifact; Unity editor or batchmode PNG capture remains an EveUnity split blocker.", + "artifacts": [ + { + "kind": "json-projection", + "schema": "gamecult.eve.unity_uitoolkit_projection.v1", + "path": "artifacts/eveunity-uitoolkit-capture/latest/unity-uitoolkit-projection.json", + "runtimeId": "unity-uitoolkit", + "providerId": "aetheria", + "surfaceId": "aetheria.daemon.game", + "sourceSurfacePath": "web/fixtures/aetheria-world-surface.json", + "requestPath": "artifacts/eveunity-uitoolkit-capture/latest/capture-request.json" + } + ] } }, {