Preserve Eve input defaults and command receipts
This commit is contained in:
parent
6766bee7c1
commit
6b5faa6a77
5 changed files with 182 additions and 4 deletions
|
|
@ -9,8 +9,8 @@ This package owns the shared browser renderer contract:
|
|||
- `applyEveSurfaceStyles(styles, body)` maps portable Eve style tokens to CSS custom properties.
|
||||
- `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.
|
||||
- Editable string, textarea, number, select, and choice controls use named Eve state bindings. The renderer preserves edited drafts by provider/surface/binding identity; untouched controls contribute their authored value when an operation captures 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. The renderer-owned receipt region survives the authoritative surface refresh that follows the command.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
37
packages/eve-browser-lowering/dist/index.js
vendored
37
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -247,7 +247,10 @@ export function renderEveSurface(surface, host, options = {}) {
|
|||
options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`;
|
||||
}
|
||||
if (surface.surface?.root) {
|
||||
const commandResult = host.querySelector(":scope > .eve-command-result-region");
|
||||
host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions));
|
||||
if (commandResult)
|
||||
host.append(commandResult);
|
||||
if (options.stateBindingResolver) {
|
||||
const controller = new EveSurfaceBindingController(surface, host, scopedOptions);
|
||||
activeSurfaceBindings.set(host, controller);
|
||||
|
|
@ -1694,7 +1697,7 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) {
|
|||
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 bindings = captureBindingValues(surface?.surface?.root, providerId, surfaceId, captureBindings, options.draftStore);
|
||||
const payload = commandPayload(action);
|
||||
if (captureBindings.length)
|
||||
payload.bindings = bindings;
|
||||
|
|
@ -1721,6 +1724,38 @@ export function createEveCommandIntent(commandId, props = {}, options = {}) {
|
|||
};
|
||||
return intent;
|
||||
}
|
||||
function captureBindingValues(root, providerId, surfaceId, bindingNames, drafts) {
|
||||
const captured = {};
|
||||
for (const name of bindingNames) {
|
||||
if (drafts?.has(providerId, surfaceId, name)) {
|
||||
captured[name] = drafts.get(providerId, surfaceId, name);
|
||||
continue;
|
||||
}
|
||||
const authored = findAuthoredBindingValue(root, name);
|
||||
if (authored.found)
|
||||
captured[name] = authored.value;
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
function findAuthoredBindingValue(node, bindingName) {
|
||||
if (!node)
|
||||
return { found: false };
|
||||
const props = objectProps(node.props);
|
||||
const binding = (node.stateBindings || []).find(candidate => firstString(candidate.bindingName, candidate.pointerId) === bindingName);
|
||||
const localName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id);
|
||||
if (localName === bindingName) {
|
||||
const targetProp = firstString(binding?.targetProp, "value");
|
||||
if (Object.prototype.hasOwnProperty.call(props, targetProp)) {
|
||||
return { found: true, value: props[targetProp] };
|
||||
}
|
||||
}
|
||||
for (const child of node.children || []) {
|
||||
const match = findAuthoredBindingValue(child, bindingName);
|
||||
if (match.found)
|
||||
return match;
|
||||
}
|
||||
return { found: false };
|
||||
}
|
||||
function commandPayload(action) {
|
||||
const payload = { ...action };
|
||||
for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ Derived state:
|
|||
|
||||
- DOM nodes, focus restoration data, normalized styles, and component indexes
|
||||
are projections of the owning surface;
|
||||
- local drafts and the latest command-result region are renderer-owned
|
||||
transients; refresh may replace the surface projection but may not erase a
|
||||
receipt before the player can perceive it;
|
||||
- loaded font stylesheets are a document resource cache, not active-surface
|
||||
state.
|
||||
|
||||
|
|
@ -41,6 +44,8 @@ Shared paths:
|
|||
creation, and asset resolution all receive the same host-scoped context;
|
||||
- initial binding hydration and live binding watches use the same component
|
||||
projection replacement primitive.
|
||||
- operation capture resolves edited drafts first and otherwise uses the
|
||||
authored value currently displayed by the bound control.
|
||||
|
||||
Cut line:
|
||||
|
||||
|
|
@ -55,4 +60,7 @@ Verification:
|
|||
and control skins after either host renders or updates;
|
||||
- a bound leaf update does not replace either host root;
|
||||
- focused input selection and host scroll survive a binding update;
|
||||
- untouched select and numeric defaults are present in captured operations;
|
||||
- accepted and denied command results remain visible after authoritative
|
||||
refresh;
|
||||
- disposing or rerendering one host cannot unsubscribe the other.
|
||||
|
|
|
|||
|
|
@ -464,7 +464,9 @@ export function renderEveSurface(
|
|||
options.statusElement.textContent = `${surface.title || surface.surface?.title || "surface"}${options.source ? ` (${options.source})` : ""}`;
|
||||
}
|
||||
if (surface.surface?.root) {
|
||||
const commandResult = host.querySelector<HTMLElement>(":scope > .eve-command-result-region");
|
||||
host.replaceChildren(renderEveComponent(surface.surface.root, scopedOptions));
|
||||
if (commandResult) host.append(commandResult);
|
||||
if (options.stateBindingResolver) {
|
||||
const controller = new EveSurfaceBindingController(surface, host, scopedOptions);
|
||||
activeSurfaceBindings.set(host, controller);
|
||||
|
|
@ -1996,7 +1998,13 @@ export function createEveCommandIntent(
|
|||
const captureBindings = stringArray(
|
||||
descriptor?.captureBindings ?? action.captureBindings ?? props.captureBindings,
|
||||
);
|
||||
const bindings = options.draftStore?.capture(providerId, surfaceId, captureBindings) ?? {};
|
||||
const bindings = captureBindingValues(
|
||||
surface?.surface?.root,
|
||||
providerId,
|
||||
surfaceId,
|
||||
captureBindings,
|
||||
options.draftStore,
|
||||
);
|
||||
const payload = commandPayload(action);
|
||||
if (captureBindings.length) payload.bindings = bindings;
|
||||
const intent: EveCommandIntent = {
|
||||
|
|
@ -2023,6 +2031,48 @@ export function createEveCommandIntent(
|
|||
return intent;
|
||||
}
|
||||
|
||||
function captureBindingValues(
|
||||
root: EveSurfaceComponent | undefined,
|
||||
providerId: string,
|
||||
surfaceId: string,
|
||||
bindingNames: readonly string[],
|
||||
drafts: EveBrowserDraftStore | undefined,
|
||||
): Record<string, unknown> {
|
||||
const captured: Record<string, unknown> = {};
|
||||
for (const name of bindingNames) {
|
||||
if (drafts?.has(providerId, surfaceId, name)) {
|
||||
captured[name] = drafts.get(providerId, surfaceId, name);
|
||||
continue;
|
||||
}
|
||||
const authored = findAuthoredBindingValue(root, name);
|
||||
if (authored.found) captured[name] = authored.value;
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
function findAuthoredBindingValue(
|
||||
node: EveSurfaceComponent | undefined,
|
||||
bindingName: string,
|
||||
): { found: boolean; value?: unknown } {
|
||||
if (!node) return { found: false };
|
||||
const props = objectProps(node.props);
|
||||
const binding = (node.stateBindings || []).find(candidate =>
|
||||
firstString(candidate.bindingName, candidate.pointerId) === bindingName,
|
||||
);
|
||||
const localName = firstString(binding?.bindingName, props.bind, binding?.pointerId, node.id);
|
||||
if (localName === bindingName) {
|
||||
const targetProp = firstString(binding?.targetProp, "value");
|
||||
if (Object.prototype.hasOwnProperty.call(props, targetProp)) {
|
||||
return { found: true, value: props[targetProp] };
|
||||
}
|
||||
}
|
||||
for (const child of node.children || []) {
|
||||
const match = findAuthoredBindingValue(child, bindingName);
|
||||
if (match.found) return match;
|
||||
}
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
function commandPayload(action: Record<string, unknown>): Record<string, unknown> {
|
||||
const payload = { ...action };
|
||||
for (const key of ["command", "commandBoundary", "receiptSchema", "schemaId", "captureBindings", "transport"]) {
|
||||
|
|
|
|||
|
|
@ -264,3 +264,88 @@ test("editable bindings preserve local drafts across authoritative refresh and o
|
|||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
||||
test("operations capture untouched authored choice defaults", () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
const commands = [];
|
||||
const host = document.querySelector("#a");
|
||||
const provider = {
|
||||
providerId: "ghostlight",
|
||||
surfaces: [{ surfaceId: "ghostlight.play", worldInteraction: {
|
||||
commandBoundary: "ghostlight.eve.commands",
|
||||
receiptSchema: "gamecult.eve.command_result.v1",
|
||||
} }],
|
||||
};
|
||||
renderEveSurface({
|
||||
providerId: "ghostlight",
|
||||
version: 7,
|
||||
commands: [{
|
||||
command: "session_zero.message.send",
|
||||
payloadSchema: "session_zero.message.send.v1",
|
||||
captureBindings: ["channel_id"],
|
||||
}],
|
||||
surface: {
|
||||
id: "ghostlight.play",
|
||||
root: { id: "root", kind: "column", children: [
|
||||
{
|
||||
id: "channel",
|
||||
kind: "control.select",
|
||||
props: { label: "Speak at", value: "shared" },
|
||||
stateBindings: [{
|
||||
targetProp: "value",
|
||||
pointerId: "ghostlight.local.channel_id",
|
||||
sourceId: "eve.browser.local",
|
||||
schemaId: "gamecult.eve.local_draft.v1",
|
||||
routeKind: "in-process",
|
||||
bindingName: "channel_id",
|
||||
valueKind: "choice",
|
||||
accessMode: "local-draft",
|
||||
authority: "eve.browser",
|
||||
}],
|
||||
children: [
|
||||
{ kind: "control.option", props: { label: "Shared", value: "shared" } },
|
||||
{ kind: "control.option", props: { label: "Private", value: "private" } },
|
||||
],
|
||||
},
|
||||
{ id: "send", kind: "control.button", props: { label: "Send", command: "session_zero.message.send" } },
|
||||
] },
|
||||
},
|
||||
}, host, {
|
||||
provider,
|
||||
draftStore: new EveBrowserDraftStore(),
|
||||
commandSink: intent => commands.push(intent),
|
||||
});
|
||||
|
||||
assert.equal(host.querySelector("select").value, "shared");
|
||||
host.querySelector("button").click();
|
||||
assert.deepEqual(commands[0].payload.bindings, { channel_id: "shared" });
|
||||
} finally {
|
||||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
||||
test("authoritative refresh preserves the renderer-owned command result", () => {
|
||||
const restoreDom = installDom();
|
||||
try {
|
||||
const host = document.querySelector("#a");
|
||||
const surface = version => ({
|
||||
providerId: "ghostlight",
|
||||
version,
|
||||
surface: { id: "ghostlight.play", root: { id: `root-${version}`, kind: "text", props: { value: `revision ${version}` } } },
|
||||
});
|
||||
renderEveSurface(surface(7), host);
|
||||
const result = document.createElement("section");
|
||||
result.className = "eve-command-result-region";
|
||||
result.setAttribute("role", "error");
|
||||
result.textContent = "The command was denied.";
|
||||
host.append(result);
|
||||
|
||||
renderEveSurface(surface(8), host);
|
||||
assert.equal(host.querySelector(".eve-command-result-region"), result);
|
||||
assert.equal(result.textContent, "The command was denied.");
|
||||
assert.match(host.textContent, /revision 8/);
|
||||
} finally {
|
||||
restoreDom();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue