Resolve browser field projection through plugin adapters
This commit is contained in:
parent
fbca4f2044
commit
fe71b8d2c5
11 changed files with 442 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
8
packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts
vendored
Normal file
8
packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts
vendored
Normal 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;
|
||||
124
packages/eve-browser-lowering/dist/fields-browser-adapter.js
vendored
Normal file
124
packages/eve-browser-lowering/dist/fields-browser-adapter.js
vendored
Normal 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 : [];
|
||||
}
|
||||
15
packages/eve-browser-lowering/dist/index.d.ts
vendored
15
packages/eve-browser-lowering/dist/index.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
33
packages/eve-browser-lowering/dist/index.js
vendored
33
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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;
|
||||
|
|
|
|||
136
packages/eve-browser-lowering/src/fields-browser-adapter.ts
Normal file
136
packages/eve-browser-lowering/src/fields-browser-adapter.ts
Normal 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 : [];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue