diff --git a/docs/surface-contract-v1.md b/docs/surface-contract-v1.md index 16ec3f9..0a91b3c 100644 --- a/docs/surface-contract-v1.md +++ b/docs/surface-contract-v1.md @@ -288,6 +288,33 @@ read scalar fields through the shared payload primitive. Renderer code should construct requests from `CultMeshOperationInvocationDescriptor` and `CultMeshOperationPayload`, not from command strings plus raw dictionaries. +### Inventory manipulation + +`inventory.grid`, `inventory.item`, and `inventory.drag_session` are standard +Eve component kinds. They describe a spatial inventory without making the +renderer an inventory authority. + +An `inventory.item` that can be moved carries `sourceKind`, +`sourceEntityKey`, `sourceIndex`, `itemKey`, `quantity`, `x`, `y`, optional +`rotation`, and `draggable`. An accepting `inventory.grid` carries +`targetKind`, `targetEntityKey`, `targetIndex`, its spatial dimensions, and +either `dropCommand` or source-specific `dropCommand.` entries. + +The lowerer owns only the transient pointer or keyboard gesture. On drop it +combines source identity with the selected target cell and emits the advertised +operation. The portable payload includes `sourceKind`, `originEntityKey`, +`originIndex`, `originCargoIndex`, `itemKey`, `quantity`, `sourceX`, `sourceY`, +`destinationKind`, `destinationEntityKey`, `destinationIndex`, +`destinationCargoIndex`, `destinationX`, `destinationY`, and +`hasDestinationPosition`. Providers ignore aliases their typed operation does +not consume. + +Fit, rotation constraints, stack limits, access, mutation, and receipts remain +provider authority. A lowerer may preview a footprint, but it must not suppress +an advertised command from a locally reconstructed acceptance opinion. +Click-to-pick/click-to-place and pointer dragging emit the same operation +payload. + ## Synchronized Style `surface.styles.tokens` is provider-owned appearance state. Renderers project diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index c653c7d..95b85a3 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -170,6 +170,7 @@ export declare function renderEveComponent(node: EveSurfaceComponent, options?: export declare function projectSemanticListItem(node: EveSurfaceComponent): EveSemanticListItem; export declare function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[]; export declare function createWorldActionIntent(command: string, action: Record, options?: EveBrowserLoweringOptions): EveCommandIntent; +export declare function createInventoryDropIntent(source: Record, target: Record, destinationX: number, destinationY: number, options?: EveBrowserLoweringOptions): EveCommandIntent | undefined; export declare function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body?: HTMLElement): void; export declare function createEveCommandIntent(commandId: string, props?: Record, options?: EveBrowserLoweringOptions): EveCommandIntent; export declare function emptyState(message: string): HTMLElement; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 5787522..ec03ee8 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -293,7 +293,7 @@ export function renderEveComponent(node, options = currentOptions) { return renderInventoryGrid(node, props, layout, style, options); } if (kind === "inventory.item") { - return renderInventoryItem(node, props, layout, style); + return renderInventoryItem(node, props, layout, style, options); } const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters) .find(candidate => candidate.componentKinds.includes(kind)); @@ -951,6 +951,36 @@ function renderInventoryGrid(node, props, layout, style, options) { board.style.overflow = firstString(props.boardOverflow, "auto"); board.style.padding = cssSize(firstString(props.boardPadding, "4")); applyGeneratedLayout(board, prefixedProps(layout, "board."), prefixedProps(style, "board.")); + board.addEventListener("dragover", event => { + if (!hasInventoryDropCommand(props)) + return; + event.preventDefault(); + if (event.dataTransfer) + event.dataTransfer.dropEffect = "move"; + }); + board.addEventListener("drop", event => { + const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || ""; + if (!encoded) + return; + let source; + try { + source = JSON.parse(encoded); + } + catch { + return; + } + const rect = board.getBoundingClientRect(); + const cellSize = Math.max(1, numberProp(props.cellSize, 72)); + const cellGap = Math.max(0, numberProp(props.cellGap, 4)); + const pitch = cellSize + cellGap; + const x = Math.max(0, Math.min(columns - 1, Math.floor((event.clientX - rect.left) / pitch))); + const y = Math.max(0, Math.min(rows - 1, Math.floor((event.clientY - rect.top) / pitch))); + const intent = createInventoryDropIntent(source, props, x, y, options); + if (!intent) + return; + event.preventDefault(); + void options.commandSink?.(intent, node); + }); const occupied = new Set(); for (const child of node.children || []) { const childProps = objectProps(child.props); @@ -978,7 +1008,7 @@ function renderInventoryGrid(node, props, layout, style, options) { grid.append(board); return grid; } -function renderInventoryItem(node, props, layout, style) { +function renderInventoryItem(node, props, layout, style, options) { const item = el("button", "cultui-inventory-item"); assignId(item, node); item.type = "button"; @@ -987,6 +1017,14 @@ function renderInventoryItem(node, props, layout, style) { const y = positiveInt(props.y, 0); item.dataset.itemKey = firstString(props.itemKey, props.label, node.id, "item"); item.dataset.source = firstString(props.source, ""); + item.draggable = (props.draggable === undefined || boolProp(props.draggable)) && + !!firstString(props.sourceKind, props.source, ""); + item.addEventListener("dragstart", event => { + if (!item.draggable || !event.dataTransfer) + return; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/vnd.gamecult.eve.inventory-item+json", JSON.stringify(props)); + }); item.style.gridColumn = String(x + 1); item.style.gridRow = String(y + 1); const icon = el("span", "cultui-inventory-item-icon"); @@ -1013,6 +1051,36 @@ function renderInventoryItem(node, props, layout, style) { ].filter(Boolean).join(" | "); return item; } +function hasInventoryDropCommand(target) { + return Object.entries(target).some(([key, value]) => (key === "dropCommand" || key.startsWith("dropCommand.")) && typeof value === "string" && value.length > 0); +} +export function createInventoryDropIntent(source, target, destinationX, destinationY, options = currentOptions) { + const sourceKind = firstString(source.sourceKind, source.source, ""); + const command = firstString(target[`dropCommand.${sourceKind}`], target.dropCommand, ""); + if (!sourceKind || !command) + return undefined; + const sourceIndex = numberProp(source.sourceIndex, -1); + const targetIndex = numberProp(target.targetIndex, -1); + const targetKind = firstString(target.targetKind, ""); + const payload = { + sourceKind, + originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""), + originIndex: sourceIndex, + originCargoIndex: sourceKind === "cargo" ? sourceIndex : -1, + itemKey: firstString(source.itemKey, ""), + quantity: Math.max(1, numberProp(source.quantity, 1)), + sourceX: numberProp(source.x, -2147483648), + sourceY: numberProp(source.y, -2147483648), + destinationKind: targetKind, + destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""), + destinationIndex: targetIndex, + destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1, + destinationX: Math.trunc(destinationX), + destinationY: Math.trunc(destinationY), + hasDestinationPosition: true, + }; + return createEveCommandIntent(command, { action: payload }, options); +} function renderInputBindingMap(node, props, options) { const root = el("section", "cultui-binding-map"); assignId(root, node); diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 06917ff..4a104a0 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -477,7 +477,7 @@ export function renderEveComponent( } if (kind === "inventory.item") { - return renderInventoryItem(node, props, layout, style); + return renderInventoryItem(node, props, layout, style, options); } const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters) @@ -1141,6 +1141,31 @@ function renderInventoryGrid( board.style.overflow = firstString(props.boardOverflow, "auto"); board.style.padding = cssSize(firstString(props.boardPadding, "4")); applyGeneratedLayout(board, prefixedProps(layout, "board."), prefixedProps(style, "board.")); + board.addEventListener("dragover", event => { + if (!hasInventoryDropCommand(props)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + }); + board.addEventListener("drop", event => { + const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || ""; + if (!encoded) return; + let source: Record; + try { + source = JSON.parse(encoded) as Record; + } catch { + return; + } + const rect = board.getBoundingClientRect(); + const cellSize = Math.max(1, numberProp(props.cellSize, 72)); + const cellGap = Math.max(0, numberProp(props.cellGap, 4)); + const pitch = cellSize + cellGap; + const x = Math.max(0, Math.min(columns - 1, Math.floor((event.clientX - rect.left) / pitch))); + const y = Math.max(0, Math.min(rows - 1, Math.floor((event.clientY - rect.top) / pitch))); + const intent = createInventoryDropIntent(source, props, x, y, options); + if (!intent) return; + event.preventDefault(); + void options.commandSink?.(intent, node); + }); const occupied = new Set(); for (const child of node.children || []) { const childProps = objectProps(child.props); @@ -1173,6 +1198,7 @@ function renderInventoryItem( props: Record, layout: Record, style: Record, + options: EveBrowserLoweringOptions, ): HTMLElement { const item = el("button", "cultui-inventory-item") as HTMLButtonElement; assignId(item, node); @@ -1182,6 +1208,16 @@ function renderInventoryItem( const y = positiveInt(props.y, 0); item.dataset.itemKey = firstString(props.itemKey, props.label, node.id, "item"); item.dataset.source = firstString(props.source, ""); + item.draggable = (props.draggable === undefined || boolProp(props.draggable)) && + !!firstString(props.sourceKind, props.source, ""); + item.addEventListener("dragstart", event => { + if (!item.draggable || !event.dataTransfer) return; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + "application/vnd.gamecult.eve.inventory-item+json", + JSON.stringify(props), + ); + }); item.style.gridColumn = String(x + 1); item.style.gridRow = String(y + 1); const icon = el("span", "cultui-inventory-item-icon"); @@ -1209,6 +1245,45 @@ function renderInventoryItem( return item; } +function hasInventoryDropCommand(target: Record): boolean { + return Object.entries(target).some(([key, value]) => + (key === "dropCommand" || key.startsWith("dropCommand.")) && typeof value === "string" && value.length > 0); +} + +export function createInventoryDropIntent( + source: Record, + target: Record, + destinationX: number, + destinationY: number, + options: EveBrowserLoweringOptions = currentOptions, +): EveCommandIntent | undefined { + const sourceKind = firstString(source.sourceKind, source.source, ""); + const command = firstString(target[`dropCommand.${sourceKind}`], target.dropCommand, ""); + if (!sourceKind || !command) return undefined; + + const sourceIndex = numberProp(source.sourceIndex, -1); + const targetIndex = numberProp(target.targetIndex, -1); + const targetKind = firstString(target.targetKind, ""); + const payload: Record = { + sourceKind, + originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""), + originIndex: sourceIndex, + originCargoIndex: sourceKind === "cargo" ? sourceIndex : -1, + itemKey: firstString(source.itemKey, ""), + quantity: Math.max(1, numberProp(source.quantity, 1)), + sourceX: numberProp(source.x, -2147483648), + sourceY: numberProp(source.y, -2147483648), + destinationKind: targetKind, + destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""), + destinationIndex: targetIndex, + destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1, + destinationX: Math.trunc(destinationX), + destinationY: Math.trunc(destinationY), + hasDestinationPosition: true, + }; + return createEveCommandIntent(command, { action: payload }, options); +} + function renderInputBindingMap( node: EveSurfaceComponent, props: Record, diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index e3daa57..608ab91 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { applyEveStateBindingValue, createEveCommandIntent, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectSemanticListItems, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js"; +import { applyEveStateBindingValue, createEveCommandIntent, createInventoryDropIntent, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectSemanticListItems, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js"; test("applies provider state bindings to component props instead of receipt diagnostics", () => { const component = { props: { value: 1.2 } }; @@ -56,6 +56,44 @@ test("world interactions emit provider-routed command intents without local auth assert.equal(intent.payload.directionX, 1); }); +test("inventory drops select the provider-advertised operation and preserve spatial identity", () => { + const intent = createInventoryDropIntent({ + sourceKind: "cargo", + sourceEntityKey: "zone.0.entity.1", + sourceIndex: 2, + itemKey: "ore", + quantity: 4, + x: 3, + y: 5, + }, { + targetKind: "equipment", + targetEntityKey: "zone.0.entity.1", + targetIndex: -1, + "dropCommand.cargo": "aetheria.daemon.commands.EquipItem", + }, 7, 9, { + activeSurfaceId: "aetheria.refit", + clientId: "browser.inventory-test", + provider: { providerId: "aetheria", surfaces: [{ surfaceId: "aetheria.refit" }] }, + }); + + assert.equal(intent.command, "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"); + assert.equal(intent.payload.destinationX, 7); + assert.equal(intent.payload.destinationY, 9); + assert.equal(intent.payload.hasDestinationPosition, true); +}); + +test("inventory drops fail closed without an operation for the source kind", () => { + assert.equal(createInventoryDropIntent( + { sourceKind: "docking-bay", itemKey: "hangar" }, + { targetKind: "equipment", "dropCommand.cargo": "equip" }, + 0, + 0, + ), undefined); +}); + test("projects provider world entities into a normalized tactical plane", () => { const entities = projectWorldScene({ kind: "world.scene3d",