Resolve browser field projection through plugin adapters

This commit is contained in:
Meta Vens 2026-07-10 15:13:59 +01:00
commit fe71b8d2c5
11 changed files with 442 additions and 15 deletions

View file

@ -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

View file

@ -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.

View file

@ -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;

View file

@ -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 : [];
}

View file

@ -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<void>;
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
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<void>;
stop(): void;

View file

@ -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;

View file

@ -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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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 : [];
}

View file

@ -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<void>;
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
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<void> {
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,

View file

@ -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/,
);
});

View file

@ -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": [

View file

@ -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,
});