From 7a9493b67dc7dcb716d928753ca113e3d8809423 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 07:01:44 +0100 Subject: [PATCH] Lower interactive worlds in the browser --- docs/eve-migration-roadmap.md | 8 + packages/eve-browser-lowering/dist/index.d.ts | 12 ++ packages/eve-browser-lowering/dist/index.js | 128 ++++++++++++++ packages/eve-browser-lowering/src/index.ts | 157 ++++++++++++++++++ packages/eve-browser-lowering/styles.css | 79 +++++++++ .../test/browser-lowering.test.mjs | 46 ++++- 6 files changed, 429 insertions(+), 1 deletion(-) diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 94c8ecf..46c6c45 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -328,6 +328,14 @@ Recently cut: next ordered snapshot request buffered forever. CultNet TS unit tests, the CultMesh TS suite, Aetheria's local Stage 7C verifier, and its full Electron verifier all pass with the repaired protocol. +- The browser lowerer now gives `world.scene3d` / `world.entity3d` a generic + tactical projection. It normalizes provider-authored X/Z poses into a stable + viewport, distinguishes controlled and faction markers, and maps target, + movement, focus, and action input into provider-routed Eve command intents. + It does not advance poses or simulate combat locally. Aetheria's Electron + witness requires the Eve-owned world scene, provider entity markers, and one + controlled marker in the rendered DOM, so surrounding HUD text cannot stand + in for interactive-world lowering proof. - The clean EveUnity PlayMode client now consumes Aetheria's independently running daemon without importing Aetheria code. Aetheria builds its authored diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index 1063728..cee82f2 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -118,8 +118,20 @@ export interface EveResolvedDocument { schemaId?: string; surface?: EveSurfaceDocument["surface"]; } +export interface EveProjectedWorldEntity { + entityId: string; + faction: string; + kind: string; + label: string; + controlled: boolean; + xPercent: number; + yPercent: number; + source: EveSurfaceComponent; +} export declare function renderEveSurface(surface: EveSurfaceDocument, host: HTMLElement, options?: EveBrowserLoweringOptions): HTMLElement; export declare function renderEveComponent(node: EveSurfaceComponent, options?: EveBrowserLoweringOptions): HTMLElement; +export declare function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[]; +export declare function createWorldActionIntent(command: string, action: Record, options?: EveBrowserLoweringOptions): EveCommandIntent; export declare function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body?: HTMLElement): void; export declare function createEveCommandIntent(commandId: string, props?: Record, options?: EveBrowserLoweringOptions): EveCommandIntent; export declare function emptyState(message: string): HTMLElement; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index d2cab97..5a86e11 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -201,6 +201,9 @@ export function renderEveComponent(node, options = currentOptions) { if (kind === "field.surface2d" || kind === "gravity.surface") { return renderGravitySurface(node, props, layout, style, options); } + if (kind === "world.scene3d") { + return renderWorldScene(node, props, layout, style, options); + } if (kind === "image.background") { const view = el("div", "cultui-background"); const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); @@ -667,6 +670,131 @@ function renderGravitySurface(node, props, layout, style, options) { window.setTimeout(scheduleDraw, 1000); return canvas; } +export function projectWorldScene(node) { + const entities = (node.children || []).filter(child => child.kind === "world.entity3d"); + const positions = entities.map(entity => vector3Prop(objectProps(entity.props).position, [0, 0, 0])); + const xs = positions.map(position => position[0]); + const zs = positions.map(position => position[2]); + const minX = Math.min(...xs, -1); + const maxX = Math.max(...xs, 1); + const minZ = Math.min(...zs, -1); + const maxZ = Math.max(...zs, 1); + const spanX = Math.max(1, maxX - minX); + const spanZ = Math.max(1, maxZ - minZ); + const padding = 6; + return entities.map((entity, index) => { + const props = objectProps(entity.props); + return { + entityId: firstString(props.entityId, entity.id), + faction: stringProp(props.faction, "neutral"), + kind: stringProp(props.entityKind, "entity"), + label: firstString(props.label, props.entityId, entity.id, "Entity"), + controlled: boolProp(props.controllable), + xPercent: padding + ((positions[index][0] - minX) / spanX) * (100 - padding * 2), + yPercent: padding + (1 - ((positions[index][2] - minZ) / spanZ)) * (100 - padding * 2), + source: entity, + }; + }); +} +function renderWorldScene(node, props, layout, style, options) { + const scene = el("section", "cultui-world-scene"); + assignId(scene, node); + applyGeneratedLayout(scene, layout, style); + scene.tabIndex = 0; + scene.setAttribute("aria-label", stringProp(props.label, "Interactive world")); + const entities = projectWorldScene(node); + const controlled = entities.find(entity => entity.controlled); + const heading = el("div", "cultui-world-heading"); + heading.append(el("strong", "", stringProp(props.label, "World"))); + heading.append(el("span", "", `${entities.length} entities`)); + scene.append(heading); + const plane = el("div", "cultui-world-plane"); + for (const entity of entities) { + const marker = el("button", `cultui-world-entity faction-${cssIdentifier(entity.faction)} kind-${cssIdentifier(entity.kind)}`); + marker.type = "button"; + marker.style.left = `${entity.xPercent}%`; + marker.style.top = `${entity.yPercent}%`; + marker.dataset.entityId = entity.entityId; + marker.dataset.controlled = entity.controlled ? "true" : "false"; + marker.title = entity.label; + marker.setAttribute("aria-label", `${entity.label}, ${entity.kind}, ${entity.faction}`); + marker.append(el("span", "cultui-world-entity-glyph", worldEntityGlyph(entity.kind))); + marker.append(el("span", "cultui-world-entity-label", entity.label)); + const entityProps = objectProps(entity.source.props); + const targetCommand = firstString(entityProps.targetCommand, props.targetCommand, entityProps.focusCommand, props.focusCommand); + if (targetCommand) { + marker.addEventListener("click", () => emitWorldCommand(targetCommand, { + actorEntityId: controlled?.entityId || firstString(props.playerEntityId), + entityId: entity.entityId, + targetEntityId: entity.entityId, + targetEntityKey: entity.entityId, + }, entity.source, options)); + } + plane.append(marker); + } + scene.append(plane); + scene.addEventListener("keydown", event => { + const direction = worldDirectionForKey(event.key); + if (direction) { + event.preventDefault(); + const command = firstString(props.movementCommand, objectProps(controlled?.source.props).moveCommand); + if (command) + emitWorldCommand(command, { + actorEntityId: controlled?.entityId || firstString(props.playerEntityId), + actorEntityKey: controlled?.entityId || firstString(props.playerEntityId), + directionX: direction[0], + directionY: direction[1], + scalar: 1, + }, node, options); + return; + } + const command = event.key === " " + ? firstString(props.actionCommand, objectProps(controlled?.source.props).actionCommand) + : event.key.toLowerCase() === "f" + ? firstString(props.focusCommand, objectProps(controlled?.source.props).focusCommand) + : ""; + if (command) { + event.preventDefault(); + emitWorldCommand(command, { + actionId: event.key === " " ? "0" : "focus", + actorEntityId: controlled?.entityId || firstString(props.playerEntityId), + actorEntityKey: controlled?.entityId || firstString(props.playerEntityId), + }, node, options); + } + }); + return scene; +} +function emitWorldCommand(command, action, node, options) { + void options.commandSink?.(createWorldActionIntent(command, action, options), node); +} +export function createWorldActionIntent(command, action, options = currentOptions) { + return createEveCommandIntent(command, { action }, options); +} +function worldDirectionForKey(key) { + switch (key.toLowerCase()) { + case "w": + case "arrowup": return [0, 1]; + case "s": + case "arrowdown": return [0, -1]; + case "a": + case "arrowleft": return [-1, 0]; + case "d": + case "arrowright": return [1, 0]; + default: return undefined; + } +} +function worldEntityGlyph(kind) { + if (kind.includes("station")) + return "S"; + if (kind.includes("projectile")) + return "."; + if (kind.includes("ship")) + return "^"; + return "+"; +} +function cssIdentifier(value) { + return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-"); +} async function resolveGravitySurfaceDocuments(node, props, options, state) { if (!options.documentResolver || state.loading) return false; diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index a23e0e6..6908299 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -185,6 +185,17 @@ export interface EveResolvedDocument { surface?: EveSurfaceDocument["surface"]; } +export interface EveProjectedWorldEntity { + entityId: string; + faction: string; + kind: string; + label: string; + controlled: boolean; + xPercent: number; + yPercent: number; + source: EveSurfaceComponent; +} + const defaultStyleTokens: Record = { "--bg": "#020909", "--bg-top": "#020909", @@ -329,6 +340,10 @@ export function renderEveComponent( return renderGravitySurface(node, props, layout, style, options); } + if (kind === "world.scene3d") { + return renderWorldScene(node, props, layout, style, options); + } + if (kind === "image.background") { const view = el("div", "cultui-background"); const src = resolveAssetUrl(firstString(props.src, props.assetUri, props.assetRef, "")); @@ -815,6 +830,148 @@ function renderGravitySurface( return canvas; } +export function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[] { + const entities = (node.children || []).filter(child => child.kind === "world.entity3d"); + const positions = entities.map(entity => vector3Prop(objectProps(entity.props).position, [0, 0, 0])); + const xs = positions.map(position => position[0]); + const zs = positions.map(position => position[2]); + const minX = Math.min(...xs, -1); + const maxX = Math.max(...xs, 1); + const minZ = Math.min(...zs, -1); + const maxZ = Math.max(...zs, 1); + const spanX = Math.max(1, maxX - minX); + const spanZ = Math.max(1, maxZ - minZ); + const padding = 6; + return entities.map((entity, index) => { + const props = objectProps(entity.props); + return { + entityId: firstString(props.entityId, entity.id), + faction: stringProp(props.faction, "neutral"), + kind: stringProp(props.entityKind, "entity"), + label: firstString(props.label, props.entityId, entity.id, "Entity"), + controlled: boolProp(props.controllable), + xPercent: padding + ((positions[index][0] - minX) / spanX) * (100 - padding * 2), + yPercent: padding + (1 - ((positions[index][2] - minZ) / spanZ)) * (100 - padding * 2), + source: entity, + }; + }); +} + +function renderWorldScene( + node: EveSurfaceComponent, + props: Record, + layout: Record, + style: Record, + options: EveBrowserLoweringOptions, +): HTMLElement { + const scene = el("section", "cultui-world-scene"); + assignId(scene, node); + applyGeneratedLayout(scene, layout, style); + scene.tabIndex = 0; + scene.setAttribute("aria-label", stringProp(props.label, "Interactive world")); + const entities = projectWorldScene(node); + const controlled = entities.find(entity => entity.controlled); + + const heading = el("div", "cultui-world-heading"); + heading.append(el("strong", "", stringProp(props.label, "World"))); + heading.append(el("span", "", `${entities.length} entities`)); + scene.append(heading); + + const plane = el("div", "cultui-world-plane"); + for (const entity of entities) { + const marker = el("button", `cultui-world-entity faction-${cssIdentifier(entity.faction)} kind-${cssIdentifier(entity.kind)}`) as HTMLButtonElement; + marker.type = "button"; + marker.style.left = `${entity.xPercent}%`; + marker.style.top = `${entity.yPercent}%`; + marker.dataset.entityId = entity.entityId; + marker.dataset.controlled = entity.controlled ? "true" : "false"; + marker.title = entity.label; + marker.setAttribute("aria-label", `${entity.label}, ${entity.kind}, ${entity.faction}`); + marker.append(el("span", "cultui-world-entity-glyph", worldEntityGlyph(entity.kind))); + marker.append(el("span", "cultui-world-entity-label", entity.label)); + const entityProps = objectProps(entity.source.props); + const targetCommand = firstString(entityProps.targetCommand, props.targetCommand, entityProps.focusCommand, props.focusCommand); + if (targetCommand) { + marker.addEventListener("click", () => emitWorldCommand(targetCommand, { + actorEntityId: controlled?.entityId || firstString(props.playerEntityId), + entityId: entity.entityId, + targetEntityId: entity.entityId, + targetEntityKey: entity.entityId, + }, entity.source, options)); + } + plane.append(marker); + } + scene.append(plane); + + scene.addEventListener("keydown", event => { + const direction = worldDirectionForKey(event.key); + if (direction) { + event.preventDefault(); + const command = firstString(props.movementCommand, objectProps(controlled?.source.props).moveCommand); + if (command) emitWorldCommand(command, { + actorEntityId: controlled?.entityId || firstString(props.playerEntityId), + actorEntityKey: controlled?.entityId || firstString(props.playerEntityId), + directionX: direction[0], + directionY: direction[1], + scalar: 1, + }, node, options); + return; + } + const command = event.key === " " + ? firstString(props.actionCommand, objectProps(controlled?.source.props).actionCommand) + : event.key.toLowerCase() === "f" + ? firstString(props.focusCommand, objectProps(controlled?.source.props).focusCommand) + : ""; + if (command) { + event.preventDefault(); + emitWorldCommand(command, { + actionId: event.key === " " ? "0" : "focus", + actorEntityId: controlled?.entityId || firstString(props.playerEntityId), + actorEntityKey: controlled?.entityId || firstString(props.playerEntityId), + }, node, options); + } + }); + return scene; +} + +function emitWorldCommand( + command: string, + action: Record, + node: EveSurfaceComponent, + options: EveBrowserLoweringOptions, +): void { + void options.commandSink?.(createWorldActionIntent(command, action, options), node); +} + +export function createWorldActionIntent( + command: string, + action: Record, + options: EveBrowserLoweringOptions = currentOptions, +): EveCommandIntent { + return createEveCommandIntent(command, { action }, options); +} + +function worldDirectionForKey(key: string): [number, number] | undefined { + switch (key.toLowerCase()) { + case "w": case "arrowup": return [0, 1]; + case "s": case "arrowdown": return [0, -1]; + case "a": case "arrowleft": return [-1, 0]; + case "d": case "arrowright": return [1, 0]; + default: return undefined; + } +} + +function worldEntityGlyph(kind: string): string { + if (kind.includes("station")) return "S"; + if (kind.includes("projectile")) return "."; + if (kind.includes("ship")) return "^"; + return "+"; +} + +function cssIdentifier(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-"); +} + async function resolveGravitySurfaceDocuments( node: EveSurfaceComponent, props: Record, diff --git a/packages/eve-browser-lowering/styles.css b/packages/eve-browser-lowering/styles.css index 6bd17e0..533383b 100644 --- a/packages/eve-browser-lowering/styles.css +++ b/packages/eve-browser-lowering/styles.css @@ -814,6 +814,85 @@ body[data-pixel-art="true"] .cultui-background { padding: 6px 8px; } +.cultui-world-scene { + position: relative; + z-index: 2; + width: 100%; + min-height: min(72vh, 760px); + overflow: hidden; + outline: none; + background: rgba(2, 8, 12, 0.5); +} + +.cultui-world-scene:focus-visible { + box-shadow: inset 0 0 0 2px var(--accent); +} + +.cultui-world-heading { + position: absolute; + z-index: 3; + top: 12px; + left: 12px; + display: flex; + gap: 12px; + align-items: baseline; + color: var(--text); + font-family: var(--font-mono); + font-size: 0.75rem; + pointer-events: none; +} + +.cultui-world-heading span { + color: var(--quiet); +} + +.cultui-world-plane { + position: absolute; + inset: 0; + background: + linear-gradient(var(--pixel-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--pixel-grid) 1px, transparent 1px); + background-size: 48px 48px; +} + +.cultui-world-entity { + position: absolute; + display: grid; + justify-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid rgba(224, 238, 240, 0.65); + border-radius: 50%; + color: #f1f5f4; + background: rgba(24, 34, 38, 0.92); + transform: translate(-50%, -50%); + cursor: pointer; +} + +.cultui-world-entity.faction-player { border-color: #54d9c7; color: #9ff8e8; } +.cultui-world-entity.faction-raider, +.cultui-world-entity.faction-hostile { border-color: #ff695f; color: #ffaaa3; } +.cultui-world-entity[data-controlled="true"] { box-shadow: 0 0 0 3px rgba(255, 190, 72, 0.4); } + +.cultui-world-entity-glyph { + align-self: center; + font: 700 16px/1 var(--font-mono); +} + +.cultui-world-entity-label { + position: absolute; + top: 38px; + width: max-content; + max-width: 132px; + overflow: hidden; + color: var(--text); + font: 600 11px/1.2 var(--font-body); + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; +} + .cultui-swatch { width: 36px; min-height: 36px; diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index 1f83715..b13a45f 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, selectAdvertisedSurface } from "../dist/index.js"; +import { createEveCommandIntent, createWorldActionIntent, projectWorldScene, selectAdvertisedSurface } from "../dist/index.js"; test("selects the requested surface from provider authority", () => { const provider = { @@ -15,6 +15,50 @@ test("selects the requested surface from provider authority", () => { assert.throws(() => selectAdvertisedSurface(provider, "example.missing"), /does not advertise surface/); }); +test("world interactions emit provider-routed command intents without local authority", () => { + const intent = createWorldActionIntent("world.move", { + actorEntityId: "entity.player", + directionX: 1, + directionY: 0, + scalar: 1, + }, { + activeSurfaceId: "example.world", + clientId: "browser.world-test", + provider: { + providerId: "example", + surfaces: [{ + surfaceId: "example.world", + worldInteraction: { + commandBoundary: "example.commands", + receiptSchema: "example.receipt.v1", + }, + }], + }, + }); + + assert.equal(intent.command, "world.move"); + assert.equal(intent.commandBoundary, "example.commands"); + assert.equal(intent.receiptSchema, "example.receipt.v1"); + assert.equal(intent.payload.actorEntityId, "entity.player"); + assert.equal(intent.payload.directionX, 1); +}); + +test("projects provider world entities into a normalized tactical plane", () => { + const entities = projectWorldScene({ + kind: "world.scene3d", + children: [ + { id: "player", kind: "world.entity3d", props: { position: "-10, 0, -20", faction: "player", controllable: "true" } }, + { id: "target", kind: "world.entity3d", props: { position: "30, 0, 40", faction: "hostile" } }, + ], + }); + + assert.equal(entities.length, 2); + assert.equal(entities[0].controlled, true); + assert.equal(entities[1].faction, "hostile"); + assert.ok(entities.every(entity => entity.xPercent >= 6 && entity.xPercent <= 94)); + assert.ok(entities.every(entity => entity.yPercent >= 6 && entity.yPercent <= 94)); +}); + test("command intents use the active surface advertisement boundary", () => { const intent = createEveCommandIntent("aetheria.daemon.editor.inspect", { action: {