From 6270999e11c80072a61b4aee07da1cc30e0dbc29 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 14:42:20 +0100 Subject: [PATCH] Discover browser world surfaces through Odin --- README.md | 6 +- docs/eve-migration-roadmap.md | 10 ++- packages/eve-browser-lowering/dist/index.js | 10 ++- packages/eve-browser-lowering/src/index.ts | 7 +-- scripts/start-browser-reference.ps1 | 68 ++++++--------------- web/eve-runtime-capability.json | 8 ++- web/hermodr-provider-catalog.mjs | 41 +++++++++++++ web/hermodr-provider-catalog.test.mjs | 38 ++++++++++++ web/surface.js | 44 ++++--------- 9 files changed, 137 insertions(+), 95 deletions(-) create mode 100644 web/hermodr-provider-catalog.mjs create mode 100644 web/hermodr-provider-catalog.test.mjs diff --git a/README.md b/README.md index b80337b..1f45762 100644 --- a/README.md +++ b/README.md @@ -182,8 +182,10 @@ Start the browser reference surface runner: powershell -ExecutionPolicy Bypass -File .\scripts\start-browser-reference.ps1 ``` -Open `http://127.0.0.1:8891/`. The page can render local fixture and -advertisement surfaces: +Open `http://127.0.0.1:8798/`. Odin-owned Hermodr serves the browser reference +and lowers Odin-visible provider advertisements and surfaces. Local fixtures +remain an offline oracle when the live catalog is unavailable. The page can +render: - `Fensalir Direct2D`, a fixture surface for the planned CultMesh-to-`AquariumUiDocument` lowering. diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 29b8d21..91f1bf4 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -115,6 +115,10 @@ advertisements and typed command/receipt documents are the boundary. attachment, export consumer, and report artifacts. - Eve retains the browser reference as the behavior oracle and a minimal generic interactive-world fixture. +- The browser reference starts through Odin-owned Hermodr, preserves advertised + provider and surface identity, selects `interactive-world` by capability, and + resolves assets through provider-advertised CDN routes. Its live startup path + contains no Aetheria identifiers; local catalogs remain offline oracle data. ## Remaining Work @@ -127,9 +131,9 @@ advertisements and typed command/receipt documents are the boundary. ### Generic Game Lowering -- Route browser and remaining runtimes through Odin's aggregated provider - discovery rather than local catalogs or sibling paths; Unity's direct - CultMesh Verse discovery is the working reference. +- Route remaining runtimes through Odin's aggregated provider discovery rather + than local catalogs or sibling paths; browser/Hermodr and Unity direct Verse + discovery are the working references. - Expand world lowering only through generic surface contracts: entities, transforms, animation state, collision/navigation intent, cameras, effects, interaction prompts, and command boundaries. diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 5a86e11..a72ebc5 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -836,7 +836,9 @@ async function resolveGravitySurfaceDocuments(node, props, options, state) { async function fetchGravitySurfaceDocument(request, options) { if (typeof fetch !== "function" || typeof window === "undefined") return undefined; - const providerId = currentSurfaceDocument?.providerId || options.provider?.providerId || "aetheria"; + const providerId = currentSurfaceDocument?.providerId || options.provider?.providerId || ""; + if (!providerId) + return undefined; const params = new URLSearchParams(); params.set("documentId", request.documentId); if (request.schemaId) @@ -1525,7 +1527,7 @@ function gravityBodiesFromDocument(document) { waveSpeed: numberProp(body.waveSpeed, 0), icon: assetUriFromRef(body.iconAsset) || assetUriFromRef(displayBody?.iconAsset), iconSize: numberProp(displayBody?.iconSize, numberProp(body.iconSize, 0)), - tint: "cultmesh://aetheria/assets/textures/tint_splat", + tint: "", }; }); } @@ -2224,10 +2226,6 @@ function textClassName(kind, props, node) { classes.push("detail", "mono"); if (classes.length === 0) classes.push("detail", "eve-text"); - if (node.id === "aetheria.main_menu.root.title") - classes.push("eve-root-title"); - if (node.id === "aetheria.main_menu.root.subtitle") - classes.push("eve-root-subtitle"); return classes.join(" "); } function applyControlBoxProps(element, props) { diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 6908299..b020ec4 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1017,7 +1017,8 @@ async function fetchGravitySurfaceDocument( options: EveBrowserLoweringOptions, ): Promise { if (typeof fetch !== "function" || typeof window === "undefined") return undefined; - const providerId = currentSurfaceDocument?.providerId || options.provider?.providerId || "aetheria"; + const providerId = currentSurfaceDocument?.providerId || options.provider?.providerId || ""; + if (!providerId) return undefined; const params = new URLSearchParams(); params.set("documentId", request.documentId); if (request.schemaId) params.set("schemaId", request.schemaId); @@ -1864,7 +1865,7 @@ function gravityBodiesFromDocument(document: Record | undefined waveSpeed: numberProp(body.waveSpeed, 0), icon: assetUriFromRef((body as Record).iconAsset) || assetUriFromRef(displayBody?.iconAsset), iconSize: numberProp(displayBody?.iconSize, numberProp((body as Record).iconSize, 0)), - tint: "cultmesh://aetheria/assets/textures/tint_splat", + tint: "", }; }); } @@ -2642,8 +2643,6 @@ function textClassName(kind: string, props: Record, node: EveSu if (kind === "label") classes.push("cultui-label"); if (props.role === "mono") classes.push("detail", "mono"); if (classes.length === 0) classes.push("detail", "eve-text"); - if (node.id === "aetheria.main_menu.root.title") classes.push("eve-root-title"); - if (node.id === "aetheria.main_menu.root.subtitle") classes.push("eve-root-subtitle"); return classes.join(" "); } diff --git a/scripts/start-browser-reference.ps1 b/scripts/start-browser-reference.ps1 index 248dbc0..9f5a57e 100644 --- a/scripts/start-browser-reference.ps1 +++ b/scripts/start-browser-reference.ps1 @@ -1,56 +1,28 @@ param( - [int] $Port = 8891 + [int] $Port = 8798, + [string] $OdinRoot = "E:\Projects\Odin", + [string] $OdinCultMeshUri = $(if ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }) ) $ErrorActionPreference = "Stop" -$projectRoot = Split-Path -Parent $PSScriptRoot -$webRoot = Join-Path $projectRoot "web" -$logRoot = Join-Path $projectRoot "artifacts\web" -New-Item -ItemType Directory -Force $logRoot | Out-Null - -$pidPath = Join-Path $logRoot "eve-browser-reference.pid" -$outLog = Join-Path $logRoot "eve-browser-reference.out.log" -$errLog = Join-Path $logRoot "eve-browser-reference.err.log" - -if (Test-Path $pidPath) { - $oldPid = Get-Content $pidPath -ErrorAction SilentlyContinue - if ($oldPid) { - Stop-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue - } +$launcher = Join-Path $OdinRoot "scripts\start-hermodr.ps1" +if (-not (Test-Path -LiteralPath $launcher)) { + throw "Odin-owned Hermodr launcher is missing: $launcher" } -$nodeScript = @' -const http = require('http'); -const fs = require('fs'); -const path = require('path'); -const root = process.argv[2]; -const port = Number(process.argv[3]); -const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.mjs':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.eve':'text/plain; charset=utf-8'}; -http.createServer((req, res) => { - const urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); - const clean = urlPath === '/' ? '/index.html' : urlPath; - const file = path.normalize(path.join(root, clean)); - if (!file.startsWith(root)) { - res.writeHead(403); - res.end('forbidden'); - return; +$healthUrl = "http://127.0.0.1:$Port/health" +try { + $health = Invoke-RestMethod -Uri $healthUrl -TimeoutSec 2 + if ($health.ok -and $health.service -eq "hermodr-browser-lowering") { + Write-Host "Eve browser reference already available at http://127.0.0.1:$Port/" + exit 0 } - fs.readFile(file, (err, data) => { - if (err) { - res.writeHead(404); - res.end('not found'); - return; - } - res.writeHead(200, {'content-type': types[path.extname(file)] || 'application/octet-stream'}); - res.end(data); - }); -}).listen(port, '127.0.0.1', () => console.log(`Eve browser reference listening on http://127.0.0.1:${port}`)); -'@ +} catch { + # Hermodr is not running on the requested local port. +} -$scriptPath = Join-Path $logRoot "serve-eve-reference.cjs" -Set-Content -LiteralPath $scriptPath -Value $nodeScript -Encoding UTF8 -$proc = Start-Process -FilePath "node" -ArgumentList @($scriptPath, $webRoot, $Port) -WorkingDirectory $projectRoot -RedirectStandardOutput $outLog -RedirectStandardError $errLog -PassThru -WindowStyle Hidden -$proc.Id | Set-Content -LiteralPath $pidPath -Start-Sleep -Milliseconds 500 -Get-Content $outLog -Tail 5 -Write-Host "PID $($proc.Id)" +& $launcher -HostAddress "127.0.0.1" -Port $Port -OdinCultMeshUri $OdinCultMeshUri -NoWindow +if (-not $?) { + throw "Hermodr failed to start." +} +Write-Host "Eve browser reference available at http://127.0.0.1:$Port/" diff --git a/web/eve-runtime-capability.json b/web/eve-runtime-capability.json index 7c14d34..68e1c46 100644 --- a/web/eve-runtime-capability.json +++ b/web/eve-runtime-capability.json @@ -12,6 +12,9 @@ "supportedFeatures": [ "embeddedDocuments", "providerAdvertisements", + "odinProviderDiscovery", + "genericWorldDiscovery", + "providerAssets", "stateBindings", "styleTokens", "authorityWitnesses" @@ -57,6 +60,8 @@ "ownership": "runtime-lowers-provider-world-surface-without-owning-world-state", "evidencePaths": [ "web/surface.js", + "web/hermodr-provider-catalog.mjs", + "scripts/start-browser-reference.ps1", "packages/eve-browser-lowering/src/index.ts", "web/fixtures/eve-world-smoke.provider-advertisement.json" ] @@ -79,10 +84,11 @@ "test": { "ownerRepo": "Eve", "status": "node-tests-and-parity-harness", - "command": "node --test web/eve-dsl.test.mjs && npm --prefix packages/eve-browser-lowering test && powershell -ExecutionPolicy Bypass -File .\\scripts\\run-parity-harness.ps1", + "command": "node --test web/*.test.mjs && npm --prefix packages/eve-browser-lowering test && powershell -ExecutionPolicy Bypass -File .\\scripts\\run-parity-harness.ps1", "evidencePaths": [ "web/eve-dsl.test.mjs", "web/provider-advertisements.test.mjs", + "web/hermodr-provider-catalog.test.mjs", "packages/eve-browser-lowering/test/browser-lowering.test.mjs", "../EveConformance/tools/parity/parity-manifest.json", "scripts/run-parity-harness.ps1" diff --git a/web/hermodr-provider-catalog.mjs b/web/hermodr-provider-catalog.mjs new file mode 100644 index 0000000..1c478f8 --- /dev/null +++ b/web/hermodr-provider-catalog.mjs @@ -0,0 +1,41 @@ +export function createHermodrProviderTargets(catalog) { + const providers = new Map((catalog?.providers || []) + .map(provider => [provider.id || provider.providerId, provider]) + .filter(([providerId]) => providerId)); + + return (catalog?.surfaces || []) + .filter(surface => surface?.providerId && surface?.surfaceId) + .map(surface => { + const provider = providers.get(surface.providerId) || {}; + const advertisedSurface = (provider.surfaces || []) + .find(candidate => candidate.surfaceId === surface.surfaceId) || {}; + const surfaceId = surface.surfaceId; + const surfaceKind = surface.surfaceKind || advertisedSurface.surfaceKind || ""; + return { + ...provider, + providerId: surface.providerId, + targetId: `${surface.providerId}::${surfaceId}`, + title: surface.title || advertisedSurface.title || provider.title || surfaceId, + kind: provider.kind || "eve.provider", + freshness: { state: "odin-visible-cultmesh" }, + surfaces: [{ + ...advertisedSurface, + transport: "hermodr-surface", + surfaceId, + surfaceKind, + url: `/hermodr/surface/${encodeURIComponent(surface.providerId)}?surfaceId=${encodeURIComponent(surfaceId)}`, + }], + }; + }); +} + +export function selectInitialProviderTarget(targets, requestedId = "", preferredSurfaceKind = "interactive-world") { + const available = Array.isArray(targets) ? targets : []; + return available.find(target => + target.targetId === requestedId || + target.providerId === requestedId || + target.surfaces?.some(surface => surface.surfaceId === requestedId) || + target.aliases?.includes(requestedId)) + || available.find(target => target.surfaces?.some(surface => surface.surfaceKind === preferredSurfaceKind)) + || available[0]; +} diff --git a/web/hermodr-provider-catalog.test.mjs b/web/hermodr-provider-catalog.test.mjs new file mode 100644 index 0000000..43eac37 --- /dev/null +++ b/web/hermodr-provider-catalog.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createHermodrProviderTargets, + selectInitialProviderTarget, +} from "./hermodr-provider-catalog.mjs"; + +const catalog = { + providers: [{ + id: "fixture.game", + title: "Fixture game", + surfaces: [ + { surfaceId: "fixture.menu", surfaceKind: "menu" }, + { surfaceId: "fixture.world", surfaceKind: "interactive-world" }, + ], + }], + surfaces: [ + { providerId: "fixture.game", surfaceId: "fixture.menu", title: "Menu" }, + { providerId: "fixture.game", surfaceId: "fixture.world", surfaceKind: "interactive-world", title: "World" }, + ], +}; + +test("preserves provider and advertised surface identity from Hermodr", () => { + const targets = createHermodrProviderTargets(catalog); + assert.equal(targets.length, 2); + assert.equal(targets[1].providerId, "fixture.game"); + assert.equal(targets[1].targetId, "fixture.game::fixture.world"); + assert.equal(targets[1].surfaces[0].surfaceId, "fixture.world"); + assert.equal(targets[1].surfaces[0].surfaceKind, "interactive-world"); + assert.match(targets[1].surfaces[0].url, /surfaceId=fixture\.world/); +}); + +test("selects an interactive world without product identity", () => { + const targets = createHermodrProviderTargets(catalog); + assert.equal(selectInitialProviderTarget(targets).targetId, "fixture.game::fixture.world"); + assert.equal(selectInitialProviderTarget(targets, "fixture.menu").targetId, "fixture.game::fixture.menu"); +}); diff --git a/web/surface.js b/web/surface.js index 5e1f548..a2a4e0e 100644 --- a/web/surface.js +++ b/web/surface.js @@ -5,6 +5,10 @@ import { } from "../packages/eve-browser-lowering/dist/index.js"; import { compileEveDsl } from "./eve-dsl.js"; import { mergeProviderAdvertisement } from "./provider-advertisements.mjs"; +import { + createHermodrProviderTargets, + selectInitialProviderTarget, +} from "./hermodr-provider-catalog.mjs"; const statusEl = document.querySelector("#status"); const app = document.querySelector("#app"); @@ -19,7 +23,7 @@ let liveHermodr = false; let openProviderGeneration = 0; providerSelect.addEventListener("change", () => { - const provider = providers.find(candidate => candidate.providerId === providerSelect.value); + const provider = providers.find(candidate => (candidate.targetId || candidate.providerId) === providerSelect.value); if (provider) void openProvider(provider); }); @@ -34,18 +38,16 @@ async function bootProviders() { } providerSelect.replaceChildren(...providers.map(provider => { const option = document.createElement("option"); - option.value = provider.providerId; + option.value = provider.targetId || provider.providerId; option.textContent = provider.title || provider.providerId; return option; })); - const requestedProviderId = new URLSearchParams(location.search).get("provider"); - const firstProduct = providers.find(provider => provider.providerId === requestedProviderId || provider.aliases?.includes(requestedProviderId)) - || providers.find(provider => provider.providerId === "aetheria") - || providers.find(provider => provider.providerId === "aetheria.main_menu.root") - || providers.find(provider => provider.providerId === "aetheria.inventory.panel") - || providers.find(provider => provider.providerId === "repixelizer") - || providers[0]; - providerSelect.value = firstProduct.providerId; + const params = new URLSearchParams(location.search); + const requestedProviderId = params.get("surface") || params.get("provider") || ""; + const firstProduct = liveHermodr + ? selectInitialProviderTarget(providers, requestedProviderId) + : providers.find(provider => provider.providerId === requestedProviderId || provider.aliases?.includes(requestedProviderId)) || providers[0]; + providerSelect.value = firstProduct.targetId || firstProduct.providerId; await openProvider(firstProduct); } @@ -60,27 +62,7 @@ async function loadHermodrProviders() { const response = await fetch("/hermodr/verse/catalog", { cache: "no-store" }); if (!response.ok) return []; const catalog = await response.json(); - const advertised = new Map(); - for (const provider of catalog.providers || []) { - advertised.set(provider.id || provider.providerId, provider); - } - return (catalog.surfaces || []) - .filter(surface => surface?.providerId && surface?.surface) - .map(surface => { - const provider = advertised.get(surface.providerId) || {}; - return { - ...provider, - providerId: surface.providerId, - title: surface.title || provider.title || surface.providerId, - kind: provider.kind || "eve.surface", - freshness: { state: "odin-visible-cultmesh" }, - surfaces: [{ - transport: "hermodr-surface", - surfaceId: surface.providerId, - url: `/hermodr/surface/${encodeURIComponent(surface.providerId)}`, - }], - }; - }); + return createHermodrProviderTargets(catalog); } catch { return []; }