diff --git a/samples/eve-two-runtime/sample.mjs b/samples/eve-two-runtime/sample.mjs new file mode 100644 index 00000000..a78b765f --- /dev/null +++ b/samples/eve-two-runtime/sample.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { JSDOM } from "jsdom"; +import { z } from "zod"; +import { defineDocumentType } from "cultcache-ts"; +import { CultMesh } from "cultmesh-ts"; +import { renderEveSurface } from "@gamecult/eve-browser-lowering"; + +const providerId = "sample.counter-provider"; +const surfaceId = "sample.counter"; +const stateKey = "counter:main"; + +const counterDocument = defineDocumentType({ + type: "sample.counter_state", + schemaId: "sample.counter_state.v1", + schema: z.object({ counterId: z.string(), count: z.number().int() }), + name: "counterId", +}); + +const surfaceDocument = defineDocumentType({ + type: "gamecult.eve.surface", + schemaId: "gamecult.eve.surface.v1", + schema: z.any(), +}); + +const receiptDocument = defineDocumentType({ + type: "sample.counter_receipt", + schemaId: "sample.counter_receipt.v1", + schema: z.object({ + receiptId: z.string(), + operationId: z.literal("sample.counter.increment"), + idempotencyKey: z.string(), + count: z.number().int(), + }), + name: "receiptId", +}); + +function counterSurface() { + return { + providerId, + title: "CultMesh two-runtime counter", + version: 1, + surface: { + id: surfaceId, + root: { + id: "counter.root", + kind: "column", + children: [ + { + id: "counter.value", + kind: "metric", + props: { label: "Canonical count", value: 0 }, + stateBindings: [{ + targetProp: "value", + pointerId: "sample.counter.count", + sourceId: `${counterDocument.type}:${stateKey}`, + schemaId: counterDocument.schemaId, + routeKind: "shared-memory", + }], + }, + { + id: "counter.increment", + kind: "control.button", + props: { label: "Increment", command: "sample.counter.increment" }, + }, + ], + }, + }, + }; +} + +function installDom() { + const dom = new JSDOM("
", { + url: "https://eve.sample/", + pretendToBeVisual: true, + }); + for (const name of [ + "document", + "window", + "Element", + "HTMLElement", + "HTMLButtonElement", + "HTMLInputElement", + "HTMLTextAreaElement", + "HTMLImageElement", + ]) globalThis[name] = dom.window[name]; + return dom; +} + +async function waitFor(predicate, description) { + const deadline = Date.now() + 2_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}.`); + await new Promise(resolve => setTimeout(resolve, 5)); + } +} + +const statePath = join(await mkdtemp(join(tmpdir(), "cultmesh-eve-two-runtime-")), "state.ccmp"); +const node = await CultMesh.startNode(statePath, { + documents: [counterDocument, surfaceDocument, receiptDocument], +}); +await node.put(counterDocument, stateKey, { counterId: stateKey, count: 0 }); +await node.put(surfaceDocument, surfaceId, counterSurface()); +await node.flush(); + +const state = node.document(counterDocument, stateKey, { pollMs: 5 }); +const headless = state.observe({ context: "headless-client" }); +await headless.ready; +const receiptsByIdempotencyKey = new Map(); +const increment = CultMesh.operation("sample.counter.increment", async (_request, context) => { + const idempotencyKey = context.idempotencyKey || "missing"; + const existing = receiptsByIdempotencyKey.get(idempotencyKey) + || node.get(receiptDocument, `receipt:${idempotencyKey}`); + if (existing) return existing; + const next = await state.authoritativeWriter("provider-daemon").update(current => ({ + ...current, + count: current.count + 1, + })); + const receipt = { + receiptId: `receipt:${idempotencyKey}`, + operationId: "sample.counter.increment", + idempotencyKey, + count: next.count, + }; + receiptsByIdempotencyKey.set(idempotencyKey, receipt); + await node.put(receiptDocument, receipt.receiptId, receipt); + await node.flush(); + return receipt; +}); + +const dom = installDom(); +const host = document.querySelector("#surface"); +const browserVerse = CultMesh.verse("local", "browser-client"); +let browserReceipt; +renderEveSurface(await node.document(surfaceDocument, surfaceId).latest(), host, { + activeSurfaceId: surfaceId, + clientId: "browser-client", + commandSink: async intent => { + browserReceipt = await increment.bind(browserVerse).invoke(intent.payload, { + idempotencyKey: "browser-click-1", + }); + }, + stateBindingResolver: async binding => { + if (binding.pointerId !== "sample.counter.count") return undefined; + return { + latest: async () => (await state.latest("browser-client")).count, + watch: callback => state.watch("browser-client", value => callback(value.count)), + }; + }, +}); + +await waitFor(() => host.querySelector(".metric-value")?.textContent === "0", "initial browser binding"); +host.querySelector("button").click(); +await waitFor(() => browserReceipt?.count === 1, "typed command receipt"); +await waitFor(() => headless.current.count === 1, "headless convergence"); +await waitFor(() => host.querySelector(".metric-value")?.textContent === "1", "browser convergence"); + +const duplicate = await increment.bind(browserVerse).invoke({}, { idempotencyKey: "browser-click-1" }); +assert.equal(duplicate.receiptId, browserReceipt.receiptId); +assert.equal(headless.current.count, 1); + +headless.dispose(); +renderEveSurface({ providerId, surface: { id: "sample.closed" } }, host); +dom.window.close(); +await node.flush(); +const reopened = await CultMesh.startNode(statePath, { + documents: [counterDocument, surfaceDocument, receiptDocument], +}); +assert.equal(reopened.getRequired(counterDocument, stateKey).count, 1); +assert.equal( + reopened.getRequired(receiptDocument, "receipt:browser-click-1").receiptId, + browserReceipt.receiptId, +); + +console.log(JSON.stringify({ + providerId, + surfaceId, + receiptId: browserReceipt.receiptId, + browserCount: 1, + headlessCount: 1, + restartCount: 1, +})); diff --git a/scripts/verify-eve-two-runtime-sample.ps1 b/scripts/verify-eve-two-runtime-sample.ps1 new file mode 100644 index 00000000..f5545d5f --- /dev/null +++ b/scripts/verify-eve-two-runtime-sample.ps1 @@ -0,0 +1,115 @@ +param( + [string] $EveRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) "..\Eve") +) + +$ErrorActionPreference = "Stop" + +$cultLibRoot = Split-Path -Parent $PSScriptRoot +$samplePath = Join-Path $cultLibRoot "samples\eve-two-runtime\sample.mjs" +$eveBrowserPackage = Join-Path $EveRoot "packages\eve-browser-lowering" +$packageRoots = @( + (Join-Path $cultLibRoot "packages\cultcache-ts"), + (Join-Path $cultLibRoot "packages\cultnet-ts"), + (Join-Path $cultLibRoot "packages\cultmesh-ts"), + $eveBrowserPackage +) + +$nodeCommand = Get-Command node.exe -ErrorAction SilentlyContinue +if (-not $nodeCommand) { + $standardNode = Join-Path $env:ProgramFiles "nodejs\node.exe" + if (Test-Path -LiteralPath $standardNode) { + $nodeCommand = Get-Item -LiteralPath $standardNode + } +} +if (-not $nodeCommand) { + throw "Node.js is required to verify the Eve two-runtime sample." +} +$nodePath = $nodeCommand.Source +$nodeDirectory = Split-Path -Parent $nodePath +$npmPath = Join-Path $nodeDirectory "npm.cmd" +if (-not (Test-Path -LiteralPath $npmPath)) { + throw "npm was not found beside Node.js: $npmPath" +} +$env:PATH = $nodeDirectory + [IO.Path]::PathSeparator + $env:PATH +if (-not (Test-Path -LiteralPath $samplePath)) { + throw "Sample not found: $samplePath" +} +foreach ($packageRoot in $packageRoots) { + if (-not (Test-Path -LiteralPath (Join-Path $packageRoot "package.json"))) { + throw "Package root not found: $packageRoot" + } +} + +$tempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$workRoot = Join-Path $tempBase ("gamecult-eve-two-runtime-" + [guid]::NewGuid().ToString("N")) +$artifactRoot = Join-Path $workRoot "artifacts" +$consumerRoot = Join-Path $workRoot "consumer" +New-Item -ItemType Directory -Path $artifactRoot, $consumerRoot -Force | Out-Null + +try { + foreach ($packageRoot in $packageRoots) { + Push-Location $packageRoot + try { + if ((Split-Path -Leaf $packageRoot) -eq "cultnet-ts") { + & $nodePath (Join-Path $packageRoot "tools\generate-swarm-contracts.mjs") + if ($LASTEXITCODE -ne 0) { + throw "CultNet TypeScript contract generation failed: $packageRoot" + } + } + $typescriptPath = if ($packageRoot -eq $eveBrowserPackage) { + Join-Path $packageRoot "node_modules\typescript\bin\tsc" + } + else { + Join-Path $cultLibRoot "node_modules\typescript\bin\tsc" + } + & $nodePath $typescriptPath -p (Join-Path $packageRoot "tsconfig.json") --pretty false + if ($LASTEXITCODE -ne 0) { + throw "Package build failed: $packageRoot" + } + & $npmPath pack --ignore-scripts --silent --pack-destination $artifactRoot | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Package pack failed: $packageRoot" + } + } + finally { + Pop-Location + } + } + + Push-Location $consumerRoot + try { + & $npmPath init --yes --silent | Out-Null + $tarballs = Get-ChildItem -LiteralPath $artifactRoot -Filter "*.tgz" | + Sort-Object Name | + ForEach-Object FullName + $installArguments = @( + "install", + "--silent", + "--no-audit", + "--no-fund", + "@msgpack/msgpack@3", + "ajv@8", + "jsdom@30", + "zod@3" + ) + @($tarballs) + & $npmPath @installArguments + if ($LASTEXITCODE -ne 0) { + throw "Clean consumer package installation failed." + } + Copy-Item -LiteralPath $samplePath -Destination (Join-Path $consumerRoot "sample.mjs") + & $nodePath .\sample.mjs + if ($LASTEXITCODE -ne 0) { + throw "Eve two-runtime sample failed." + } + } + finally { + Pop-Location + } +} +finally { + $resolvedWorkRoot = [IO.Path]::GetFullPath($workRoot) + if ($resolvedWorkRoot.StartsWith($tempBase, [StringComparison]::OrdinalIgnoreCase) -and + (Test-Path -LiteralPath $resolvedWorkRoot)) { + Remove-Item -LiteralPath $resolvedWorkRoot -Recurse -Force + } +} diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md index 666acf1d..95048d3a 100644 --- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md +++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md @@ -35,15 +35,31 @@ Run the same provider and connect at least two lowerers. Both must report the same provider id, surface id, surface version, and command receipt ids. Native pixels may differ; provider truth may not. -Current verification for the Unity consumer: +The package-artifact browser/headless checkpoint is executable from CultLib: + +```powershell +cd CultLib +powershell -ExecutionPolicy Bypass -File .\scripts\verify-eve-two-runtime-sample.ps1 ` + -EveRoot ..\Eve +``` + +The verifier builds and packs `cultcache-ts`, `cultnet-ts`, `cultmesh-ts`, and +`@gamecult/eve-browser-lowering`, installs the tarballs into an empty temporary +consumer, and runs `samples/eve-two-runtime/sample.mjs`. One provider-owned +counter surface is lowered by a browser runtime and observed by a headless +runtime. A browser command crosses the typed operation boundary, both runtimes +converge on the same state and receipt identity, a duplicate idempotency key is +not applied twice, and the state plus receipt survive reopening the `.ccmp` +store. + +Current verification for the Unity consumer remains: ```powershell cd E:\Projects\EveUnity powershell -ExecutionPolicy Bypass -File .\scripts\run-release-consumer-tests.ps1 ``` -The browser/Electron counterpart and a single command-driven two-runtime sample -remain required before this chapter can be considered complete. Their absence -is tracked as developer-experience debt rather than papered over with bespoke -setup instructions. +This checkpoint proves clean package consumption and two lowering/observation +runtimes. It does not claim an Odin-discovered network hop or cross-language +command execution; those remain separate public Verse conformance gates.