Route transient Eve controls through provider host

This commit is contained in:
Meta Vens 2026-08-23 01:26:45 +02:00
commit e5a81185bb
5 changed files with 158 additions and 41 deletions

View file

@ -158,6 +158,7 @@ export declare class EveBrowserProviderHost {
private submit;
private consumeCommandResult;
private presentCommandStatus;
private loweringOptions;
}
export interface EveEmbeddedDocumentRequest {
documentId: string;

View file

@ -111,19 +111,7 @@ export class EveBrowserProviderHost {
if (version === this.lastSurfaceVersion)
return;
this.lastSurfaceVersion = version;
renderEveSurface(surface, this.host, {
activeSurfaceId: this.selected.surfaceId,
assetUrlResolver: this.transport.resolveAssetUrl,
body: this.options.body,
clientId: this.options.clientId || "eve-browser",
commandSink: intent => this.submit(intent),
documentResolver: this.transport.resolveDocument,
provider: this.provider,
pluginAdapters: this.pluginAdapters,
source: this.options.source,
statusElement: this.options.statusElement,
draftStore: this.draftStore,
});
renderEveSurface(surface, this.host, this.loweringOptions());
}
catch (error) {
if (this.options.statusElement) {
@ -171,15 +159,24 @@ export class EveBrowserProviderHost {
region.replaceChildren(el("p", "eve-command-result-message", message));
const projection = transient;
if (projection?.surface?.root) {
region.append(renderEveComponent(projection.surface.root, {
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
clientId: this.options.clientId,
draftStore: this.draftStore,
pluginAdapters: this.pluginAdapters,
provider: this.provider,
}));
region.append(renderEveComponent(projection.surface.root, scopeEveBrowserOptions(projection, this.loweringOptions())));
}
}
loweringOptions() {
return {
activeSurfaceId: this.selected?.surfaceId,
assetUrlResolver: this.transport.resolveAssetUrl,
body: this.options.body,
clientId: this.options.clientId || "eve-browser",
commandSink: intent => this.submit(intent),
documentResolver: this.transport.resolveDocument,
provider: this.provider,
pluginAdapters: this.pluginAdapters,
source: this.options.source,
statusElement: this.options.statusElement,
draftStore: this.draftStore,
};
}
}
function requireCommandCapability(surface) {
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {

View file

@ -28,6 +28,9 @@ Derived state:
- local drafts and the latest command-result region are renderer-owned
transients; refresh may replace the surface projection but may not erase a
receipt before the player can perceive it;
- a provider-owned transient projection inherits the selected logical
surface's advertised command boundary while retaining its own command
descriptors and component context;
- loaded font stylesheets are a document resource cache, not active-surface
state.
@ -42,6 +45,8 @@ Shared paths:
- initial render, embedded render, plugin render, binding updates, command
creation, and asset resolution all receive the same host-scoped context;
- retained and command-result projections use the same host transport options;
a transient presentation surface ID never becomes command-routing authority;
- initial binding hydration and live binding watches use the same component
projection replacement primitive.
- operation capture resolves edited drafts first and otherwise uses the
@ -63,4 +68,6 @@ Verification:
- untouched select and numeric defaults are present in captured operations;
- accepted and denied command results remain visible after authoritative
refresh;
- controls inside a transient command-result projection invoke its descriptor
through the selected provider surface and canonical command sink;
- disposing or rerendering one host cannot unsubscribe the other.

View file

@ -269,19 +269,7 @@ export class EveBrowserProviderHost {
const version = `${surface.providerId || ""}:${surface.surface?.id || ""}:${surface.version ?? ""}`;
if (version === this.lastSurfaceVersion) return;
this.lastSurfaceVersion = version;
renderEveSurface(surface, this.host, {
activeSurfaceId: this.selected.surfaceId,
assetUrlResolver: this.transport.resolveAssetUrl,
body: this.options.body,
clientId: this.options.clientId || "eve-browser",
commandSink: intent => this.submit(intent),
documentResolver: this.transport.resolveDocument,
provider: this.provider,
pluginAdapters: this.pluginAdapters,
source: this.options.source,
statusElement: this.options.statusElement,
draftStore: this.draftStore,
});
renderEveSurface(surface, this.host, this.loweringOptions());
} catch (error) {
if (this.options.statusElement) {
this.options.statusElement.textContent = error instanceof Error ? error.message : "Eve surface unavailable.";
@ -334,15 +322,28 @@ export class EveBrowserProviderHost {
region.replaceChildren(el("p", "eve-command-result-message", message));
const projection = transient as EveSurfaceDocument | undefined;
if (projection?.surface?.root) {
region.append(renderEveComponent(projection.surface.root, {
activeSurfaceId: projection.surface.id || this.selected?.surfaceId,
clientId: this.options.clientId,
draftStore: this.draftStore,
pluginAdapters: this.pluginAdapters,
provider: this.provider,
}));
region.append(renderEveComponent(
projection.surface.root,
scopeEveBrowserOptions(projection, this.loweringOptions()),
));
}
}
private loweringOptions(): EveBrowserLoweringOptions {
return {
activeSurfaceId: this.selected?.surfaceId,
assetUrlResolver: this.transport.resolveAssetUrl,
body: this.options.body,
clientId: this.options.clientId || "eve-browser",
commandSink: intent => this.submit(intent),
documentResolver: this.transport.resolveDocument,
provider: this.provider,
pluginAdapters: this.pluginAdapters,
source: this.options.source,
statusElement: this.options.statusElement,
draftStore: this.draftStore,
};
}
}
function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void {

View file

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { EveBrowserDraftStore, renderEveSurface } from "../dist/index.js";
import { EveBrowserDraftStore, EveBrowserProviderHost, renderEveSurface } from "../dist/index.js";
function installDom() {
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='a'></div><div id='b'></div></body></html>", {
@ -433,3 +433,114 @@ test("authoritative refresh preserves the renderer-owned accessible command resu
restoreDom();
}
});
test("transient projection controls use the selected provider command boundary", async () => {
const restoreDom = installDom();
try {
const host = document.querySelector("#a");
const commands = [];
const descriptor = (command, payloadSchema) => ({
schema: "gamecult.eve.command.v1",
command,
payloadSchema,
});
const surfaceDocument = {
type: "surface-state",
schema: "gamecult.eve.surface.v1",
providerId: "ghostlight",
providerKind: "narrative.simulation",
title: "Ghostlight",
version: 7,
updatedAtUtc: "2026-08-22T00:00:00Z",
surface: {
id: "ghostlight.play",
styles: {},
root: {
id: "root",
kind: "control.button",
props: { label: "Assess", command: "world.assess" },
children: [],
},
},
commands: [descriptor("world.assess", "ghostlight.player_action_assess.v1")],
};
const transientProjection = {
type: "surface-state",
schema: "gamecult.eve.surface.v1",
providerId: "ghostlight",
providerKind: "narrative.simulation.command-result",
title: "Assessment",
version: 7,
updatedAtUtc: "2026-08-22T00:00:01Z",
surface: {
id: "ghostlight.command-result",
styles: {},
root: {
id: "roll",
kind: "control.button",
props: {
label: "Roll the d20",
command: "world.attempt",
action: { assessment_digest: "sha256:assessment" },
},
children: [],
},
},
commands: [descriptor("world.attempt", "ghostlight.player_action_attempt.v1")],
};
const receipt = command => ({
schema: "gamecult.eve.command_receipt.v1",
receiptId: `receipt:${command}`,
commandId: `command:${command}`,
command,
state: "accepted",
ownerRepo: "GameCult/Ghostlight",
authority: "WorldKernel",
providerId: "ghostlight",
surfaceId: "ghostlight.play",
message: "accepted",
diagnostics: [],
issuedAtUtc: "2026-08-22T00:00:02Z",
sourceVersion: 7,
});
const transport = {
providerAdvertisement: async () => ({
providerId: "ghostlight",
surfaces: [{
surfaceId: "ghostlight.play",
worldInteraction: {
commandBoundary: "ghostlight.eve.commands",
receiptSchema: "gamecult.eve.command_result.v1",
},
}],
}),
surface: async () => surfaceDocument,
submitCommand: async intent => {
commands.push(intent);
return {
schema: "gamecult.eve.command_result.v1",
receipt: receipt(intent.operation.operationId),
...(intent.operation.operationId === "world.assess" ? { transientProjection } : {}),
};
},
};
const providerHost = new EveBrowserProviderHost(host, transport, { pollMs: 0 });
await providerHost.start();
host.querySelector("button").click();
await new Promise(resolve => setTimeout(resolve, 0));
const roll = host.querySelector(".eve-command-result-region button");
assert.equal(roll.textContent, "Roll the d20");
roll.click();
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(commands.map(command => command.operation.operationId), ["world.assess", "world.attempt"]);
assert.equal(commands[1].surfaceId, "ghostlight.play");
assert.equal(commands[1].operation.schemaId, "ghostlight.player_action_attempt.v1");
assert.equal(commands[1].commandBoundary, "ghostlight.eve.commands");
assert.equal(commands[1].payload.assessment_digest, "sha256:assessment");
providerHost.stop();
} finally {
restoreDom();
}
});