Add artifact-only Eve two-runtime sample
This commit is contained in:
parent
1e1cfd840c
commit
32258f49c3
3 changed files with 320 additions and 5 deletions
184
samples/eve-two-runtime/sample.mjs
Normal file
184
samples/eve-two-runtime/sample.mjs
Normal file
|
|
@ -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("<!doctype html><html><head></head><body><main id='surface'></main></body></html>", {
|
||||
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,
|
||||
}));
|
||||
115
scripts/verify-eve-two-runtime-sample.ps1
Normal file
115
scripts/verify-eve-two-runtime-sample.ps1
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue