From fe71b8d2c50c3d291a930754cb56f8f35a33acf8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 15:13:59 +0100 Subject: [PATCH] Resolve browser field projection through plugin adapters --- docs/eve-migration-roadmap.md | 13 +- docs/world-state-lowering.md | 7 +- .../dist/fields-browser-adapter.d.ts | 8 ++ .../dist/fields-browser-adapter.js | 124 ++++++++++++++++ packages/eve-browser-lowering/dist/index.d.ts | 15 ++ packages/eve-browser-lowering/dist/index.js | 33 ++++- .../src/fields-browser-adapter.ts | 136 ++++++++++++++++++ packages/eve-browser-lowering/src/index.ts | 61 +++++++- .../test/browser-lowering.test.mjs | 43 +++++- web/eve-runtime-capability.json | 11 ++ web/surface.js | 6 + 11 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts create mode 100644 packages/eve-browser-lowering/dist/fields-browser-adapter.js create mode 100644 packages/eve-browser-lowering/src/fields-browser-adapter.ts diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 4a8a591..4d80619 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -112,8 +112,9 @@ advertisements and typed command/receipt documents are the boundary. Aetheria remains owner of sampled field truth; runtimes remain owners of native field projection. - Aetheria publishes objects, gravity, and render splats as - `gamecult.fields.*` documents. Odin/Hermodr recognizes those plugin-owned - schema identities and contains no Aetheria viewport-schema branch. + `gamecult.fields.*` documents and advertises `fields.surface` on graphical + surfaces that use them. Hermodr carries decoded MessagePack values without + interpreting field tuples; the browser fields adapter owns that conversion. - The TeX owner sidecar serves its Eve ABI over CultNet RUDP operation envelopes; its stdio protocol is retained only as a local debug adapter. - Aetheria owns its provider advertisement, world surface, scenario, asset @@ -158,10 +159,10 @@ advertisements and typed command/receipt documents are the boundary. is its semantic owner. Sai already owns VN/Ink execution and its live witness. - Keep nested plugin availability explicit and non-transitive: Sai does not own Norn or TeX. -- Move Hermodr's generic field normalization out of its provider transport and - move browser field projection behind a runtime adapter resolved from the - advertised `fields.surface` plugin. Hermodr should carry typed documents; - plugin/runtime owners should interpret and project them. +- Move the remaining browser WebGL field projection body from the general + component lowerer into the `fields.surface` runtime adapter. The browser now + resolves required adapter capabilities from provider advertisements and owns + field tuple conversion outside Hermodr. ### Conformance diff --git a/docs/world-state-lowering.md b/docs/world-state-lowering.md index 45e4cbb..193dfbf 100644 --- a/docs/world-state-lowering.md +++ b/docs/world-state-lowering.md @@ -122,9 +122,10 @@ Aetheria is the current pressure case for this contract. The Aetheria daemon should publish the game state, rules, generated level, assets, operations, scalar/vector fields, object rows, and Eve surfaces needed -to render both Starbridge RTS and ARPG views. Hermodr should reconstruct the RTS -surface as an unspecialized browser/Eve lowerer. Electron should render the same -surface as the player-facing Starbridge shell. Unity should become an ARPG +to render both Starbridge RTS and ARPG views. Hermodr should carry provider +documents without interpreting plugin semantics; the browser fields adapter +should reconstruct the RTS view as part of the unspecialized Eve lowerer. +Electron should render the same surface as the player-facing Starbridge shell. Unity should become an ARPG lowerer over the same kind of daemon-authored surface, and Godot should become the equivalent non-Unity ARPG conformance target. diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts new file mode 100644 index 0000000..e131a7e --- /dev/null +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts @@ -0,0 +1,8 @@ +export interface EveBrowserPluginAdapter { + pluginId: string; + capabilities: readonly string[]; + schemas: readonly string[]; + normalizeDocument(schemaId: string | undefined, value: unknown): unknown; +} +export declare const fieldsBrowserAdapter: EveBrowserPluginAdapter; +export declare function normalizeFieldsDocument(schemaId: string | undefined, value: unknown): unknown; diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.js b/packages/eve-browser-lowering/dist/fields-browser-adapter.js new file mode 100644 index 0000000..dc21b0b --- /dev/null +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.js @@ -0,0 +1,124 @@ +const FIELD_SCHEMAS = new Set([ + "gamecult.fields.splats.v1", + "gamecult.fields.gravity.v1", + "gamecult.fields.objects.v1", +]); +export const fieldsBrowserAdapter = { + pluginId: "fields.surface", + capabilities: ["field.surface2d", "gravity.surface", "field.scalar", "field.vector", "field.objects"], + schemas: [...FIELD_SCHEMAS], + normalizeDocument: normalizeFieldsDocument, +}; +export function normalizeFieldsDocument(schemaId, value) { + const tuple = Array.isArray(value) ? value : undefined; + const documentSchema = tuple ? tuple[0] : value?.schema; + const schema = String(schemaId || documentSchema || ""); + if (!tuple || !FIELD_SCHEMAS.has(schema)) + return value; + if (schema === "gamecult.fields.splats.v1") + return normalizeSplats(tuple); + if (schema === "gamecult.fields.gravity.v1") + return normalizeGravity(tuple); + return normalizeObjects(tuple); +} +function normalizeSplats(value) { + const splats = array(value[9]); + return { + schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3], + runId: value[4], zoneIndex: value[5], zoneName: value[6], viewport: normalizeViewport(value[7]), + layers: array(value[8]).map(normalizeSplatLayer), + splats: { + count: splats[0] ?? 0, centerX: splats[1] || [], centerY: splats[2] || [], + halfExtentX: splats[3] || [], halfExtentY: splats[4] || [], rotationCos: splats[5] || [], + rotationSin: splats[6] || [], channel: splats[7] || [], falloff: splats[8] || [], + valueR: splats[9] || [], valueG: splats[10] || [], valueB: splats[11] || [], valueA: splats[12] || [], + sourceKey: splats[13] || [], layerIndex: splats[14] || [], sourceKind: splats[15] || [], + frequencyX: splats[16] || [], frequencyY: splats[17] || [], phaseX: splats[18] || [], + phaseY: splats[19] || [], animationSpeed: splats[20] || [], sourceFlags: splats[21] || [], + }, + }; +} +function normalizeGravity(value) { + return { + schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3], + runId: value[4], zoneIndex: value[5], zoneName: value[6], viewport: normalizeViewport(value[7]), + gravityInfluences: array(value[8]).map(normalizeGravityInfluence), + bodies: array(value[9]).map(normalizeBody), terrainRadius: value[10], terrainDepth: value[11], + terrainDepthExponent: value[12], terrainWaveFrequency: value[13], + }; +} +function normalizeObjects(value) { + return { + schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3], + runId: value[4], zoneIndex: value[5], zoneName: value[6], currentEntityKey: value[7], + viewport: normalizeViewport(value[8]), controlledEntityIndices: array(value[9]), + objects: array(value[10]).map(normalizeObject), + }; +} +function normalizeViewport(value) { + const row = array(value); + return row.length ? { minX: row[0], minY: row[1], maxX: row[2], maxY: row[3] } : value; +} +function normalizeSplatLayer(value) { + const row = array(value); + if (!row.length) + return value; + return { + layerKey: row[0], displayName: row[1], channel: row[2], blendMode: row[3], graphicsFormat: row[4], + clearBeforeDraw: row[5], clearR: row[6], clearG: row[7], clearB: row[8], clearA: row[9], + }; +} +function normalizeGravityInfluence(value) { + const row = array(value); + if (!row.length) + return value; + return { + bodyKey: row[0], orbitKey: row[1], kind: row[2], x: row[3], y: row[4], radius: row[5], + gravityDepth: row[6], gravityDepthExponent: row[7], waveRadius: row[8], waveDepth: row[9], waveSpeed: row[10], + }; +} +function normalizeBody(value) { + const row = array(value); + if (!row.length) + return value; + return { + bodyKey: row[0], orbitKey: row[1], name: row[2], kind: row[3], x: row[4], y: row[5], radius: row[6], + isAsteroidBelt: row[7], body: row[8], iconAsset: normalizeAsset(row[9]), + }; +} +function normalizeObject(value) { + const row = array(value); + if (!row.length) + return value; + return { + entityIndex: row[0], entityKey: row[1], displayName: row[2], kind: row[3], factionKey: row[4], + x: row[5], y: row[6], z: row[7], directionX: row[8], directionY: row[9], velocityX: row[10], + velocityY: row[11], controlled: row[12], targetEntityIndex: row[13], isActive: row[14], visibility: row[15], + status: normalizeStatus(row[16]), inventory: array(row[17]).map(normalizeInventoryItem), iconAsset: normalizeAsset(row[18]), + }; +} +function normalizeStatus(value) { + const row = array(value); + return row.length ? { hull: row[0], shield: row[1], heat: row[2] } : value || {}; +} +function normalizeInventoryItem(value) { + const row = array(value); + if (!row.length) + return value; + return { + source: row[0], itemKey: row[1], quantity: row[2], quality: row[3], durability: row[4], enabled: row[5], + sourceIndex: row[6], x: row[7], y: row[8], iconAsset: normalizeAsset(row[9]), + }; +} +function normalizeAsset(value) { + const row = array(value); + if (!row.length) + return value || {}; + return { + assetKey: row[0], kind: row[1], uri: row[2], transport: row[3], contentHash: row[4], + mimeType: row[5], metadata: row[6] || {}, + }; +} +function array(value) { + return Array.isArray(value) ? value : []; +} diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index cee82f2..7fea5b3 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -1,3 +1,5 @@ +import { type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; +export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; export interface EveSurfaceComponent { id?: string; kind?: string; @@ -55,6 +57,14 @@ export interface EveProviderSurfaceAdvertisement { status?: string; transport?: string; worldInteraction?: EveSurfaceWorldInteraction; + requiresPlugins?: EvePluginRequirement[]; +} +export interface EvePluginRequirement { + pluginId: string; + versionRange?: string; + availability?: "required" | "optional" | "optional-nested"; + requiredCapabilities?: string[]; + optionalCapabilities?: string[]; } export interface EveProviderAdvertisement { providerId?: string; @@ -72,6 +82,7 @@ export interface EveBrowserLoweringOptions { commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise; documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; provider?: EveProviderAdvertisement; + pluginAdapters?: readonly EveBrowserPluginAdapter[]; source?: string; statusElement?: HTMLElement; } @@ -89,7 +100,10 @@ export interface EveBrowserProviderHostOptions { requestedSurfaceId?: string; source?: string; statusElement?: HTMLElement; + pluginAdapters?: readonly EveBrowserPluginAdapter[]; } +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; export declare class EveBrowserProviderHost { private readonly host; @@ -100,6 +114,7 @@ export declare class EveBrowserProviderHost { private pollHandle; private provider; private selected; + private pluginAdapters; constructor(host: HTMLElement, transport: EveBrowserProviderTransport, options?: EveBrowserProviderHostOptions); start(): Promise; stop(): void; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index a72ebc5..8dbef25 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -1,3 +1,24 @@ +import { fieldsBrowserAdapter } from "./fields-browser-adapter.js"; +export { fieldsBrowserAdapter, normalizeFieldsDocument } from "./fields-browser-adapter.js"; +export const defaultBrowserPluginAdapters = [fieldsBrowserAdapter]; +export function resolveRequiredPluginAdapters(surface, available = defaultBrowserPluginAdapters) { + const resolved = []; + for (const requirement of surface.requiresPlugins || []) { + const adapter = available.find(candidate => candidate.pluginId === requirement.pluginId); + if (!adapter) { + if ((requirement.availability || "required") === "required") { + throw new Error(`Missing required Eve plugin adapter ${requirement.pluginId}.`); + } + continue; + } + const missing = (requirement.requiredCapabilities || []).filter(capability => !adapter.capabilities.includes(capability)); + if (missing.length > 0) { + throw new Error(`Eve plugin adapter ${requirement.pluginId} lacks required capabilities: ${missing.join(", ")}.`); + } + resolved.push(adapter); + } + return resolved; +} export function selectAdvertisedSurface(provider, requestedSurfaceId = "") { const surfaces = provider.surfaces || provider.localAdvertisement?.surfaces || []; const selected = requestedSurfaceId @@ -17,6 +38,7 @@ export class EveBrowserProviderHost { pollHandle; provider; selected; + pluginAdapters = []; constructor(host, transport, options = {}) { this.host = host; this.transport = transport; @@ -25,6 +47,7 @@ export class EveBrowserProviderHost { 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); this.active = true; await this.refresh(); const pollMs = Math.max(0, this.options.pollMs ?? 250); @@ -55,6 +78,7 @@ export class EveBrowserProviderHost { commandSink: intent => this.submit(intent), documentResolver: this.transport.resolveDocument, provider: this.provider, + pluginAdapters: this.pluginAdapters, source: this.options.source, statusElement: this.options.statusElement, }); @@ -810,9 +834,9 @@ async function resolveGravitySurfaceDocuments(node, props, options, state) { let changed = false; for (const request of requests) { const resolved = await options.documentResolver(request, node); - let document = objectProps(resolved?.document); + let document = objectProps(normalizePluginDocument(resolved?.schemaId || request.schemaId, resolved?.document, options)); if (Object.keys(document).length === 0) { - document = objectProps(await fetchGravitySurfaceDocument(request, options)); + document = objectProps(normalizePluginDocument(request.schemaId, await fetchGravitySurfaceDocument(request, options), options)); } if (!document || Object.keys(document).length === 0) continue; @@ -833,6 +857,11 @@ async function resolveGravitySurfaceDocuments(node, props, options, state) { state.loading = false; } } +function normalizePluginDocument(schemaId, value, options) { + const adapters = options.pluginAdapters || defaultBrowserPluginAdapters; + const adapter = adapters.find(candidate => !schemaId || candidate.schemas.includes(schemaId)); + return adapter ? adapter.normalizeDocument(schemaId, value) : value; +} async function fetchGravitySurfaceDocument(request, options) { if (typeof fetch !== "function" || typeof window === "undefined") return undefined; diff --git a/packages/eve-browser-lowering/src/fields-browser-adapter.ts b/packages/eve-browser-lowering/src/fields-browser-adapter.ts new file mode 100644 index 0000000..2e18eaf --- /dev/null +++ b/packages/eve-browser-lowering/src/fields-browser-adapter.ts @@ -0,0 +1,136 @@ +const FIELD_SCHEMAS = new Set([ + "gamecult.fields.splats.v1", + "gamecult.fields.gravity.v1", + "gamecult.fields.objects.v1", +]); + +export interface EveBrowserPluginAdapter { + pluginId: string; + capabilities: readonly string[]; + schemas: readonly string[]; + normalizeDocument(schemaId: string | undefined, value: unknown): unknown; +} + +export const fieldsBrowserAdapter: EveBrowserPluginAdapter = { + pluginId: "fields.surface", + capabilities: ["field.surface2d", "gravity.surface", "field.scalar", "field.vector", "field.objects"], + schemas: [...FIELD_SCHEMAS], + normalizeDocument: normalizeFieldsDocument, +}; + +export function normalizeFieldsDocument(schemaId: string | undefined, value: unknown): unknown { + const tuple = Array.isArray(value) ? value : undefined; + const documentSchema = tuple ? tuple[0] : (value as { schema?: unknown } | undefined)?.schema; + const schema = String(schemaId || documentSchema || ""); + if (!tuple || !FIELD_SCHEMAS.has(schema)) return value; + if (schema === "gamecult.fields.splats.v1") return normalizeSplats(tuple); + if (schema === "gamecult.fields.gravity.v1") return normalizeGravity(tuple); + return normalizeObjects(tuple); +} + +function normalizeSplats(value: unknown[]): Record { + const splats = array(value[9]); + return { + schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3], + runId: value[4], zoneIndex: value[5], zoneName: value[6], viewport: normalizeViewport(value[7]), + layers: array(value[8]).map(normalizeSplatLayer), + splats: { + count: splats[0] ?? 0, centerX: splats[1] || [], centerY: splats[2] || [], + halfExtentX: splats[3] || [], halfExtentY: splats[4] || [], rotationCos: splats[5] || [], + rotationSin: splats[6] || [], channel: splats[7] || [], falloff: splats[8] || [], + valueR: splats[9] || [], valueG: splats[10] || [], valueB: splats[11] || [], valueA: splats[12] || [], + sourceKey: splats[13] || [], layerIndex: splats[14] || [], sourceKind: splats[15] || [], + frequencyX: splats[16] || [], frequencyY: splats[17] || [], phaseX: splats[18] || [], + phaseY: splats[19] || [], animationSpeed: splats[20] || [], sourceFlags: splats[21] || [], + }, + }; +} + +function normalizeGravity(value: unknown[]): Record { + return { + schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3], + runId: value[4], zoneIndex: value[5], zoneName: value[6], viewport: normalizeViewport(value[7]), + gravityInfluences: array(value[8]).map(normalizeGravityInfluence), + bodies: array(value[9]).map(normalizeBody), terrainRadius: value[10], terrainDepth: value[11], + terrainDepthExponent: value[12], terrainWaveFrequency: value[13], + }; +} + +function normalizeObjects(value: unknown[]): Record { + return { + schema: value[0], frameId: value[1], publishedAtUtc: value[2], simulationTimeSeconds: value[3], + runId: value[4], zoneIndex: value[5], zoneName: value[6], currentEntityKey: value[7], + viewport: normalizeViewport(value[8]), controlledEntityIndices: array(value[9]), + objects: array(value[10]).map(normalizeObject), + }; +} + +function normalizeViewport(value: unknown): unknown { + const row = array(value); + return row.length ? { minX: row[0], minY: row[1], maxX: row[2], maxY: row[3] } : value; +} + +function normalizeSplatLayer(value: unknown): unknown { + const row = array(value); + if (!row.length) return value; + return { + layerKey: row[0], displayName: row[1], channel: row[2], blendMode: row[3], graphicsFormat: row[4], + clearBeforeDraw: row[5], clearR: row[6], clearG: row[7], clearB: row[8], clearA: row[9], + }; +} + +function normalizeGravityInfluence(value: unknown): unknown { + const row = array(value); + if (!row.length) return value; + return { + bodyKey: row[0], orbitKey: row[1], kind: row[2], x: row[3], y: row[4], radius: row[5], + gravityDepth: row[6], gravityDepthExponent: row[7], waveRadius: row[8], waveDepth: row[9], waveSpeed: row[10], + }; +} + +function normalizeBody(value: unknown): unknown { + const row = array(value); + if (!row.length) return value; + return { + bodyKey: row[0], orbitKey: row[1], name: row[2], kind: row[3], x: row[4], y: row[5], radius: row[6], + isAsteroidBelt: row[7], body: row[8], iconAsset: normalizeAsset(row[9]), + }; +} + +function normalizeObject(value: unknown): unknown { + const row = array(value); + if (!row.length) return value; + return { + entityIndex: row[0], entityKey: row[1], displayName: row[2], kind: row[3], factionKey: row[4], + x: row[5], y: row[6], z: row[7], directionX: row[8], directionY: row[9], velocityX: row[10], + velocityY: row[11], controlled: row[12], targetEntityIndex: row[13], isActive: row[14], visibility: row[15], + status: normalizeStatus(row[16]), inventory: array(row[17]).map(normalizeInventoryItem), iconAsset: normalizeAsset(row[18]), + }; +} + +function normalizeStatus(value: unknown): unknown { + const row = array(value); + return row.length ? { hull: row[0], shield: row[1], heat: row[2] } : value || {}; +} + +function normalizeInventoryItem(value: unknown): unknown { + const row = array(value); + if (!row.length) return value; + return { + source: row[0], itemKey: row[1], quantity: row[2], quality: row[3], durability: row[4], enabled: row[5], + sourceIndex: row[6], x: row[7], y: row[8], iconAsset: normalizeAsset(row[9]), + }; +} + +function normalizeAsset(value: unknown): unknown { + const row = array(value); + if (!row.length) return value || {}; + return { + assetKey: row[0], kind: row[1], uri: row[2], transport: row[3], contentHash: row[4], + mimeType: row[5], metadata: row[6] || {}, + }; +} + +function array(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index b020ec4..342371f 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1,3 +1,7 @@ +import { fieldsBrowserAdapter, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; + +export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js"; + export interface EveSurfaceComponent { id?: string; kind?: string; @@ -55,6 +59,15 @@ export interface EveProviderSurfaceAdvertisement { status?: string; transport?: string; worldInteraction?: EveSurfaceWorldInteraction; + requiresPlugins?: EvePluginRequirement[]; +} + +export interface EvePluginRequirement { + pluginId: string; + versionRange?: string; + availability?: "required" | "optional" | "optional-nested"; + requiredCapabilities?: string[]; + optionalCapabilities?: string[]; } export interface EveProviderAdvertisement { @@ -74,6 +87,7 @@ export interface EveBrowserLoweringOptions { commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise; documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; provider?: EveProviderAdvertisement; + pluginAdapters?: readonly EveBrowserPluginAdapter[]; source?: string; statusElement?: HTMLElement; } @@ -93,6 +107,31 @@ export interface EveBrowserProviderHostOptions { requestedSurfaceId?: string; source?: string; statusElement?: HTMLElement; + pluginAdapters?: readonly EveBrowserPluginAdapter[]; +} + +export const defaultBrowserPluginAdapters: readonly EveBrowserPluginAdapter[] = [fieldsBrowserAdapter]; + +export function resolveRequiredPluginAdapters( + surface: EveProviderSurfaceAdvertisement, + available: readonly EveBrowserPluginAdapter[] = defaultBrowserPluginAdapters, +): readonly EveBrowserPluginAdapter[] { + const resolved: EveBrowserPluginAdapter[] = []; + for (const requirement of surface.requiresPlugins || []) { + const adapter = available.find(candidate => candidate.pluginId === requirement.pluginId); + if (!adapter) { + if ((requirement.availability || "required") === "required") { + throw new Error(`Missing required Eve plugin adapter ${requirement.pluginId}.`); + } + continue; + } + const missing = (requirement.requiredCapabilities || []).filter(capability => !adapter.capabilities.includes(capability)); + if (missing.length > 0) { + throw new Error(`Eve plugin adapter ${requirement.pluginId} lacks required capabilities: ${missing.join(", ")}.`); + } + resolved.push(adapter); + } + return resolved; } export function selectAdvertisedSurface( @@ -115,6 +154,7 @@ export class EveBrowserProviderHost { private pollHandle: number | undefined; private provider: EveProviderAdvertisement | undefined; private selected: EveProviderSurfaceAdvertisement | undefined; + private pluginAdapters: readonly EveBrowserPluginAdapter[] = []; constructor( private readonly host: HTMLElement, @@ -125,6 +165,10 @@ 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, + ); this.active = true; await this.refresh(); const pollMs = Math.max(0, this.options.pollMs ?? 250); @@ -154,6 +198,7 @@ export class EveBrowserProviderHost { commandSink: intent => this.submit(intent), documentResolver: this.transport.resolveDocument, provider: this.provider, + pluginAdapters: this.pluginAdapters, source: this.options.source, statusElement: this.options.statusElement, }); @@ -989,10 +1034,10 @@ async function resolveGravitySurfaceDocuments( try { let changed = false; for (const request of requests) { - const resolved = await options.documentResolver(request, node); - let document = objectProps((resolved as EveResolvedDocument | undefined)?.document); + const resolved = await options.documentResolver(request, node) as EveResolvedDocument | undefined; + let document = objectProps(normalizePluginDocument(resolved?.schemaId || request.schemaId, resolved?.document, options)); if (Object.keys(document).length === 0) { - document = objectProps(await fetchGravitySurfaceDocument(request, options)); + document = objectProps(normalizePluginDocument(request.schemaId, await fetchGravitySurfaceDocument(request, options), options)); } if (!document || Object.keys(document).length === 0) continue; const key = request.slotId === "renderSplats" @@ -1012,6 +1057,16 @@ async function resolveGravitySurfaceDocuments( } } +function normalizePluginDocument( + schemaId: string | undefined, + value: unknown, + options: EveBrowserLoweringOptions, +): unknown { + const adapters = options.pluginAdapters || defaultBrowserPluginAdapters; + const adapter = adapters.find(candidate => !schemaId || candidate.schemas.includes(schemaId)); + return adapter ? adapter.normalizeDocument(schemaId, value) : value; +} + async function fetchGravitySurfaceDocument( request: EveEmbeddedDocumentRequest, options: EveBrowserLoweringOptions, diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index b13a45f..e59de07 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createEveCommandIntent, createWorldActionIntent, projectWorldScene, selectAdvertisedSurface } from "../dist/index.js"; +import { createEveCommandIntent, createWorldActionIntent, normalizeFieldsDocument, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js"; test("selects the requested surface from provider authority", () => { const provider = { @@ -94,3 +94,44 @@ test("command intents use the active surface advertisement boundary", () => { assert.equal(intent.commandBoundary, "aetheria.daemon.editor.commands"); assert.equal(intent.receiptSchema, "aetheria.editor_receipt.v1"); }); + +test("the browser fields adapter decodes plugin wire tuples", () => { + const gravity = normalizeFieldsDocument("gamecult.fields.gravity.v1", [ + "gamecult.fields.gravity.v1", 17, "now", 4.2, "run", 3, "zone", [-10, -20, 30, 40], + [["body", "orbit", "planet", 1, 2, 3, 4, 5, 6, 7, 8]], + [["body", "orbit", "World", "planet", 1, 2, 3, false, {}, ["icon", "image", "cultmesh://asset", "cultmesh", "hash", "image/png", {}]]], + 90, 12, 2, 0.5, + ]); + + assert.equal(gravity.frameId, 17); + assert.deepEqual(gravity.viewport, { minX: -10, minY: -20, maxX: 30, maxY: 40 }); + assert.equal(gravity.gravityInfluences[0].waveSpeed, 8); + assert.equal(gravity.bodies[0].iconAsset.uri, "cultmesh://asset"); +}); + +test("the browser fields adapter leaves unrelated provider documents untouched", () => { + const document = ["provider.schema.v1", 1, 2]; + assert.equal(normalizeFieldsDocument("provider.schema.v1", document), document); +}); + +test("required plugin advertisements resolve runtime adapters and report capability gaps", () => { + const surface = { + surfaceId: "world", + requiresPlugins: [{ + pluginId: "fields.surface", + availability: "required", + requiredCapabilities: ["field.surface2d", "gravity.surface"], + }], + }; + assert.equal(resolveRequiredPluginAdapters(surface)[0].pluginId, "fields.surface"); + assert.throws( + () => resolveRequiredPluginAdapters(surface, []), + /Missing required Eve plugin adapter fields\.surface/, + ); + assert.throws( + () => resolveRequiredPluginAdapters(surface, [{ + pluginId: "fields.surface", capabilities: ["field.surface2d"], schemas: [], normalizeDocument: (_, value) => value, + }]), + /lacks required capabilities: gravity\.surface/, + ); +}); diff --git a/web/eve-runtime-capability.json b/web/eve-runtime-capability.json index 68e1c46..16b8a76 100644 --- a/web/eve-runtime-capability.json +++ b/web/eve-runtime-capability.json @@ -20,6 +20,17 @@ "authorityWitnesses" ], "supportedPlugins": [ + { + "pluginId": "fields.surface", + "capabilities": [ + "field.surface2d", + "gravity.surface", + "field.scalar", + "field.vector", + "field.objects" + ], + "projection": "packages/eve-browser-lowering/src/fields-browser-adapter.ts" + }, { "pluginId": "sai.vn", "capabilities": [ diff --git a/web/surface.js b/web/surface.js index a2a4e0e..d82cd1a 100644 --- a/web/surface.js +++ b/web/surface.js @@ -2,6 +2,7 @@ import { createEveCommandIntent, emptyState, renderEveSurface, + resolveRequiredPluginAdapters, } from "../packages/eve-browser-lowering/dist/index.js"; import { compileEveDsl } from "./eve-dsl.js"; import { mergeProviderAdvertisement } from "./provider-advertisements.mjs"; @@ -145,6 +146,10 @@ function renderSurface(state, source) { surfaceId.textContent = state.providerId || "surface unknown"; surfaceVersion.textContent = `v${state.version ?? "?"}`; const providerId = state.providerId || currentProvider?.providerId || ""; + const advertisedSurface = currentProvider?.surfaces?.find(candidate => candidate.surfaceId === state.surface?.id) + || currentProvider?.surfaces?.[0] + || {}; + const pluginAdapters = resolveRequiredPluginAdapters(advertisedSurface); renderEveSurface(state, app, { activeSurfaceId: state.surface?.id, body: document.body, @@ -153,6 +158,7 @@ function renderSurface(state, source) { documentResolver: liveHermodr ? createHermodrDocumentResolver(providerId) : undefined, assetUrlResolver: liveHermodr ? resolveHermodrAssetUrl : undefined, provider: currentProvider, + pluginAdapters, source, statusElement: statusEl, });