Define generic spatial inventory drops

This commit is contained in:
Meta Vens 2026-07-17 01:14:13 +01:00
commit 9d3191d5f8
5 changed files with 213 additions and 4 deletions

View file

@ -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.<sourceKind>` 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

View file

@ -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<string, unknown>, options?: EveBrowserLoweringOptions): EveCommandIntent;
export declare function createInventoryDropIntent(source: Record<string, unknown>, target: Record<string, unknown>, 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<string, unknown>, options?: EveBrowserLoweringOptions): EveCommandIntent;
export declare function emptyState(message: string): HTMLElement;

View file

@ -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);

View file

@ -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<string, unknown>;
try {
source = JSON.parse(encoded) as Record<string, unknown>;
} 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<string>();
for (const child of node.children || []) {
const childProps = objectProps(child.props);
@ -1173,6 +1198,7 @@ function renderInventoryItem(
props: Record<string, unknown>,
layout: Record<string, unknown>,
style: Record<string, unknown>,
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<string, unknown>): boolean {
return Object.entries(target).some(([key, value]) =>
(key === "dropCommand" || key.startsWith("dropCommand.")) && typeof value === "string" && value.length > 0);
}
export function createInventoryDropIntent(
source: Record<string, unknown>,
target: Record<string, unknown>,
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<string, unknown> = {
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<string, unknown>,

View file

@ -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",