Define generic spatial inventory drops
This commit is contained in:
parent
78543b984b
commit
9d3191d5f8
5 changed files with 213 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
72
packages/eve-browser-lowering/dist/index.js
vendored
72
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue