Route transient Eve controls through provider host
This commit is contained in:
parent
3a5e2057e0
commit
e5a81185bb
5 changed files with 158 additions and 41 deletions
|
|
@ -158,6 +158,7 @@ export declare class EveBrowserProviderHost {
|
|||
private submit;
|
||||
private consumeCommandResult;
|
||||
private presentCommandStatus;
|
||||
private loweringOptions;
|
||||
}
|
||||
export interface EveEmbeddedDocumentRequest {
|
||||
documentId: string;
|
||||
|
|
|
|||
37
packages/eve-browser-lowering/dist/index.js
vendored
37
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue