Repair Eve browser command and editable binding contracts
This commit is contained in:
parent
e7b09f755d
commit
4338a42c6e
32 changed files with 1533 additions and 86 deletions
|
|
@ -58,6 +58,13 @@ Providers own:
|
|||
- reconciliation;
|
||||
- side effects.
|
||||
|
||||
Command delivery returns `gamecult.eve.command_result.v1`. The generic result
|
||||
always contains the provider's persisted receipt and may carry a transient Eve
|
||||
projection. A single optional plugin payload names its plugin id and schema;
|
||||
only that plugin's renderer adapter may consume it. The payload cannot accept a
|
||||
provider command or mutate provider state, so plugin rendering never becomes a
|
||||
shadow authority.
|
||||
|
||||
Odin/CultMesh owns:
|
||||
|
||||
- provider advertisement discovery;
|
||||
|
|
|
|||
|
|
@ -247,14 +247,20 @@ invocations use `gamecult.eve.command_invocation.v1`:
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "surface-command",
|
||||
"schema": "gamecult.eve.command_invocation.v1",
|
||||
"providerId": "gamecult.home.vn",
|
||||
"surfaceId": "sai.visual_novel.surface",
|
||||
"command": "story.choose",
|
||||
"operation": {
|
||||
"operationId": "story.choose",
|
||||
"schemaId": "sai.story_choice.v1",
|
||||
"idempotencyKey": "21a816d5-7ed5-4513-afb0-0a9bc9aa334b",
|
||||
"routeHint": { "sourceVersion": 41 }
|
||||
},
|
||||
"payload": { "index": 0, "targetPath": "eve" },
|
||||
"issuedAt": "2026-05-31T00:00:00.000Z",
|
||||
"clientId": "browser.reference"
|
||||
"clientId": "browser.reference",
|
||||
"commandBoundary": "sai.commands",
|
||||
"receiptSchema": "gamecult.eve.command_receipt.v1"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -283,6 +289,13 @@ are not the authoritative live command model and should not appear as public
|
|||
runtime construction APIs.
|
||||
|
||||
Renderer command requests carry `CultMeshOperationInvocationDescriptor` values.
|
||||
Editable controls remain state bindings rather than form-shaped command DTOs.
|
||||
Bindings may additionally name their document and field, value kind, access
|
||||
mode, authority, stable `bindingName`, and optional `writeCommand`. Renderers
|
||||
keep unaccepted edits as local drafts. Operations declare `captureBindings` and
|
||||
receive those values under `payload.bindings`; accepted command results may
|
||||
request that exact bindings be cleared. HTML form elements are an accessibility
|
||||
lowering detail, not part of Eve's public ontology.
|
||||
That invocation descriptor is the canonical operation identity at click/change
|
||||
time and preserves request schema, preferred route, and optional idempotency.
|
||||
Renderer command requests also carry `CultMeshOperationPayload`, a shared
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ This package owns the shared browser renderer contract:
|
|||
- `renderEveSurface(surface, host, options)` lowers an Eve surface document into a DOM host.
|
||||
- `renderEveComponent(component, options)` lowers a single component tree.
|
||||
- `applyEveSurfaceStyles(styles, body)` maps portable Eve style tokens to CSS custom properties.
|
||||
- `createEveCommandIntent(commandId, props, options)` creates the browser command envelope for host daemons and carries the provider-advertised `commandBoundary` / `receiptSchema` when the active surface exposes `worldInteraction`.
|
||||
- `createEveCommandIntent(commandId, props, options)` emits the canonical `gamecult.eve.command_invocation.v1` operation descriptor, including the provider-advertised command boundary and receipt schema, a source-version route hint, and a fresh idempotency key.
|
||||
- `EveBrowserProviderHost` owns advertisement-backed surface selection and uses one provider-neutral embedded-document resolver; provider schema dispatch stays behind the host transport boundary.
|
||||
- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves draft values by provider/surface/binding identity and an operation may capture those bindings atomically under `payload.bindings`.
|
||||
- `gamecult.eve.command_result.v1` carries the persisted receipt, an optional one-time Eve projection, a binding-clear directive, and at most one plugin-scoped payload. Only the matching plugin adapter may consume that payload.
|
||||
|
||||
Host applications own transport and command delivery. Providers own command acceptance and receipts through their advertisements. For example, Eir resolves surfaces through Odin/CultMesh and posts commands to `/eir/command`; Aetheria resolves surfaces through Electron IPC. Neither host should duplicate DOM lowering or import provider internals to find a command route.
|
||||
|
||||
An HTML `<form>` may be used internally for keyboard behavior, but it is not an Eve primitive or state owner. Eve exposes editable bindings and typed operations; HTML remains a lowering detail.
|
||||
|
||||
Asset references are left as-is unless the host provides `assetUrlResolver`. Eir uses that hook only for CultMesh/CultCache asset URIs such as `cultmesh://schema/record` or `cultcache://schema/record`; ordinary HTTPS assets and web fonts remain ordinary browser URLs.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ export interface EveBrowserPluginAdapter {
|
|||
componentKinds: readonly string[];
|
||||
schemas: readonly string[];
|
||||
normalizeDocument(schemaId: string | undefined, value: unknown): unknown;
|
||||
consumeCommandResult?: (payload: {
|
||||
pluginId: string;
|
||||
schemaId: string;
|
||||
payload: Record<string, unknown>;
|
||||
}, result: unknown, context: {
|
||||
provider: unknown;
|
||||
surface: unknown;
|
||||
}) => void | Promise<void>;
|
||||
renderComponent?: (component: FieldsComponent, props: Record<string, unknown>, layout: Record<string, unknown>, style: Record<string, unknown>, options: FieldsLoweringOptions, context: FieldsRuntimeContext) => HTMLElement;
|
||||
}
|
||||
export declare const fieldsBrowserAdapter: EveBrowserPluginAdapter;
|
||||
|
|
|
|||
43
packages/eve-browser-lowering/dist/index.d.ts
vendored
43
packages/eve-browser-lowering/dist/index.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
|||
import { type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
|
||||
import { type EveCommandInvocation, type EveCommandResult } from "@gamecult/eve-contracts";
|
||||
export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
|
||||
export interface EveSurfaceComponent {
|
||||
id?: string;
|
||||
|
|
@ -19,6 +20,13 @@ export interface EveStateBindingDescriptor {
|
|||
schemaId: string;
|
||||
routeKind: string;
|
||||
routeDescription?: string;
|
||||
bindingName?: string;
|
||||
documentId?: string;
|
||||
fieldPath?: string;
|
||||
valueKind?: "string" | "number" | "boolean" | "choice" | "string-list";
|
||||
accessMode?: "read" | "write" | "read-write" | "local-draft";
|
||||
authority?: string;
|
||||
writeCommand?: string;
|
||||
}
|
||||
export interface EveStateBindingHandle {
|
||||
latest(): Promise<unknown>;
|
||||
|
|
@ -36,6 +44,12 @@ export interface EveSurfaceDocument {
|
|||
styles?: EveSurfaceStyles;
|
||||
};
|
||||
mesh?: unknown;
|
||||
commands?: Array<{
|
||||
command: string;
|
||||
payloadSchema?: string;
|
||||
captureBindings?: string[];
|
||||
transport?: string;
|
||||
}>;
|
||||
}
|
||||
export type EveSurfaceStyles = {
|
||||
tokens?: Record<string, unknown>;
|
||||
|
|
@ -49,18 +63,8 @@ export type EveSurfaceStyles = {
|
|||
name?: string;
|
||||
value?: unknown;
|
||||
}>;
|
||||
export interface EveCommandIntent {
|
||||
type: "surface-command";
|
||||
schema: "gamecult.eve.command_invocation.v1";
|
||||
providerId: string;
|
||||
surfaceId: string;
|
||||
command: string;
|
||||
commandBoundary?: string;
|
||||
receiptSchema?: string;
|
||||
payload: Record<string, unknown>;
|
||||
issuedAt: string;
|
||||
clientId: string;
|
||||
}
|
||||
export type EveCommandIntent = EveCommandInvocation;
|
||||
export type EveBrowserCommandResult = EveCommandResult;
|
||||
export interface EveSemanticListItem {
|
||||
label: string;
|
||||
status: string;
|
||||
|
|
@ -99,13 +103,14 @@ export interface EveBrowserLoweringOptions {
|
|||
assetUrlResolver?: (uri: string, surface: EveSurfaceDocument | undefined) => string;
|
||||
body?: HTMLElement;
|
||||
clientId?: string;
|
||||
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise<void>;
|
||||
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise<EveBrowserCommandResult | void>;
|
||||
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
|
||||
provider?: EveProviderAdvertisement;
|
||||
pluginAdapters?: readonly EveBrowserPluginAdapter[];
|
||||
stateBindingResolver?: EveStateBindingResolver;
|
||||
source?: string;
|
||||
statusElement?: HTMLElement;
|
||||
draftStore?: EveBrowserDraftStore;
|
||||
}
|
||||
export interface EveBrowserProviderTransport {
|
||||
providerAdvertisement(): Promise<EveProviderAdvertisement>;
|
||||
|
|
@ -123,6 +128,15 @@ export interface EveBrowserProviderHostOptions {
|
|||
statusElement?: HTMLElement;
|
||||
pluginAdapters?: readonly EveBrowserPluginAdapter[];
|
||||
}
|
||||
export declare class EveBrowserDraftStore {
|
||||
private readonly values;
|
||||
private key;
|
||||
has(providerId: string, surfaceId: string, bindingName: string): boolean;
|
||||
get(providerId: string, surfaceId: string, bindingName: string): unknown;
|
||||
set(providerId: string, surfaceId: string, bindingName: string, value: unknown): void;
|
||||
clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void;
|
||||
capture(providerId: string, surfaceId: string, bindingNames: readonly string[]): Record<string, unknown>;
|
||||
}
|
||||
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;
|
||||
|
|
@ -136,11 +150,14 @@ export declare class EveBrowserProviderHost {
|
|||
private provider;
|
||||
private selected;
|
||||
private pluginAdapters;
|
||||
private readonly draftStore;
|
||||
constructor(host: HTMLElement, transport: EveBrowserProviderTransport, options?: EveBrowserProviderHostOptions);
|
||||
start(): Promise<void>;
|
||||
stop(): void;
|
||||
refresh(): Promise<void>;
|
||||
private submit;
|
||||
private consumeCommandResult;
|
||||
private presentCommandStatus;
|
||||
}
|
||||
export interface EveEmbeddedDocumentRequest {
|
||||
documentId: string;
|
||||
|
|
|
|||
359
packages/eve-browser-lowering/dist/index.js
vendored
359
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -1,5 +1,35 @@
|
|||
import { fieldsBrowserAdapter } from "./fields-browser-adapter.js";
|
||||
import { parseEveCommandResult, } from "@gamecult/eve-contracts";
|
||||
export { fieldsBrowserAdapter, normalizeFieldsDocument } from "./fields-browser-adapter.js";
|
||||
export class EveBrowserDraftStore {
|
||||
values = new Map();
|
||||
key(providerId, surfaceId, bindingName) {
|
||||
return `${providerId}\u001f${surfaceId}\u001f${bindingName}`;
|
||||
}
|
||||
has(providerId, surfaceId, bindingName) {
|
||||
return this.values.has(this.key(providerId, surfaceId, bindingName));
|
||||
}
|
||||
get(providerId, surfaceId, bindingName) {
|
||||
return this.values.get(this.key(providerId, surfaceId, bindingName));
|
||||
}
|
||||
set(providerId, surfaceId, bindingName, value) {
|
||||
this.values.set(this.key(providerId, surfaceId, bindingName), value);
|
||||
}
|
||||
clear(providerId, surfaceId, bindingNames) {
|
||||
if (bindingNames?.length) {
|
||||
for (const name of bindingNames)
|
||||
this.values.delete(this.key(providerId, surfaceId, name));
|
||||
return;
|
||||
}
|
||||
const prefix = `${providerId}\u001f${surfaceId}\u001f`;
|
||||
for (const key of this.values.keys())
|
||||
if (key.startsWith(prefix))
|
||||
this.values.delete(key);
|
||||
}
|
||||
capture(providerId, surfaceId, bindingNames) {
|
||||
return Object.fromEntries(bindingNames.map(name => [name, this.get(providerId, surfaceId, name)]));
|
||||
}
|
||||
}
|
||||
export const defaultBrowserPluginAdapters = [fieldsBrowserAdapter];
|
||||
export function resolveRequiredPluginAdapters(surface, available = defaultBrowserPluginAdapters) {
|
||||
const resolved = [];
|
||||
|
|
@ -39,15 +69,26 @@ export class EveBrowserProviderHost {
|
|||
provider;
|
||||
selected;
|
||||
pluginAdapters = [];
|
||||
draftStore = new EveBrowserDraftStore();
|
||||
constructor(host, transport, options = {}) {
|
||||
this.host = host;
|
||||
this.transport = transport;
|
||||
this.options = options;
|
||||
}
|
||||
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);
|
||||
try {
|
||||
this.provider = await this.transport.providerAdvertisement();
|
||||
this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId);
|
||||
requireCommandCapability(this.selected);
|
||||
this.pluginAdapters = resolveRequiredPluginAdapters(this.selected, this.options.pluginAdapters || defaultBrowserPluginAdapters);
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eve provider capabilities are unavailable.";
|
||||
this.host.replaceChildren(emptyState(message));
|
||||
if (this.options.statusElement)
|
||||
this.options.statusElement.textContent = message;
|
||||
throw error;
|
||||
}
|
||||
this.active = true;
|
||||
await this.refresh();
|
||||
const pollMs = Math.max(0, this.options.pollMs ?? 250);
|
||||
|
|
@ -81,6 +122,7 @@ export class EveBrowserProviderHost {
|
|||
pluginAdapters: this.pluginAdapters,
|
||||
source: this.options.source,
|
||||
statusElement: this.options.statusElement,
|
||||
draftStore: this.draftStore,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
|
|
@ -90,10 +132,59 @@ export class EveBrowserProviderHost {
|
|||
}
|
||||
}
|
||||
async submit(intent) {
|
||||
await this.transport.submitCommand(intent);
|
||||
try {
|
||||
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
||||
await this.consumeCommandResult(intent, result);
|
||||
}
|
||||
catch (error) {
|
||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
|
||||
return;
|
||||
}
|
||||
this.lastSurfaceVersion = "";
|
||||
window.setTimeout(() => void this.refresh(), 100);
|
||||
}
|
||||
async consumeCommandResult(intent, result) {
|
||||
const accepted = result.receipt.state === "accepted" || result.receipt.state === "reconciled";
|
||||
if (accepted && result.draftDirective?.clear) {
|
||||
this.draftStore.clear(intent.providerId, intent.surfaceId, result.draftDirective.bindingNames);
|
||||
}
|
||||
if (result.pluginPayload) {
|
||||
const adapter = this.pluginAdapters.find(candidate => candidate.pluginId === result.pluginPayload?.pluginId);
|
||||
if (!adapter?.consumeCommandResult) {
|
||||
throw new Error(`No Eve plugin adapter can consume ${result.pluginPayload.pluginId}.`);
|
||||
}
|
||||
await adapter.consumeCommandResult(result.pluginPayload, result, {
|
||||
provider: this.provider,
|
||||
surface: this.selected,
|
||||
});
|
||||
}
|
||||
this.presentCommandStatus(result.receipt.message || result.receipt.state, accepted ? "status" : "error", result.transientProjection);
|
||||
}
|
||||
presentCommandStatus(message, role, transient) {
|
||||
let region = this.host.querySelector(":scope > .eve-command-result-region");
|
||||
if (!region) {
|
||||
region = el("section", "eve-command-result-region");
|
||||
region.setAttribute("aria-live", "polite");
|
||||
this.host.append(region);
|
||||
}
|
||||
region.setAttribute("role", role);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
function requireCommandCapability(surface) {
|
||||
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
|
||||
throw new Error(`Eve surface ${surface.surfaceId || "(unknown)"} does not advertise a command boundary and receipt schema.`);
|
||||
}
|
||||
}
|
||||
const defaultStyleTokens = {
|
||||
"--bg": "#020909",
|
||||
|
|
@ -124,6 +215,7 @@ const defaultStyleTokens = {
|
|||
};
|
||||
const loadedFontStylesheets = new Map();
|
||||
const activeSurfaceBindings = new WeakMap();
|
||||
const surfaceDraftStores = new WeakMap();
|
||||
const eveBrowserRenderContext = Symbol("eve.browser.render-context");
|
||||
function scopeEveBrowserOptions(surface, options) {
|
||||
return {
|
||||
|
|
@ -140,7 +232,12 @@ function renderContext(options) {
|
|||
}
|
||||
export function renderEveSurface(surface, host, options = {}) {
|
||||
activeSurfaceBindings.get(host)?.dispose();
|
||||
const scopedOptions = scopeEveBrowserOptions(surface, options);
|
||||
let draftStore = options.draftStore || surfaceDraftStores.get(host);
|
||||
if (!draftStore) {
|
||||
draftStore = new EveBrowserDraftStore();
|
||||
surfaceDraftStores.set(host, draftStore);
|
||||
}
|
||||
const scopedOptions = scopeEveBrowserOptions(surface, { ...options, draftStore });
|
||||
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host);
|
||||
host.dataset.provider = surface.providerId || options.provider?.providerId || "";
|
||||
if (options.body) {
|
||||
|
|
@ -686,14 +783,15 @@ function renderEveComponentProjection(node, options) {
|
|||
if (kind === "input.select" || kind === "control.select") {
|
||||
return renderSelect(node, props, children, layout, style, options);
|
||||
}
|
||||
if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") {
|
||||
const field = el("label", "field cultui-field");
|
||||
assignId(field, node);
|
||||
applyBoxProps(field, props);
|
||||
applyGeneratedLayout(field, layout, style);
|
||||
field.append(el("span", "field-label", stringProp(props.label, "")));
|
||||
field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value)));
|
||||
return field;
|
||||
if (kind === "control.choice-group") {
|
||||
return renderChoiceGroup(node, props, children, layout, style, options);
|
||||
}
|
||||
if (kind === "input.number" || kind === "control.range" || kind === "control.input.number" ||
|
||||
kind === "control.input.text" || kind === "control.input.textarea") {
|
||||
return renderEditableControl(node, kind, props, layout, style, options);
|
||||
}
|
||||
if (kind === "control.tabs") {
|
||||
return renderTabs(node, props, children, layout, style, options);
|
||||
}
|
||||
if (kind === "color.swatch") {
|
||||
const swatch = el("div", "paint-swatch cultui-swatch");
|
||||
|
|
@ -1590,25 +1688,57 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) {
|
|||
const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId);
|
||||
const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target);
|
||||
const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema);
|
||||
if (!commandBoundary || !receiptSchema) {
|
||||
throw new Error(`Eve surface ${surfaceId} cannot invoke commands without an advertised command boundary and receipt schema.`);
|
||||
}
|
||||
const operationId = commandId || stringProp(action.type, "invoke");
|
||||
const descriptor = surface?.commands?.find(command => command.command === operationId);
|
||||
const captureBindings = stringArray(descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings);
|
||||
const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {};
|
||||
const payload = commandPayload(action);
|
||||
if (captureBindings.length)
|
||||
payload.bindings = bindings;
|
||||
const intent = {
|
||||
type: "surface-command",
|
||||
schema: "gamecult.eve.command_invocation.v1",
|
||||
providerId,
|
||||
surfaceId,
|
||||
command: commandId || stringProp(action.type, "invoke"),
|
||||
payload: {
|
||||
...action,
|
||||
transport: props.transport ?? null,
|
||||
operation: {
|
||||
operationId,
|
||||
schemaId: firstString(descriptor?.payloadSchema, action.schemaId, props.payloadSchema, "gamecult.eve.operation_payload.v1"),
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
routeHint: {
|
||||
sourceVersion: Math.max(0, Math.trunc(surface?.version ?? 0)),
|
||||
...(firstString(descriptor?.transport, props.transport, action.transport)
|
||||
? { transport: firstString(descriptor?.transport, props.transport, action.transport) }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
payload,
|
||||
issuedAt: new Date().toISOString(),
|
||||
clientId: options.clientId || "eve.browser",
|
||||
commandBoundary,
|
||||
receiptSchema,
|
||||
};
|
||||
if (commandBoundary)
|
||||
intent.commandBoundary = commandBoundary;
|
||||
if (receiptSchema)
|
||||
intent.receiptSchema = receiptSchema;
|
||||
return intent;
|
||||
}
|
||||
function commandPayload(action) {
|
||||
const payload = { ...action };
|
||||
for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) {
|
||||
delete payload[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
function randomIdempotencyKey() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function")
|
||||
return globalThis.crypto.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto?.getRandomValues?.(bytes);
|
||||
if (!bytes.some(Boolean)) {
|
||||
for (let index = 0; index < bytes.length; index++)
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return [...bytes].map(value => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
function resolveAdvertisedWorldInteraction(options, surfaceId) {
|
||||
const surfaces = [
|
||||
...(options.provider?.surfaces || []),
|
||||
|
|
@ -1643,7 +1773,172 @@ function wireCommand(element, node, commandId, props, options) {
|
|||
}
|
||||
});
|
||||
}
|
||||
function editableBindingContext(node, props, options) {
|
||||
const binding = (node.stateBindings || []).find(candidate => candidate.targetProp === "value")
|
||||
?? node.stateBindings?.[0];
|
||||
const surface = renderContext(options)?.surface;
|
||||
const providerId = options.provider?.providerId || surface?.providerId || "surface unknown";
|
||||
const surfaceId = options.activeSurfaceId || surface?.surface?.id || providerId;
|
||||
const bindingName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id);
|
||||
const draft = bindingName && options.draftStore?.has(providerId, surfaceId, bindingName)
|
||||
? options.draftStore.get(providerId, surfaceId, bindingName)
|
||||
: undefined;
|
||||
const accessMode = binding?.accessMode || (binding ? "read" : "local-draft");
|
||||
return {
|
||||
binding,
|
||||
bindingName,
|
||||
providerId,
|
||||
surfaceId,
|
||||
value: draft ?? props.value ?? "",
|
||||
disabled: boolProp(props.disabled) || accessMode === "read",
|
||||
};
|
||||
}
|
||||
function updateDraft(context, value, options) {
|
||||
if (context.bindingName)
|
||||
options.draftStore?.set(context.providerId, context.surfaceId, context.bindingName, value);
|
||||
}
|
||||
function publishDirectEdit(context, value, node, options) {
|
||||
const binding = context.binding;
|
||||
const writeCommand = binding?.writeCommand;
|
||||
if (!binding || !writeCommand || !context.bindingName)
|
||||
return;
|
||||
void options.commandSink?.(createEveCommandIntent(writeCommand, {
|
||||
action: {
|
||||
bindingName: context.bindingName,
|
||||
documentId: binding.documentId,
|
||||
fieldPath: binding.fieldPath,
|
||||
value,
|
||||
},
|
||||
}, options), node);
|
||||
}
|
||||
function renderEditableControl(node, kind, props, layout, style, options) {
|
||||
const context = editableBindingContext(node, props, options);
|
||||
const field = el("label", "field cultui-field cultui-editable");
|
||||
assignId(field, node);
|
||||
applyBoxProps(field, props);
|
||||
applyGeneratedLayout(field, layout, style);
|
||||
const label = stringProp(props.label, "");
|
||||
if (label)
|
||||
field.append(el("span", "field-label", label));
|
||||
const control = kind === "control.input.textarea"
|
||||
? el("textarea", "field-control cultui-field-input")
|
||||
: el("input", "field-control cultui-field-input");
|
||||
if (control instanceof HTMLInputElement) {
|
||||
control.type = kind.includes("number") || kind === "control.range" ? "number" : "text";
|
||||
if (props.min !== undefined)
|
||||
control.min = String(props.min);
|
||||
if (props.max !== undefined)
|
||||
control.max = String(props.max);
|
||||
if (props.step !== undefined)
|
||||
control.step = String(props.step);
|
||||
}
|
||||
else if (props.rows !== undefined) {
|
||||
control.rows = positiveInt(props.rows, 3);
|
||||
}
|
||||
control.value = String(context.value ?? "");
|
||||
control.disabled = context.disabled;
|
||||
control.name = context.bindingName;
|
||||
control.dataset.bindingName = context.bindingName;
|
||||
control.setAttribute("aria-label", label || context.bindingName || node.id || "Input");
|
||||
if (props.placeholder !== undefined)
|
||||
control.setAttribute("placeholder", String(props.placeholder));
|
||||
if (boolProp(props.required))
|
||||
control.required = true;
|
||||
control.addEventListener("input", () => {
|
||||
const value = control instanceof HTMLInputElement && control.type === "number"
|
||||
? (control.value === "" ? "" : Number(control.value))
|
||||
: control.value;
|
||||
updateDraft(context, value, options);
|
||||
});
|
||||
control.addEventListener("change", () => {
|
||||
const value = control instanceof HTMLInputElement && control.type === "number"
|
||||
? (control.value === "" ? "" : Number(control.value))
|
||||
: control.value;
|
||||
publishDirectEdit(context, value, node, options);
|
||||
});
|
||||
field.append(control);
|
||||
const validation = firstString(props.validationMessage, props.error, "");
|
||||
if (validation) {
|
||||
const message = el("span", "cultui-field-validation", validation);
|
||||
message.setAttribute("role", "alert");
|
||||
field.append(message);
|
||||
control.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
return field;
|
||||
}
|
||||
function renderChoiceGroup(node, props, children, layout, style, options) {
|
||||
const context = editableBindingContext(node, props, options);
|
||||
const group = el("fieldset", "field cultui-field cultui-choice-group");
|
||||
assignId(group, node);
|
||||
applyGeneratedLayout(group, layout, style);
|
||||
const label = firstString(props.label, node.text, context.bindingName);
|
||||
if (label)
|
||||
group.append(el("legend", "field-label", label));
|
||||
for (const child of children) {
|
||||
const optionProps = objectProps(child.props);
|
||||
const value = firstString(optionProps.value, child.id);
|
||||
const option = el("label", "cultui-choice");
|
||||
const input = el("input");
|
||||
input.type = boolProp(props.multiple) ? "checkbox" : "radio";
|
||||
input.name = context.bindingName;
|
||||
input.value = value;
|
||||
input.disabled = context.disabled || boolProp(optionProps.disabled);
|
||||
input.checked = input.type === "checkbox"
|
||||
? stringArray(context.value).includes(value)
|
||||
: String(context.value) === value;
|
||||
input.addEventListener("change", () => {
|
||||
const next = input.type === "checkbox"
|
||||
? [...group.querySelectorAll("input:checked")].map(candidate => candidate.value)
|
||||
: value;
|
||||
updateDraft(context, next, options);
|
||||
publishDirectEdit(context, next, node, options);
|
||||
});
|
||||
option.append(input, document.createTextNode(firstString(optionProps.label, child.text, value)));
|
||||
group.append(option);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
function renderTabs(node, props, children, layout, style, options) {
|
||||
const context = editableBindingContext(node, { ...props, bind: props.bind || `${node.id || "tabs"}.selected` }, options);
|
||||
const root = el("section", "cultui-tabs");
|
||||
assignId(root, node);
|
||||
applyGeneratedLayout(root, layout, style);
|
||||
const tabList = el("div", "cultui-tab-list");
|
||||
tabList.setAttribute("role", "tablist");
|
||||
const panels = el("div", "cultui-tab-panels");
|
||||
const selected = firstString(context.value, objectProps(children[0]?.props).value, children[0]?.id);
|
||||
const activate = (value) => {
|
||||
updateDraft(context, value, options);
|
||||
for (const button of tabList.querySelectorAll("[role=tab]")) {
|
||||
const active = button.dataset.value === value;
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
button.tabIndex = active ? 0 : -1;
|
||||
}
|
||||
for (const panel of panels.querySelectorAll("[role=tabpanel]"))
|
||||
panel.hidden = panel.dataset.value !== value;
|
||||
};
|
||||
for (const [index, child] of children.entries()) {
|
||||
const childProps = objectProps(child.props);
|
||||
const value = firstString(childProps.value, child.id, String(index));
|
||||
const button = el("button", "cultui-tab", firstString(childProps.label, child.text, value));
|
||||
button.type = "button";
|
||||
button.dataset.value = value;
|
||||
button.setAttribute("role", "tab");
|
||||
button.addEventListener("click", () => activate(value));
|
||||
tabList.append(button);
|
||||
const panel = el("section", "cultui-tab-panel");
|
||||
panel.dataset.value = value;
|
||||
panel.setAttribute("role", "tabpanel");
|
||||
for (const content of child.children || [])
|
||||
panel.append(renderEveComponent(content, options));
|
||||
panels.append(panel);
|
||||
}
|
||||
root.append(tabList, panels);
|
||||
activate(selected);
|
||||
return root;
|
||||
}
|
||||
function renderSelect(node, props, children, layout, style, options) {
|
||||
const context = editableBindingContext(node, props, options);
|
||||
const field = el("label", "field cultui-field cultui-select");
|
||||
assignId(field, node);
|
||||
applyBoxProps(field, props);
|
||||
|
|
@ -1664,12 +1959,15 @@ function renderSelect(node, props, children, layout, style, options) {
|
|||
(optionProps.enabled !== undefined && !boolProp(optionProps.enabled));
|
||||
select.append(option);
|
||||
}
|
||||
select.value = stringProp(props.value, "");
|
||||
select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled));
|
||||
select.value = String(context.value ?? "");
|
||||
select.name = context.bindingName;
|
||||
select.dataset.bindingName = context.bindingName;
|
||||
select.disabled = context.disabled || (props.enabled !== undefined && !boolProp(props.enabled));
|
||||
const commandId = resolveComponentCommandId(props, node);
|
||||
if (commandId) {
|
||||
select.dataset.commandId = commandId;
|
||||
select.addEventListener("change", () => {
|
||||
updateDraft(context, select.value, options);
|
||||
const intent = createEveCommandIntent(commandId, {
|
||||
...props,
|
||||
action: {
|
||||
|
|
@ -1680,6 +1978,12 @@ function renderSelect(node, props, children, layout, style, options) {
|
|||
void options.commandSink?.(intent, node);
|
||||
});
|
||||
}
|
||||
else {
|
||||
select.addEventListener("change", () => {
|
||||
updateDraft(context, select.value, options);
|
||||
publishDirectEdit(context, select.value, node, options);
|
||||
});
|
||||
}
|
||||
field.append(select);
|
||||
return field;
|
||||
}
|
||||
|
|
@ -1917,6 +2221,13 @@ export function projectSemanticListItems(value) {
|
|||
function stringProp(value, fallback) {
|
||||
return value === null || value === undefined ? fallback : String(value);
|
||||
}
|
||||
function stringArray(value) {
|
||||
if (Array.isArray(value))
|
||||
return value.map(item => String(item).trim()).filter(Boolean);
|
||||
if (typeof value === "string")
|
||||
return value.split(",").map(item => item.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
function firstString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim())
|
||||
|
|
|
|||
21
packages/eve-browser-lowering/package-lock.json
generated
21
packages/eve-browser-lowering/package-lock.json
generated
|
|
@ -7,12 +7,29 @@
|
|||
"": {
|
||||
"name": "@gamecult/eve-browser-lowering",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@gamecult/eve-contracts": "file:../eve-contracts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"../eve-contracts": {
|
||||
"name": "@gamecult/eve-contracts",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@msgpack/msgpack": "^3.1.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
|
||||
|
|
@ -217,6 +234,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@gamecult/eve-contracts": {
|
||||
"resolved": "../eve-contracts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/jsdom": {
|
||||
"version": "30.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
"dist",
|
||||
"styles.css"
|
||||
],
|
||||
"dependencies": {
|
||||
"@gamecult/eve-contracts": "file:../eve-contracts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"test": "node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test test/*.test.mjs"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export interface EveBrowserPluginAdapter {
|
|||
componentKinds: readonly string[];
|
||||
schemas: readonly string[];
|
||||
normalizeDocument(schemaId: string | undefined, value: unknown): unknown;
|
||||
consumeCommandResult?: (
|
||||
payload: { pluginId: string; schemaId: string; payload: Record<string, unknown> },
|
||||
result: unknown,
|
||||
context: { provider: unknown; surface: unknown },
|
||||
) => void | Promise<void>;
|
||||
renderComponent?: (
|
||||
component: FieldsComponent,
|
||||
props: Record<string, unknown>,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { fieldsBrowserAdapter, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
|
||||
import {
|
||||
parseEveCommandResult,
|
||||
type EveCommandInvocation,
|
||||
type EveCommandResult,
|
||||
} from "@gamecult/eve-contracts";
|
||||
|
||||
export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
|
||||
|
||||
|
|
@ -22,6 +27,13 @@ export interface EveStateBindingDescriptor {
|
|||
schemaId: string;
|
||||
routeKind: string;
|
||||
routeDescription?: string;
|
||||
bindingName?: string;
|
||||
documentId?: string;
|
||||
fieldPath?: string;
|
||||
valueKind?: "string" | "number" | "boolean" | "choice" | "string-list";
|
||||
accessMode?: "read" | "write" | "read-write" | "local-draft";
|
||||
authority?: string;
|
||||
writeCommand?: string;
|
||||
}
|
||||
|
||||
export interface EveStateBindingHandle {
|
||||
|
|
@ -45,6 +57,12 @@ export interface EveSurfaceDocument {
|
|||
styles?: EveSurfaceStyles;
|
||||
};
|
||||
mesh?: unknown;
|
||||
commands?: Array<{
|
||||
command: string;
|
||||
payloadSchema?: string;
|
||||
captureBindings?: string[];
|
||||
transport?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type EveSurfaceStyles =
|
||||
|
|
@ -55,18 +73,8 @@ export type EveSurfaceStyles =
|
|||
}
|
||||
| Array<{ name?: string; value?: unknown }>;
|
||||
|
||||
export interface EveCommandIntent {
|
||||
type: "surface-command";
|
||||
schema: "gamecult.eve.command_invocation.v1";
|
||||
providerId: string;
|
||||
surfaceId: string;
|
||||
command: string;
|
||||
commandBoundary?: string;
|
||||
receiptSchema?: string;
|
||||
payload: Record<string, unknown>;
|
||||
issuedAt: string;
|
||||
clientId: string;
|
||||
}
|
||||
export type EveCommandIntent = EveCommandInvocation;
|
||||
export type EveBrowserCommandResult = EveCommandResult;
|
||||
|
||||
export interface EveSemanticListItem {
|
||||
label: string;
|
||||
|
|
@ -111,13 +119,14 @@ export interface EveBrowserLoweringOptions {
|
|||
assetUrlResolver?: (uri: string, surface: EveSurfaceDocument | undefined) => string;
|
||||
body?: HTMLElement;
|
||||
clientId?: string;
|
||||
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => void | Promise<void>;
|
||||
commandSink?: (intent: EveCommandIntent, component: EveSurfaceComponent) => EveBrowserCommandResult | void | Promise<EveBrowserCommandResult | void>;
|
||||
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
|
||||
provider?: EveProviderAdvertisement;
|
||||
pluginAdapters?: readonly EveBrowserPluginAdapter[];
|
||||
stateBindingResolver?: EveStateBindingResolver;
|
||||
source?: string;
|
||||
statusElement?: HTMLElement;
|
||||
draftStore?: EveBrowserDraftStore;
|
||||
}
|
||||
|
||||
export interface EveBrowserProviderTransport {
|
||||
|
|
@ -138,6 +147,39 @@ export interface EveBrowserProviderHostOptions {
|
|||
pluginAdapters?: readonly EveBrowserPluginAdapter[];
|
||||
}
|
||||
|
||||
export class EveBrowserDraftStore {
|
||||
private readonly values = new Map<string, unknown>();
|
||||
|
||||
private key(providerId: string, surfaceId: string, bindingName: string): string {
|
||||
return `${providerId}\u001f${surfaceId}\u001f${bindingName}`;
|
||||
}
|
||||
|
||||
has(providerId: string, surfaceId: string, bindingName: string): boolean {
|
||||
return this.values.has(this.key(providerId, surfaceId, bindingName));
|
||||
}
|
||||
|
||||
get(providerId: string, surfaceId: string, bindingName: string): unknown {
|
||||
return this.values.get(this.key(providerId, surfaceId, bindingName));
|
||||
}
|
||||
|
||||
set(providerId: string, surfaceId: string, bindingName: string, value: unknown): void {
|
||||
this.values.set(this.key(providerId, surfaceId, bindingName), value);
|
||||
}
|
||||
|
||||
clear(providerId: string, surfaceId: string, bindingNames?: readonly string[]): void {
|
||||
if (bindingNames?.length) {
|
||||
for (const name of bindingNames) this.values.delete(this.key(providerId, surfaceId, name));
|
||||
return;
|
||||
}
|
||||
const prefix = `${providerId}\u001f${surfaceId}\u001f`;
|
||||
for (const key of this.values.keys()) if (key.startsWith(prefix)) this.values.delete(key);
|
||||
}
|
||||
|
||||
capture(providerId: string, surfaceId: string, bindingNames: readonly string[]): Record<string, unknown> {
|
||||
return Object.fromEntries(bindingNames.map(name => [name, this.get(providerId, surfaceId, name)]));
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultBrowserPluginAdapters: readonly EveBrowserPluginAdapter[] = [fieldsBrowserAdapter];
|
||||
|
||||
export function resolveRequiredPluginAdapters(
|
||||
|
|
@ -183,6 +225,7 @@ export class EveBrowserProviderHost {
|
|||
private provider: EveProviderAdvertisement | undefined;
|
||||
private selected: EveProviderSurfaceAdvertisement | undefined;
|
||||
private pluginAdapters: readonly EveBrowserPluginAdapter[] = [];
|
||||
private readonly draftStore = new EveBrowserDraftStore();
|
||||
|
||||
constructor(
|
||||
private readonly host: HTMLElement,
|
||||
|
|
@ -191,12 +234,20 @@ 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,
|
||||
);
|
||||
try {
|
||||
this.provider = await this.transport.providerAdvertisement();
|
||||
this.selected = selectAdvertisedSurface(this.provider, this.options.requestedSurfaceId);
|
||||
requireCommandCapability(this.selected);
|
||||
this.pluginAdapters = resolveRequiredPluginAdapters(
|
||||
this.selected,
|
||||
this.options.pluginAdapters || defaultBrowserPluginAdapters,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eve provider capabilities are unavailable.";
|
||||
this.host.replaceChildren(emptyState(message));
|
||||
if (this.options.statusElement) this.options.statusElement.textContent = message;
|
||||
throw error;
|
||||
}
|
||||
this.active = true;
|
||||
await this.refresh();
|
||||
const pollMs = Math.max(0, this.options.pollMs ?? 250);
|
||||
|
|
@ -229,6 +280,7 @@ export class EveBrowserProviderHost {
|
|||
pluginAdapters: this.pluginAdapters,
|
||||
source: this.options.source,
|
||||
statusElement: this.options.statusElement,
|
||||
draftStore: this.draftStore,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.options.statusElement) {
|
||||
|
|
@ -238,10 +290,65 @@ export class EveBrowserProviderHost {
|
|||
}
|
||||
|
||||
private async submit(intent: EveCommandIntent): Promise<void> {
|
||||
await this.transport.submitCommand(intent);
|
||||
try {
|
||||
const result = parseEveCommandResult(await this.transport.submitCommand(intent));
|
||||
await this.consumeCommandResult(intent, result);
|
||||
} catch (error) {
|
||||
this.presentCommandStatus(error instanceof Error ? error.message : "Command failed.", "error");
|
||||
return;
|
||||
}
|
||||
this.lastSurfaceVersion = "";
|
||||
window.setTimeout(() => void this.refresh(), 100);
|
||||
}
|
||||
|
||||
private async consumeCommandResult(intent: EveCommandIntent, result: EveBrowserCommandResult): Promise<void> {
|
||||
const accepted = result.receipt.state === "accepted" || result.receipt.state === "reconciled";
|
||||
if (accepted && result.draftDirective?.clear) {
|
||||
this.draftStore.clear(intent.providerId, intent.surfaceId, result.draftDirective.bindingNames);
|
||||
}
|
||||
if (result.pluginPayload) {
|
||||
const adapter = this.pluginAdapters.find(candidate => candidate.pluginId === result.pluginPayload?.pluginId);
|
||||
if (!adapter?.consumeCommandResult) {
|
||||
throw new Error(`No Eve plugin adapter can consume ${result.pluginPayload.pluginId}.`);
|
||||
}
|
||||
await adapter.consumeCommandResult(result.pluginPayload, result, {
|
||||
provider: this.provider,
|
||||
surface: this.selected,
|
||||
});
|
||||
}
|
||||
this.presentCommandStatus(
|
||||
result.receipt.message || result.receipt.state,
|
||||
accepted ? "status" : "error",
|
||||
result.transientProjection,
|
||||
);
|
||||
}
|
||||
|
||||
private presentCommandStatus(message: string, role: "status" | "error", transient?: unknown): void {
|
||||
let region = this.host.querySelector<HTMLElement>(":scope > .eve-command-result-region");
|
||||
if (!region) {
|
||||
region = el("section", "eve-command-result-region");
|
||||
region.setAttribute("aria-live", "polite");
|
||||
this.host.append(region);
|
||||
}
|
||||
region.setAttribute("role", role);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireCommandCapability(surface: EveProviderSurfaceAdvertisement): void {
|
||||
if (!surface.worldInteraction?.commandBoundary || !surface.worldInteraction.receiptSchema) {
|
||||
throw new Error(`Eve surface ${surface.surfaceId || "(unknown)"} does not advertise a command boundary and receipt schema.`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface EveEmbeddedDocumentRequest {
|
||||
|
|
@ -299,6 +406,7 @@ const defaultStyleTokens: Record<string, string> = {
|
|||
|
||||
const loadedFontStylesheets = new Map<string, HTMLLinkElement>();
|
||||
const activeSurfaceBindings = new WeakMap<HTMLElement, EveSurfaceBindingController>();
|
||||
const surfaceDraftStores = new WeakMap<HTMLElement, EveBrowserDraftStore>();
|
||||
|
||||
interface NormalizedStyles {
|
||||
tokens: Record<string, unknown>;
|
||||
|
|
@ -341,7 +449,12 @@ export function renderEveSurface(
|
|||
options: EveBrowserLoweringOptions = {},
|
||||
): HTMLElement {
|
||||
activeSurfaceBindings.get(host)?.dispose();
|
||||
const scopedOptions = scopeEveBrowserOptions(surface, options);
|
||||
let draftStore = options.draftStore || surfaceDraftStores.get(host);
|
||||
if (!draftStore) {
|
||||
draftStore = new EveBrowserDraftStore();
|
||||
surfaceDraftStores.set(host, draftStore);
|
||||
}
|
||||
const scopedOptions = scopeEveBrowserOptions(surface, { ...options, draftStore });
|
||||
applyEveSurfaceStyles(surface.surface?.styles, options.body ?? host);
|
||||
host.dataset.provider = surface.providerId || options.provider?.providerId || "";
|
||||
if (options.body) {
|
||||
|
|
@ -889,14 +1002,19 @@ function renderEveComponentProjection(
|
|||
return renderSelect(node, props, children, layout, style, options);
|
||||
}
|
||||
|
||||
if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") {
|
||||
const field = el("label", "field cultui-field");
|
||||
assignId(field, node);
|
||||
applyBoxProps(field, props);
|
||||
applyGeneratedLayout(field, layout, style);
|
||||
field.append(el("span", "field-label", stringProp(props.label, "")));
|
||||
field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value)));
|
||||
return field;
|
||||
if (kind === "control.choice-group") {
|
||||
return renderChoiceGroup(node, props, children, layout, style, options);
|
||||
}
|
||||
|
||||
if (
|
||||
kind === "input.number" || kind === "control.range" || kind === "control.input.number" ||
|
||||
kind === "control.input.text" || kind === "control.input.textarea"
|
||||
) {
|
||||
return renderEditableControl(node, kind, props, layout, style, options);
|
||||
}
|
||||
|
||||
if (kind === "control.tabs") {
|
||||
return renderTabs(node, props, children, layout, style, options);
|
||||
}
|
||||
|
||||
if (kind === "color.swatch") {
|
||||
|
|
@ -1870,24 +1988,59 @@ export function createEveCommandIntent(
|
|||
const worldInteraction = resolveAdvertisedWorldInteraction(options, surfaceId);
|
||||
const commandBoundary = firstString(worldInteraction.commandBoundary, props.commandBoundary, action.commandBoundary, action.target);
|
||||
const receiptSchema = firstString(worldInteraction.receiptSchema, props.receiptSchema, action.receiptSchema);
|
||||
if (!commandBoundary || !receiptSchema) {
|
||||
throw new Error(`Eve surface ${surfaceId} cannot invoke commands without an advertised command boundary and receipt schema.`);
|
||||
}
|
||||
const operationId = commandId || stringProp(action.type, "invoke");
|
||||
const descriptor = surface?.commands?.find(command => command.command === operationId);
|
||||
const captureBindings = stringArray(
|
||||
descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings,
|
||||
);
|
||||
const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {};
|
||||
const payload = commandPayload(action);
|
||||
if (captureBindings.length) payload.bindings = bindings;
|
||||
const intent: EveCommandIntent = {
|
||||
type: "surface-command",
|
||||
schema: "gamecult.eve.command_invocation.v1",
|
||||
providerId,
|
||||
surfaceId,
|
||||
command: commandId || stringProp(action.type, "invoke"),
|
||||
payload: {
|
||||
...action,
|
||||
transport: props.transport ?? null,
|
||||
operation: {
|
||||
operationId,
|
||||
schemaId: firstString(descriptor?.payloadSchema, action.schemaId, props.payloadSchema, "gamecult.eve.operation_payload.v1"),
|
||||
idempotencyKey: randomIdempotencyKey(),
|
||||
routeHint: {
|
||||
sourceVersion: Math.max(0, Math.trunc(surface?.version ?? 0)),
|
||||
...(firstString(descriptor?.transport, props.transport, action.transport)
|
||||
? { transport: firstString(descriptor?.transport, props.transport, action.transport) }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
payload,
|
||||
issuedAt: new Date().toISOString(),
|
||||
clientId: options.clientId || "eve.browser",
|
||||
commandBoundary,
|
||||
receiptSchema,
|
||||
};
|
||||
if (commandBoundary) intent.commandBoundary = commandBoundary;
|
||||
if (receiptSchema) intent.receiptSchema = receiptSchema;
|
||||
return intent;
|
||||
}
|
||||
|
||||
function commandPayload(action: Record<string, unknown>): Record<string, unknown> {
|
||||
const payload = { ...action };
|
||||
for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) {
|
||||
delete payload[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function randomIdempotencyKey(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto?.getRandomValues?.(bytes);
|
||||
if (!bytes.some(Boolean)) {
|
||||
for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return [...bytes].map(value => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function resolveAdvertisedWorldInteraction(
|
||||
options: EveBrowserLoweringOptions,
|
||||
surfaceId: string,
|
||||
|
|
@ -1944,6 +2097,207 @@ function wireCommand(
|
|||
});
|
||||
}
|
||||
|
||||
interface EditableBindingContext {
|
||||
bindingName: string;
|
||||
providerId: string;
|
||||
surfaceId: string;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
binding?: EveStateBindingDescriptor;
|
||||
}
|
||||
|
||||
function editableBindingContext(
|
||||
node: EveSurfaceComponent,
|
||||
props: Record<string, unknown>,
|
||||
options: EveBrowserLoweringOptions,
|
||||
): EditableBindingContext {
|
||||
const binding = (node.stateBindings || []).find(candidate => candidate.targetProp === "value")
|
||||
?? node.stateBindings?.[0];
|
||||
const surface = renderContext(options)?.surface;
|
||||
const providerId = options.provider?.providerId || surface?.providerId || "surface unknown";
|
||||
const surfaceId = options.activeSurfaceId || surface?.surface?.id || providerId;
|
||||
const bindingName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id);
|
||||
const draft = bindingName && options.draftStore?.has(providerId, surfaceId, bindingName)
|
||||
? options.draftStore.get(providerId, surfaceId, bindingName)
|
||||
: undefined;
|
||||
const accessMode = binding?.accessMode || (binding ? "read" : "local-draft");
|
||||
return {
|
||||
binding,
|
||||
bindingName,
|
||||
providerId,
|
||||
surfaceId,
|
||||
value: draft ?? props.value ?? "",
|
||||
disabled: boolProp(props.disabled) || accessMode === "read",
|
||||
};
|
||||
}
|
||||
|
||||
function updateDraft(
|
||||
context: EditableBindingContext,
|
||||
value: unknown,
|
||||
options: EveBrowserLoweringOptions,
|
||||
): void {
|
||||
if (context.bindingName) options.draftStore?.set(context.providerId, context.surfaceId, context.bindingName, value);
|
||||
}
|
||||
|
||||
function publishDirectEdit(
|
||||
context: EditableBindingContext,
|
||||
value: unknown,
|
||||
node: EveSurfaceComponent,
|
||||
options: EveBrowserLoweringOptions,
|
||||
): void {
|
||||
const binding = context.binding;
|
||||
const writeCommand = binding?.writeCommand;
|
||||
if (!binding || !writeCommand || !context.bindingName) return;
|
||||
void options.commandSink?.(createEveCommandIntent(writeCommand, {
|
||||
action: {
|
||||
bindingName: context.bindingName,
|
||||
documentId: binding.documentId,
|
||||
fieldPath: binding.fieldPath,
|
||||
value,
|
||||
},
|
||||
}, options), node);
|
||||
}
|
||||
|
||||
function renderEditableControl(
|
||||
node: EveSurfaceComponent,
|
||||
kind: string,
|
||||
props: Record<string, unknown>,
|
||||
layout: Record<string, unknown>,
|
||||
style: Record<string, unknown>,
|
||||
options: EveBrowserLoweringOptions,
|
||||
): HTMLElement {
|
||||
const context = editableBindingContext(node, props, options);
|
||||
const field = el("label", "field cultui-field cultui-editable");
|
||||
assignId(field, node);
|
||||
applyBoxProps(field, props);
|
||||
applyGeneratedLayout(field, layout, style);
|
||||
const label = stringProp(props.label, "");
|
||||
if (label) field.append(el("span", "field-label", label));
|
||||
const control = kind === "control.input.textarea"
|
||||
? el("textarea", "field-control cultui-field-input") as HTMLTextAreaElement
|
||||
: el("input", "field-control cultui-field-input") as HTMLInputElement;
|
||||
if (control instanceof HTMLInputElement) {
|
||||
control.type = kind.includes("number") || kind === "control.range" ? "number" : "text";
|
||||
if (props.min !== undefined) control.min = String(props.min);
|
||||
if (props.max !== undefined) control.max = String(props.max);
|
||||
if (props.step !== undefined) control.step = String(props.step);
|
||||
} else if (props.rows !== undefined) {
|
||||
control.rows = positiveInt(props.rows, 3);
|
||||
}
|
||||
control.value = String(context.value ?? "");
|
||||
control.disabled = context.disabled;
|
||||
control.name = context.bindingName;
|
||||
control.dataset.bindingName = context.bindingName;
|
||||
control.setAttribute("aria-label", label || context.bindingName || node.id || "Input");
|
||||
if (props.placeholder !== undefined) control.setAttribute("placeholder", String(props.placeholder));
|
||||
if (boolProp(props.required)) control.required = true;
|
||||
control.addEventListener("input", () => {
|
||||
const value = control instanceof HTMLInputElement && control.type === "number"
|
||||
? (control.value === "" ? "" : Number(control.value))
|
||||
: control.value;
|
||||
updateDraft(context, value, options);
|
||||
});
|
||||
control.addEventListener("change", () => {
|
||||
const value = control instanceof HTMLInputElement && control.type === "number"
|
||||
? (control.value === "" ? "" : Number(control.value))
|
||||
: control.value;
|
||||
publishDirectEdit(context, value, node, options);
|
||||
});
|
||||
field.append(control);
|
||||
const validation = firstString(props.validationMessage, props.error, "");
|
||||
if (validation) {
|
||||
const message = el("span", "cultui-field-validation", validation);
|
||||
message.setAttribute("role", "alert");
|
||||
field.append(message);
|
||||
control.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
function renderChoiceGroup(
|
||||
node: EveSurfaceComponent,
|
||||
props: Record<string, unknown>,
|
||||
children: EveSurfaceComponent[],
|
||||
layout: Record<string, unknown>,
|
||||
style: Record<string, unknown>,
|
||||
options: EveBrowserLoweringOptions,
|
||||
): HTMLElement {
|
||||
const context = editableBindingContext(node, props, options);
|
||||
const group = el("fieldset", "field cultui-field cultui-choice-group") as HTMLFieldSetElement;
|
||||
assignId(group, node);
|
||||
applyGeneratedLayout(group, layout, style);
|
||||
const label = firstString(props.label, node.text, context.bindingName);
|
||||
if (label) group.append(el("legend", "field-label", label));
|
||||
for (const child of children) {
|
||||
const optionProps = objectProps(child.props);
|
||||
const value = firstString(optionProps.value, child.id);
|
||||
const option = el("label", "cultui-choice");
|
||||
const input = el("input") as HTMLInputElement;
|
||||
input.type = boolProp(props.multiple) ? "checkbox" : "radio";
|
||||
input.name = context.bindingName;
|
||||
input.value = value;
|
||||
input.disabled = context.disabled || boolProp(optionProps.disabled);
|
||||
input.checked = input.type === "checkbox"
|
||||
? stringArray(context.value).includes(value)
|
||||
: String(context.value) === value;
|
||||
input.addEventListener("change", () => {
|
||||
const next = input.type === "checkbox"
|
||||
? [...group.querySelectorAll<HTMLInputElement>("input:checked")].map(candidate => candidate.value)
|
||||
: value;
|
||||
updateDraft(context, next, options);
|
||||
publishDirectEdit(context, next, node, options);
|
||||
});
|
||||
option.append(input, document.createTextNode(firstString(optionProps.label, child.text, value)));
|
||||
group.append(option);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderTabs(
|
||||
node: EveSurfaceComponent,
|
||||
props: Record<string, unknown>,
|
||||
children: EveSurfaceComponent[],
|
||||
layout: Record<string, unknown>,
|
||||
style: Record<string, unknown>,
|
||||
options: EveBrowserLoweringOptions,
|
||||
): HTMLElement {
|
||||
const context = editableBindingContext(node, { ...props, bind: props.bind || `${node.id || "tabs"}.selected` }, options);
|
||||
const root = el("section", "cultui-tabs");
|
||||
assignId(root, node);
|
||||
applyGeneratedLayout(root, layout, style);
|
||||
const tabList = el("div", "cultui-tab-list");
|
||||
tabList.setAttribute("role", "tablist");
|
||||
const panels = el("div", "cultui-tab-panels");
|
||||
const selected = firstString(context.value, objectProps(children[0]?.props).value, children[0]?.id);
|
||||
const activate = (value: string) => {
|
||||
updateDraft(context, value, options);
|
||||
for (const button of tabList.querySelectorAll<HTMLButtonElement>("[role=tab]")) {
|
||||
const active = button.dataset.value === value;
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
button.tabIndex = active ? 0 : -1;
|
||||
}
|
||||
for (const panel of panels.querySelectorAll<HTMLElement>("[role=tabpanel]")) panel.hidden = panel.dataset.value !== value;
|
||||
};
|
||||
for (const [index, child] of children.entries()) {
|
||||
const childProps = objectProps(child.props);
|
||||
const value = firstString(childProps.value, child.id, String(index));
|
||||
const button = el("button", "cultui-tab", firstString(childProps.label, child.text, value)) as HTMLButtonElement;
|
||||
button.type = "button";
|
||||
button.dataset.value = value;
|
||||
button.setAttribute("role", "tab");
|
||||
button.addEventListener("click", () => activate(value));
|
||||
tabList.append(button);
|
||||
const panel = el("section", "cultui-tab-panel");
|
||||
panel.dataset.value = value;
|
||||
panel.setAttribute("role", "tabpanel");
|
||||
for (const content of child.children || []) panel.append(renderEveComponent(content, options));
|
||||
panels.append(panel);
|
||||
}
|
||||
root.append(tabList, panels);
|
||||
activate(selected);
|
||||
return root;
|
||||
}
|
||||
|
||||
function renderSelect(
|
||||
node: EveSurfaceComponent,
|
||||
props: Record<string, unknown>,
|
||||
|
|
@ -1952,6 +2306,7 @@ function renderSelect(
|
|||
style: Record<string, unknown>,
|
||||
options: EveBrowserLoweringOptions,
|
||||
): HTMLElement {
|
||||
const context = editableBindingContext(node, props, options);
|
||||
const field = el("label", "field cultui-field cultui-select");
|
||||
assignId(field, node);
|
||||
applyBoxProps(field, props);
|
||||
|
|
@ -1971,13 +2326,16 @@ function renderSelect(
|
|||
(optionProps.enabled !== undefined && !boolProp(optionProps.enabled));
|
||||
select.append(option);
|
||||
}
|
||||
select.value = stringProp(props.value, "");
|
||||
select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled));
|
||||
select.value = String(context.value ?? "");
|
||||
select.name = context.bindingName;
|
||||
select.dataset.bindingName = context.bindingName;
|
||||
select.disabled = context.disabled || (props.enabled !== undefined && !boolProp(props.enabled));
|
||||
|
||||
const commandId = resolveComponentCommandId(props, node);
|
||||
if (commandId) {
|
||||
select.dataset.commandId = commandId;
|
||||
select.addEventListener("change", () => {
|
||||
updateDraft(context, select.value, options);
|
||||
const intent = createEveCommandIntent(commandId, {
|
||||
...props,
|
||||
action: {
|
||||
|
|
@ -1987,6 +2345,11 @@ function renderSelect(
|
|||
}, options);
|
||||
void options.commandSink?.(intent, node);
|
||||
});
|
||||
} else {
|
||||
select.addEventListener("change", () => {
|
||||
updateDraft(context, select.value, options);
|
||||
publishDirectEdit(context, select.value, node, options);
|
||||
});
|
||||
}
|
||||
field.append(select);
|
||||
return field;
|
||||
|
|
@ -2206,6 +2569,12 @@ function stringProp(value: unknown, fallback: string): string {
|
|||
return value === null || value === undefined ? fallback : String(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(item => String(item).trim()).filter(Boolean);
|
||||
if (typeof value === "string") return value.split(",").map(item => item.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
|
|
|
|||
|
|
@ -1121,3 +1121,50 @@ body[data-pixel-art="true"] .cultui-background {
|
|||
width: auto;
|
||||
}
|
||||
}
|
||||
.cultui-field-input,
|
||||
.cultui-field-select {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--text, inherit);
|
||||
background: var(--panel-inset, rgba(0, 0, 0, 0.25));
|
||||
border: 1px solid var(--border, currentColor);
|
||||
border-radius: 0.25rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea.cultui-field-input {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cultui-field-validation {
|
||||
display: block;
|
||||
color: var(--danger, #ff8e8e);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cultui-choice-group {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cultui-choice {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.cultui-tab-list {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cultui-tab[aria-selected="true"] {
|
||||
border-bottom-color: var(--accent, currentColor);
|
||||
}
|
||||
|
||||
.eve-command-result-region {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ test("world interactions emit provider-routed command intents without local auth
|
|||
},
|
||||
});
|
||||
|
||||
assert.equal(intent.command, "world.move");
|
||||
assert.equal(intent.operation.operationId, "world.move");
|
||||
assert.equal(intent.operation.schemaId, "gamecult.eve.operation_payload.v1");
|
||||
assert.ok(intent.operation.idempotencyKey);
|
||||
assert.equal(intent.operation.routeHint.sourceVersion, 0);
|
||||
assert.equal(intent.commandBoundary, "example.commands");
|
||||
assert.equal(intent.receiptSchema, "example.receipt.v1");
|
||||
assert.equal(intent.payload.actorEntityId, "entity.player");
|
||||
|
|
@ -75,10 +78,16 @@ test("inventory drops select the provider-advertised operation and preserve spat
|
|||
}, 7, 9, {
|
||||
activeSurfaceId: "aetheria.refit",
|
||||
clientId: "browser.inventory-test",
|
||||
provider: { providerId: "aetheria", surfaces: [{ surfaceId: "aetheria.refit" }] },
|
||||
provider: { providerId: "aetheria", surfaces: [{
|
||||
surfaceId: "aetheria.refit",
|
||||
worldInteraction: {
|
||||
commandBoundary: "aetheria.refit.commands",
|
||||
receiptSchema: "gamecult.eve.command_receipt.v1",
|
||||
},
|
||||
}] },
|
||||
});
|
||||
|
||||
assert.equal(intent.command, "aetheria.daemon.commands.EquipItem");
|
||||
assert.equal(intent.operation.operationId, "aetheria.daemon.commands.EquipItem");
|
||||
assert.equal(intent.payload.originEntityKey, "zone.0.entity.1");
|
||||
assert.equal(intent.payload.originCargoIndex, 2);
|
||||
assert.equal(intent.payload.destinationEntityKey, "zone.0.entity.1");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { renderEveSurface } from "../dist/index.js";
|
||||
import { EveBrowserDraftStore, 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>", {
|
||||
|
|
@ -104,6 +104,7 @@ test("browser lowering isolates two hosts and patches only the bound component",
|
|||
assetUrlResolver: uri => `https://alpha.assets${uri}`,
|
||||
commandSink: intent => alphaCommands.push(intent),
|
||||
pluginAdapters: [testInputAdapter(() => { alphaInputRenders++; })],
|
||||
provider: { providerId: "alpha", surfaces: [{ surfaceId: "alpha.hangar", worldInteraction: { commandBoundary: "alpha.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] },
|
||||
stateBindingResolver: async () => ({
|
||||
latest: async () => "hydrated",
|
||||
watch: callback => {
|
||||
|
|
@ -116,6 +117,7 @@ test("browser lowering isolates two hosts and patches only the bound component",
|
|||
assetUrlResolver: uri => `https://beta.assets${uri}`,
|
||||
commandSink: intent => betaCommands.push(intent),
|
||||
pluginAdapters: [testInputAdapter()],
|
||||
provider: { providerId: "beta", surfaces: [{ surfaceId: "beta.hangar", worldInteraction: { commandBoundary: "beta.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] },
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
|
@ -177,6 +179,7 @@ test("browser lowering renders command-backed select options", () => {
|
|||
}, host, {
|
||||
clientId: "browser-test",
|
||||
commandSink: intent => commands.push(intent),
|
||||
provider: { providerId: "hangar-provider", surfaces: [{ surfaceId: "hangar", worldInteraction: { commandBoundary: "hangar.commands", receiptSchema: "gamecult.eve.command_receipt.v1" } }] },
|
||||
});
|
||||
|
||||
const select = host.querySelector("select");
|
||||
|
|
@ -188,9 +191,76 @@ test("browser lowering renders command-backed select options", () => {
|
|||
select.value = "gamecult";
|
||||
select.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
assert.equal(commands.length, 1);
|
||||
assert.equal(commands[0].command, "hangar.select_verse");
|
||||
assert.equal(commands[0].operation.operationId, "hangar.select_verse");
|
||||
assert.equal(commands[0].payload.value, "gamecult");
|
||||
} finally {
|
||||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
||||
test("editable bindings preserve local drafts across authoritative refresh and operations capture them", () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
const commands = [];
|
||||
const drafts = new EveBrowserDraftStore();
|
||||
const host = document.querySelector("#a");
|
||||
const provider = {
|
||||
providerId: "ghostlight",
|
||||
surfaces: [{ surfaceId: "ghostlight.play", worldInteraction: {
|
||||
commandBoundary: "ghostlight.eve.commands",
|
||||
receiptSchema: "gamecult.eve.command_result.v1",
|
||||
} }],
|
||||
};
|
||||
const surface = value => ({
|
||||
providerId: "ghostlight",
|
||||
version: 7,
|
||||
commands: [{
|
||||
command: "session_zero.message.send",
|
||||
payloadSchema: "session_zero.message.send.v1",
|
||||
captureBindings: ["composer.message"],
|
||||
}],
|
||||
surface: {
|
||||
id: "ghostlight.play",
|
||||
root: { id: "root", kind: "column", children: [
|
||||
{
|
||||
id: "composer",
|
||||
kind: "control.input.textarea",
|
||||
props: { label: "Message", value },
|
||||
stateBindings: [{
|
||||
targetProp: "value",
|
||||
pointerId: "ghostlight.local.composer.message",
|
||||
sourceId: "eve.browser.local",
|
||||
schemaId: "gamecult.eve.local_draft.v1",
|
||||
routeKind: "in-process",
|
||||
bindingName: "composer.message",
|
||||
valueKind: "string",
|
||||
accessMode: "local-draft",
|
||||
authority: "eve.browser",
|
||||
}],
|
||||
},
|
||||
{ id: "send", kind: "control.button", props: { label: "Send", command: "session_zero.message.send" } },
|
||||
] },
|
||||
},
|
||||
});
|
||||
const options = { provider, draftStore: drafts, commandSink: intent => commands.push(intent) };
|
||||
renderEveSurface(surface(""), host, options);
|
||||
const composer = host.querySelector("textarea");
|
||||
composer.value = "The world should remember this.";
|
||||
composer.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
|
||||
renderEveSurface(surface("stale provider value"), host, options);
|
||||
assert.equal(host.querySelector("textarea").value, "The world should remember this.");
|
||||
host.querySelector("button").click();
|
||||
assert.deepEqual(commands[0].payload.bindings, {
|
||||
"composer.message": "The world should remember this.",
|
||||
});
|
||||
assert.equal(commands[0].operation.schemaId, "session_zero.message.send.v1");
|
||||
assert.equal(commands[0].operation.routeHint.sourceVersion, 7);
|
||||
|
||||
drafts.clear("ghostlight", "ghostlight.play", ["composer.message"]);
|
||||
renderEveSurface(surface("accepted provider value"), host, options);
|
||||
assert.equal(host.querySelector("textarea").value, "accepted provider value");
|
||||
} finally {
|
||||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ export interface EveCommandDescriptor {
|
|||
authority?: string;
|
||||
result?: string;
|
||||
payloadSchema?: string;
|
||||
captureBindings?: string[];
|
||||
[k: string]: any;
|
||||
}
|
||||
|
|
|
|||
44
packages/eve-contracts/dist/generated/command-result.d.ts
vendored
Normal file
44
packages/eve-contracts/dist/generated/command-result.d.ts
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export interface EveCommandResult {
|
||||
schema: "gamecult.eve.command_result.v1";
|
||||
receipt: EveCommandReceipt;
|
||||
transientProjection?: {
|
||||
schema: "gamecult.eve.surface.v1";
|
||||
[k: string]: any;
|
||||
};
|
||||
pluginPayload?: {
|
||||
pluginId: string;
|
||||
schemaId: string;
|
||||
payload: {
|
||||
[k: string]: any;
|
||||
};
|
||||
};
|
||||
draftDirective?: {
|
||||
clear: boolean;
|
||||
bindingNames?: string[];
|
||||
};
|
||||
}
|
||||
export interface EveCommandReceipt {
|
||||
schema: "gamecult.eve.command_receipt.v1";
|
||||
receiptId: string;
|
||||
commandId: string;
|
||||
command: string;
|
||||
state: ("accepted" | "denied" | "pending" | "reconciled");
|
||||
ownerRepo: string;
|
||||
authority: string;
|
||||
providerId: string;
|
||||
surfaceId: string;
|
||||
message?: string;
|
||||
diagnostics?: {
|
||||
[k: string]: any;
|
||||
}[];
|
||||
issuedAtUtc?: string;
|
||||
sourceVersion: number;
|
||||
navigation?: {
|
||||
verseId: string;
|
||||
providerId?: string;
|
||||
surfaceId: string;
|
||||
surfaceKind?: string;
|
||||
rendezvousEndpoints?: string[];
|
||||
};
|
||||
[k: string]: any;
|
||||
}
|
||||
1
packages/eve-contracts/dist/generated/command-result.js
vendored
Normal file
1
packages/eve-contracts/dist/generated/command-result.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
|
|
@ -429,6 +429,30 @@ export declare const eveContractSchemas: {
|
|||
readonly routeDescription: {
|
||||
readonly type: "string";
|
||||
};
|
||||
readonly bindingName: {
|
||||
readonly type: "string";
|
||||
readonly minLength: 1;
|
||||
};
|
||||
readonly documentId: {
|
||||
readonly type: "string";
|
||||
};
|
||||
readonly fieldPath: {
|
||||
readonly type: "string";
|
||||
};
|
||||
readonly valueKind: {
|
||||
readonly type: "string";
|
||||
readonly enum: readonly ["string", "number", "boolean", "choice", "string-list"];
|
||||
};
|
||||
readonly accessMode: {
|
||||
readonly type: "string";
|
||||
readonly enum: readonly ["read", "write", "read-write", "local-draft"];
|
||||
};
|
||||
readonly authority: {
|
||||
readonly type: "string";
|
||||
};
|
||||
readonly writeCommand: {
|
||||
readonly type: "string";
|
||||
};
|
||||
};
|
||||
readonly additionalProperties: true;
|
||||
};
|
||||
|
|
@ -505,6 +529,14 @@ export declare const eveContractSchemas: {
|
|||
readonly payloadSchema: {
|
||||
readonly type: "string";
|
||||
};
|
||||
readonly captureBindings: {
|
||||
readonly type: "array";
|
||||
readonly items: {
|
||||
readonly type: "string";
|
||||
readonly minLength: 1;
|
||||
};
|
||||
readonly uniqueItems: true;
|
||||
};
|
||||
};
|
||||
readonly additionalProperties: true;
|
||||
};
|
||||
|
|
@ -654,6 +686,68 @@ export declare const eveContractSchemas: {
|
|||
};
|
||||
readonly additionalProperties: true;
|
||||
};
|
||||
readonly commandResult: {
|
||||
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
||||
readonly $id: "gamecult.eve.command_result.v1";
|
||||
readonly title: "Eve Command Result";
|
||||
readonly type: "object";
|
||||
readonly required: readonly ["schema", "receipt"];
|
||||
readonly properties: {
|
||||
readonly schema: {
|
||||
readonly const: "gamecult.eve.command_result.v1";
|
||||
};
|
||||
readonly receipt: {
|
||||
readonly $ref: "gamecult.eve.command_receipt.v1";
|
||||
};
|
||||
readonly transientProjection: {
|
||||
readonly type: "object";
|
||||
readonly required: readonly ["schema"];
|
||||
readonly properties: {
|
||||
readonly schema: {
|
||||
readonly const: "gamecult.eve.surface.v1";
|
||||
};
|
||||
};
|
||||
readonly additionalProperties: true;
|
||||
};
|
||||
readonly pluginPayload: {
|
||||
readonly type: "object";
|
||||
readonly required: readonly ["pluginId", "schemaId", "payload"];
|
||||
readonly properties: {
|
||||
readonly pluginId: {
|
||||
readonly type: "string";
|
||||
readonly minLength: 1;
|
||||
};
|
||||
readonly schemaId: {
|
||||
readonly type: "string";
|
||||
readonly minLength: 1;
|
||||
};
|
||||
readonly payload: {
|
||||
readonly type: "object";
|
||||
};
|
||||
};
|
||||
readonly additionalProperties: false;
|
||||
};
|
||||
readonly draftDirective: {
|
||||
readonly type: "object";
|
||||
readonly required: readonly ["clear"];
|
||||
readonly properties: {
|
||||
readonly clear: {
|
||||
readonly type: "boolean";
|
||||
};
|
||||
readonly bindingNames: {
|
||||
readonly type: "array";
|
||||
readonly items: {
|
||||
readonly type: "string";
|
||||
readonly minLength: 1;
|
||||
};
|
||||
readonly uniqueItems: true;
|
||||
};
|
||||
};
|
||||
readonly additionalProperties: false;
|
||||
};
|
||||
};
|
||||
readonly additionalProperties: false;
|
||||
};
|
||||
readonly inputCapability: {
|
||||
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
||||
readonly $id: "gamecult.eve.input_capability.v1";
|
||||
|
|
|
|||
116
packages/eve-contracts/dist/generated/schemas.js
vendored
116
packages/eve-contracts/dist/generated/schemas.js
vendored
|
|
@ -506,6 +506,41 @@ export const eveContractSchemas = {
|
|||
},
|
||||
"routeDescription": {
|
||||
"type": "string"
|
||||
},
|
||||
"bindingName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"documentId": {
|
||||
"type": "string"
|
||||
},
|
||||
"fieldPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"choice",
|
||||
"string-list"
|
||||
]
|
||||
},
|
||||
"accessMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"read",
|
||||
"write",
|
||||
"read-write",
|
||||
"local-draft"
|
||||
]
|
||||
},
|
||||
"authority": {
|
||||
"type": "string"
|
||||
},
|
||||
"writeCommand": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
|
@ -600,6 +635,14 @@ export const eveContractSchemas = {
|
|||
},
|
||||
"payloadSchema": {
|
||||
"type": "string"
|
||||
},
|
||||
"captureBindings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
|
@ -781,6 +824,79 @@ export const eveContractSchemas = {
|
|||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"commandResult": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "gamecult.eve.command_result.v1",
|
||||
"title": "Eve Command Result",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"receipt"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "gamecult.eve.command_result.v1"
|
||||
},
|
||||
"receipt": {
|
||||
"$ref": "gamecult.eve.command_receipt.v1"
|
||||
},
|
||||
"transientProjection": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "gamecult.eve.surface.v1"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"pluginPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pluginId",
|
||||
"schemaId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"pluginId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"schemaId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"payload": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"draftDirective": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"clear"
|
||||
],
|
||||
"properties": {
|
||||
"clear": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"bindingNames": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"inputCapability": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "gamecult.eve.input_capability.v1",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ export interface Component {
|
|||
schemaId: string;
|
||||
routeKind: string;
|
||||
routeDescription?: string;
|
||||
bindingName?: string;
|
||||
documentId?: string;
|
||||
fieldPath?: string;
|
||||
valueKind?: ("string" | "number" | "boolean" | "choice" | "string-list");
|
||||
accessMode?: ("read" | "write" | "read-write" | "local-draft");
|
||||
authority?: string;
|
||||
writeCommand?: string;
|
||||
[k: string]: any;
|
||||
}[];
|
||||
embeddedDocuments?: {
|
||||
|
|
@ -62,5 +69,6 @@ export interface EveCommandDescriptor {
|
|||
authority?: string;
|
||||
result?: string;
|
||||
payloadSchema?: string;
|
||||
captureBindings?: string[];
|
||||
[k: string]: any;
|
||||
}
|
||||
|
|
|
|||
6
packages/eve-contracts/dist/index.d.ts
vendored
6
packages/eve-contracts/dist/index.d.ts
vendored
|
|
@ -2,6 +2,7 @@ import { type ErrorObject } from "ajv/dist/2020.js";
|
|||
import type { EveCommandDescriptor } from "./generated/command-descriptor.js";
|
||||
import type { EveCommandInvocation } from "./generated/command-invocation.js";
|
||||
import type { EveCommandReceipt } from "./generated/command-receipt.js";
|
||||
import type { EveCommandResult } from "./generated/command-result.js";
|
||||
import type { EveInputCapability } from "./generated/input-capability.js";
|
||||
import type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
|
||||
import type { EveSurfaceDocument } from "./generated/surface.js";
|
||||
|
|
@ -9,6 +10,7 @@ import { type EveContractSchemaName } from "./generated/schemas.js";
|
|||
export type { EveCommandDescriptor } from "./generated/command-descriptor.js";
|
||||
export type { EveCommandInvocation } from "./generated/command-invocation.js";
|
||||
export type { EveCommandReceipt } from "./generated/command-receipt.js";
|
||||
export type { EveCommandResult } from "./generated/command-result.js";
|
||||
export type { EveInputCapability } from "./generated/input-capability.js";
|
||||
export type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
|
||||
export type { EveSurfaceDocument } from "./generated/surface.js";
|
||||
|
|
@ -18,6 +20,7 @@ export declare const EVE_SURFACE_SCHEMA: "gamecult.eve.surface.v1";
|
|||
export declare const EVE_COMMAND_DESCRIPTOR_SCHEMA: "gamecult.eve.command.v1";
|
||||
export declare const EVE_COMMAND_INVOCATION_SCHEMA: "gamecult.eve.command_invocation.v1";
|
||||
export declare const EVE_COMMAND_RECEIPT_SCHEMA: "gamecult.eve.command_receipt.v1";
|
||||
export declare const EVE_COMMAND_RESULT_SCHEMA: "gamecult.eve.command_result.v1";
|
||||
export declare const EVE_INPUT_CAPABILITY_SCHEMA: "gamecult.eve.input_capability.v1";
|
||||
type ContractTypes = {
|
||||
providerAdvertisement: EveProviderAdvertisement;
|
||||
|
|
@ -25,6 +28,7 @@ type ContractTypes = {
|
|||
commandDescriptor: EveCommandDescriptor;
|
||||
commandInvocation: EveCommandInvocation;
|
||||
commandReceipt: EveCommandReceipt;
|
||||
commandResult: EveCommandResult;
|
||||
inputCapability: EveInputCapability;
|
||||
};
|
||||
export declare class EveContractValidationError extends TypeError {
|
||||
|
|
@ -39,10 +43,12 @@ export declare const isEveSurfaceDocument: (value: unknown) => value is EveSurfa
|
|||
export declare const isEveCommandDescriptor: (value: unknown) => value is EveCommandDescriptor;
|
||||
export declare const isEveCommandInvocation: (value: unknown) => value is EveCommandInvocation;
|
||||
export declare const isEveCommandReceipt: (value: unknown) => value is EveCommandReceipt;
|
||||
export declare const isEveCommandResult: (value: unknown) => value is EveCommandResult;
|
||||
export declare const isEveInputCapability: (value: unknown) => value is EveInputCapability;
|
||||
export declare const parseEveProviderAdvertisement: (value: unknown) => EveProviderAdvertisement;
|
||||
export declare const parseEveSurfaceDocument: (value: unknown) => EveSurfaceDocument;
|
||||
export declare const parseEveCommandDescriptor: (value: unknown) => EveCommandDescriptor;
|
||||
export declare const parseEveCommandInvocation: (value: unknown) => EveCommandInvocation;
|
||||
export declare const parseEveCommandReceipt: (value: unknown) => EveCommandReceipt;
|
||||
export declare const parseEveCommandResult: (value: unknown) => EveCommandResult;
|
||||
export declare const parseEveInputCapability: (value: unknown) => EveInputCapability;
|
||||
|
|
|
|||
8
packages/eve-contracts/dist/index.js
vendored
8
packages/eve-contracts/dist/index.js
vendored
|
|
@ -6,6 +6,7 @@ export const EVE_SURFACE_SCHEMA = "gamecult.eve.surface.v1";
|
|||
export const EVE_COMMAND_DESCRIPTOR_SCHEMA = "gamecult.eve.command.v1";
|
||||
export const EVE_COMMAND_INVOCATION_SCHEMA = "gamecult.eve.command_invocation.v1";
|
||||
export const EVE_COMMAND_RECEIPT_SCHEMA = "gamecult.eve.command_receipt.v1";
|
||||
export const EVE_COMMAND_RESULT_SCHEMA = "gamecult.eve.command_result.v1";
|
||||
export const EVE_INPUT_CAPABILITY_SCHEMA = "gamecult.eve.input_capability.v1";
|
||||
const ajv = new Ajv2020({
|
||||
allErrors: true,
|
||||
|
|
@ -41,12 +42,19 @@ export const isEveSurfaceDocument = (value) => isEveContract("surface", value);
|
|||
export const isEveCommandDescriptor = (value) => isEveContract("commandDescriptor", value);
|
||||
export const isEveCommandInvocation = (value) => isEveContract("commandInvocation", value);
|
||||
export const isEveCommandReceipt = (value) => isEveContract("commandReceipt", value);
|
||||
export const isEveCommandResult = (value) => isEveContract("commandResult", value);
|
||||
export const isEveInputCapability = (value) => isEveContract("inputCapability", value);
|
||||
export const parseEveProviderAdvertisement = (value) => parseEveContract("providerAdvertisement", value);
|
||||
export const parseEveSurfaceDocument = (value) => parseEveContract("surface", value);
|
||||
export const parseEveCommandDescriptor = (value) => parseEveContract("commandDescriptor", value);
|
||||
export const parseEveCommandInvocation = (value) => parseEveContract("commandInvocation", value);
|
||||
export const parseEveCommandReceipt = (value) => parseEveContract("commandReceipt", value);
|
||||
export const parseEveCommandResult = (value) => {
|
||||
const result = parseEveContract("commandResult", value);
|
||||
if (result.transientProjection)
|
||||
parseEveSurfaceDocument(result.transientProjection);
|
||||
return result;
|
||||
};
|
||||
export const parseEveInputCapability = (value) => parseEveContract("inputCapability", value);
|
||||
function requiredValidator(schemaId) {
|
||||
const validator = ajv.getSchema(schemaId);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const contracts = [
|
|||
["commandDescriptor", "EveCommandDescriptor", "command-descriptor.ts", "gamecult.eve.command.v1.schema.json"],
|
||||
["commandInvocation", "EveCommandInvocation", "command-invocation.ts", "gamecult.eve.command_invocation.v1.schema.json"],
|
||||
["commandReceipt", "EveCommandReceipt", "command-receipt.ts", "gamecult.eve.command_receipt.v1.schema.json"],
|
||||
["commandResult", "EveCommandResult", "command-result.ts", "gamecult.eve.command_result.v1.schema.json"],
|
||||
["inputCapability", "EveInputCapability", "input-capability.ts", "gamecult.eve.input_capability.v1.schema.json"],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ transport?: string
|
|||
authority?: string
|
||||
result?: string
|
||||
payloadSchema?: string
|
||||
captureBindings?: string[]
|
||||
[k: string]: any
|
||||
}
|
||||
|
|
|
|||
46
packages/eve-contracts/src/generated/command-result.ts
Normal file
46
packages/eve-contracts/src/generated/command-result.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Generated by scripts/generate-contracts.mjs. Do not edit by hand.
|
||||
// Source: schemas/gamecult.eve.command_result.v1.schema.json
|
||||
export interface EveCommandResult {
|
||||
schema: "gamecult.eve.command_result.v1"
|
||||
receipt: EveCommandReceipt
|
||||
transientProjection?: {
|
||||
schema: "gamecult.eve.surface.v1"
|
||||
[k: string]: any
|
||||
}
|
||||
pluginPayload?: {
|
||||
pluginId: string
|
||||
schemaId: string
|
||||
payload: {
|
||||
[k: string]: any
|
||||
}
|
||||
}
|
||||
draftDirective?: {
|
||||
clear: boolean
|
||||
bindingNames?: string[]
|
||||
}
|
||||
}
|
||||
export interface EveCommandReceipt {
|
||||
schema: "gamecult.eve.command_receipt.v1"
|
||||
receiptId: string
|
||||
commandId: string
|
||||
command: string
|
||||
state: ("accepted" | "denied" | "pending" | "reconciled")
|
||||
ownerRepo: string
|
||||
authority: string
|
||||
providerId: string
|
||||
surfaceId: string
|
||||
message?: string
|
||||
diagnostics?: {
|
||||
[k: string]: any
|
||||
}[]
|
||||
issuedAtUtc?: string
|
||||
sourceVersion: number
|
||||
navigation?: {
|
||||
verseId: string
|
||||
providerId?: string
|
||||
surfaceId: string
|
||||
surfaceKind?: string
|
||||
rendezvousEndpoints?: string[]
|
||||
}
|
||||
[k: string]: any
|
||||
}
|
||||
|
|
@ -506,6 +506,41 @@ export const eveContractSchemas = {
|
|||
},
|
||||
"routeDescription": {
|
||||
"type": "string"
|
||||
},
|
||||
"bindingName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"documentId": {
|
||||
"type": "string"
|
||||
},
|
||||
"fieldPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"choice",
|
||||
"string-list"
|
||||
]
|
||||
},
|
||||
"accessMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"read",
|
||||
"write",
|
||||
"read-write",
|
||||
"local-draft"
|
||||
]
|
||||
},
|
||||
"authority": {
|
||||
"type": "string"
|
||||
},
|
||||
"writeCommand": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
|
@ -600,6 +635,14 @@ export const eveContractSchemas = {
|
|||
},
|
||||
"payloadSchema": {
|
||||
"type": "string"
|
||||
},
|
||||
"captureBindings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
|
@ -781,6 +824,79 @@ export const eveContractSchemas = {
|
|||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"commandResult": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "gamecult.eve.command_result.v1",
|
||||
"title": "Eve Command Result",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"receipt"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "gamecult.eve.command_result.v1"
|
||||
},
|
||||
"receipt": {
|
||||
"$ref": "gamecult.eve.command_receipt.v1"
|
||||
},
|
||||
"transientProjection": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "gamecult.eve.surface.v1"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"pluginPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pluginId",
|
||||
"schemaId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"pluginId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"schemaId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"payload": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"draftDirective": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"clear"
|
||||
],
|
||||
"properties": {
|
||||
"clear": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"bindingNames": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"inputCapability": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "gamecult.eve.input_capability.v1",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ sourceId: string
|
|||
schemaId: string
|
||||
routeKind: string
|
||||
routeDescription?: string
|
||||
bindingName?: string
|
||||
documentId?: string
|
||||
fieldPath?: string
|
||||
valueKind?: ("string" | "number" | "boolean" | "choice" | "string-list")
|
||||
accessMode?: ("read" | "write" | "read-write" | "local-draft")
|
||||
authority?: string
|
||||
writeCommand?: string
|
||||
[k: string]: any
|
||||
}[]
|
||||
embeddedDocuments?: {
|
||||
|
|
@ -65,5 +72,6 @@ transport?: string
|
|||
authority?: string
|
||||
result?: string
|
||||
payloadSchema?: string
|
||||
captureBindings?: string[]
|
||||
[k: string]: any
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020.
|
|||
import type { EveCommandDescriptor } from "./generated/command-descriptor.js";
|
||||
import type { EveCommandInvocation } from "./generated/command-invocation.js";
|
||||
import type { EveCommandReceipt } from "./generated/command-receipt.js";
|
||||
import type { EveCommandResult } from "./generated/command-result.js";
|
||||
import type { EveInputCapability } from "./generated/input-capability.js";
|
||||
import type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
|
||||
import type { EveSurfaceDocument } from "./generated/surface.js";
|
||||
|
|
@ -10,6 +11,7 @@ import { eveContractSchemas, type EveContractSchemaName } from "./generated/sche
|
|||
export type { EveCommandDescriptor } from "./generated/command-descriptor.js";
|
||||
export type { EveCommandInvocation } from "./generated/command-invocation.js";
|
||||
export type { EveCommandReceipt } from "./generated/command-receipt.js";
|
||||
export type { EveCommandResult } from "./generated/command-result.js";
|
||||
export type { EveInputCapability } from "./generated/input-capability.js";
|
||||
export type { EveProviderAdvertisement } from "./generated/provider-advertisement.js";
|
||||
export type { EveSurfaceDocument } from "./generated/surface.js";
|
||||
|
|
@ -20,6 +22,7 @@ export const EVE_SURFACE_SCHEMA = "gamecult.eve.surface.v1" as const;
|
|||
export const EVE_COMMAND_DESCRIPTOR_SCHEMA = "gamecult.eve.command.v1" as const;
|
||||
export const EVE_COMMAND_INVOCATION_SCHEMA = "gamecult.eve.command_invocation.v1" as const;
|
||||
export const EVE_COMMAND_RECEIPT_SCHEMA = "gamecult.eve.command_receipt.v1" as const;
|
||||
export const EVE_COMMAND_RESULT_SCHEMA = "gamecult.eve.command_result.v1" as const;
|
||||
export const EVE_INPUT_CAPABILITY_SCHEMA = "gamecult.eve.input_capability.v1" as const;
|
||||
|
||||
type ContractTypes = {
|
||||
|
|
@ -28,6 +31,7 @@ type ContractTypes = {
|
|||
commandDescriptor: EveCommandDescriptor;
|
||||
commandInvocation: EveCommandInvocation;
|
||||
commandReceipt: EveCommandReceipt;
|
||||
commandResult: EveCommandResult;
|
||||
inputCapability: EveInputCapability;
|
||||
};
|
||||
|
||||
|
|
@ -82,6 +86,8 @@ export const isEveCommandInvocation = (value: unknown): value is EveCommandInvoc
|
|||
isEveContract("commandInvocation", value);
|
||||
export const isEveCommandReceipt = (value: unknown): value is EveCommandReceipt =>
|
||||
isEveContract("commandReceipt", value);
|
||||
export const isEveCommandResult = (value: unknown): value is EveCommandResult =>
|
||||
isEveContract("commandResult", value);
|
||||
export const isEveInputCapability = (value: unknown): value is EveInputCapability =>
|
||||
isEveContract("inputCapability", value);
|
||||
|
||||
|
|
@ -95,6 +101,11 @@ export const parseEveCommandInvocation = (value: unknown): EveCommandInvocation
|
|||
parseEveContract("commandInvocation", value);
|
||||
export const parseEveCommandReceipt = (value: unknown): EveCommandReceipt =>
|
||||
parseEveContract("commandReceipt", value);
|
||||
export const parseEveCommandResult = (value: unknown): EveCommandResult => {
|
||||
const result = parseEveContract("commandResult", value);
|
||||
if (result.transientProjection) parseEveSurfaceDocument(result.transientProjection);
|
||||
return result;
|
||||
};
|
||||
export const parseEveInputCapability = (value: unknown): EveInputCapability =>
|
||||
parseEveContract("inputCapability", value);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
isEveCommandDescriptor,
|
||||
parseEveCommandInvocation,
|
||||
parseEveCommandReceipt,
|
||||
parseEveCommandResult,
|
||||
parseEveInputCapability,
|
||||
parseEveProviderAdvertisement,
|
||||
parseEveSurfaceDocument,
|
||||
|
|
@ -194,6 +195,33 @@ test("validates an existing canonical Eve surface fixture", async () => {
|
|||
assert.equal(parseEveSurfaceDocument(fixture).providerId, "eve.world-smoke");
|
||||
});
|
||||
|
||||
test("command results validate persisted receipts and transient Eve projections together", () => {
|
||||
const result = parseEveCommandResult({
|
||||
schema: "gamecult.eve.command_result.v1",
|
||||
receipt: {
|
||||
schema: "gamecult.eve.command_receipt.v1",
|
||||
receiptId: "receipt-2",
|
||||
commandId: "command-2",
|
||||
command: "session.message.send",
|
||||
state: "accepted",
|
||||
ownerRepo: "Ghostlight",
|
||||
authority: "SessionZeroKernel",
|
||||
providerId: "ghostlight",
|
||||
surfaceId: "ghostlight.play",
|
||||
sourceVersion: 4,
|
||||
},
|
||||
transientProjection: surface,
|
||||
draftDirective: { clear: true, bindingNames: ["composer.message"] },
|
||||
});
|
||||
assert.equal(result.receipt.state, "accepted");
|
||||
assert.equal(result.transientProjection.providerId, "voidbot.swarm");
|
||||
assert.throws(() => parseEveCommandResult({
|
||||
schema: "gamecult.eve.command_result.v1",
|
||||
receipt: result.receipt,
|
||||
transientProjection: { schema: "gamecult.eve.surface.v1" },
|
||||
}), EveContractValidationError);
|
||||
});
|
||||
|
||||
test("validates the Epiphany Model Atlas advertisement and canonical surface", async () => {
|
||||
const fixtureRoot = resolve(import.meta.dirname, "..", "..", "..", "web", "fixtures");
|
||||
const advertisement = JSON.parse(await readFile(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@
|
|||
"transport": { "type": "string" },
|
||||
"authority": { "type": "string" },
|
||||
"result": { "type": "string" },
|
||||
"payloadSchema": { "type": "string" }
|
||||
"payloadSchema": { "type": "string" },
|
||||
"captureBindings": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
|
|
|
|||
43
schemas/gamecult.eve.command_result.v1.schema.json
Normal file
43
schemas/gamecult.eve.command_result.v1.schema.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "gamecult.eve.command_result.v1",
|
||||
"title": "Eve Command Result",
|
||||
"type": "object",
|
||||
"required": ["schema", "receipt"],
|
||||
"properties": {
|
||||
"schema": { "const": "gamecult.eve.command_result.v1" },
|
||||
"receipt": { "$ref": "gamecult.eve.command_receipt.v1" },
|
||||
"transientProjection": {
|
||||
"type": "object",
|
||||
"required": ["schema"],
|
||||
"properties": {
|
||||
"schema": { "const": "gamecult.eve.surface.v1" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"pluginPayload": {
|
||||
"type": "object",
|
||||
"required": ["pluginId", "schemaId", "payload"],
|
||||
"properties": {
|
||||
"pluginId": { "type": "string", "minLength": 1 },
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"payload": { "type": "object" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"draftDirective": {
|
||||
"type": "object",
|
||||
"required": ["clear"],
|
||||
"properties": {
|
||||
"clear": { "type": "boolean" },
|
||||
"bindingNames": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
13
schemas/gamecult.eve.operation_payload.v1.schema.json
Normal file
13
schemas/gamecult.eve.operation_payload.v1.schema.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "gamecult.eve.operation_payload.v1",
|
||||
"title": "Eve Generic Operation Payload",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bindings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
|
|
@ -63,7 +63,20 @@
|
|||
"sourceId": { "type": "string", "minLength": 1 },
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"routeKind": { "type": "string", "minLength": 1 },
|
||||
"routeDescription": { "type": "string" }
|
||||
"routeDescription": { "type": "string" },
|
||||
"bindingName": { "type": "string", "minLength": 1 },
|
||||
"documentId": { "type": "string" },
|
||||
"fieldPath": { "type": "string" },
|
||||
"valueKind": {
|
||||
"type": "string",
|
||||
"enum": ["string", "number", "boolean", "choice", "string-list"]
|
||||
},
|
||||
"accessMode": {
|
||||
"type": "string",
|
||||
"enum": ["read", "write", "read-write", "local-draft"]
|
||||
},
|
||||
"authority": { "type": "string" },
|
||||
"writeCommand": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue