Add portable inventory placement previews

This commit is contained in:
Meta Vens 2026-08-18 05:22:45 +02:00
commit 2c4a9adb05
10 changed files with 457 additions and 35 deletions

View file

@ -171,6 +171,16 @@ export declare function projectSemanticListItem(node: EveSurfaceComponent): EveS
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 interface InventoryPlacementCell {
x: number;
y: number;
}
export interface InventoryPlacementPreview {
valid: boolean;
reason: "valid" | "outside-grid" | "outside-valid-shape" | "occupied";
cells: InventoryPlacementCell[];
}
export declare function createInventoryPlacementPreview(source: Record<string, unknown>, target: Record<string, unknown>, targetChildren: EveSurfaceComponent[], destinationX: number, destinationY: number): InventoryPlacementPreview;
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

@ -1042,8 +1042,30 @@ function renderInventoryGrid(node, props, layout, style, options) {
event.preventDefault();
if (event.dataTransfer)
event.dataTransfer.dropEffect = "move";
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
if (!encoded)
return;
try {
const source = JSON.parse(encoded);
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.floor((event.clientX - rect.left) / pitch));
const y = Math.max(0, Math.floor((event.clientY - rect.top) / pitch));
showInventoryPlacementPreview(board, source, props, node.children || [], x, y, cellSize);
}
catch {
clearInventoryPlacementPreview(board);
}
});
board.addEventListener("dragleave", event => {
if (event.relatedTarget instanceof Node && board.contains(event.relatedTarget))
return;
clearInventoryPlacementPreview(board);
});
board.addEventListener("drop", event => {
clearInventoryPlacementPreview(board);
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
if (!encoded)
return;
@ -1060,6 +1082,8 @@ function renderInventoryGrid(node, props, layout, style, options) {
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)));
if (!createInventoryPlacementPreview(source, props, node.children || [], x, y).valid)
return;
const intent = createInventoryDropIntent(source, props, x, y, options);
if (!intent)
return;
@ -1108,7 +1132,7 @@ function renderInventoryItem(node, props, layout, style, options) {
if (!item.draggable || !event.dataTransfer)
return;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/vnd.gamecult.eve.inventory-item+json", JSON.stringify(props));
event.dataTransfer.setData("application/vnd.gamecult.eve.inventory-item+json", JSON.stringify({ ...props, id: node.id }));
});
let widthCells = positiveInt(props.shapeWidth, 1);
let heightCells = positiveInt(props.shapeHeight, 1);
@ -1153,7 +1177,10 @@ export function createInventoryDropIntent(source, target, destinationX, destinat
const sourceIndex = numberProp(source.sourceIndex, -1);
const targetIndex = numberProp(target.targetIndex, -1);
const targetKind = firstString(target.targetKind, "");
const payload = {
const payload = {};
copyInventoryPayloadProps(source, payload);
copyInventoryPayloadProps(target, payload);
Object.assign(payload, {
sourceKind,
originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""),
originIndex: sourceIndex,
@ -1162,16 +1189,91 @@ export function createInventoryDropIntent(source, target, destinationX, destinat
quantity: Math.max(1, numberProp(source.quantity, 1)),
sourceX: numberProp(source.x, -2147483648),
sourceY: numberProp(source.y, -2147483648),
sourceRotation: firstString(source.rotation, "None"),
destinationKind: targetKind,
destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""),
destinationIndex: targetIndex,
destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1,
destinationX: Math.trunc(destinationX),
destinationY: Math.trunc(destinationY),
destinationRotation: firstString(target.dropRotation, source.rotation, "None"),
hasDestinationPosition: true,
};
});
return createEveCommandIntent(command, { action: payload }, options);
}
export function createInventoryPlacementPreview(source, target, targetChildren, destinationX, destinationY) {
const columns = Math.max(1, positiveInt(target.columns, 1));
const rows = Math.max(1, positiveInt(target.rows, 1));
const cells = inventoryItemCells(source).map(cell => ({
x: Math.trunc(destinationX) + cell.x,
y: Math.trunc(destinationY) + cell.y,
}));
const validCells = new Set(parseInventoryCells(target.validCells).map(cell => `${cell.x}:${cell.y}`));
const sourceId = firstString(source.id, "");
const occupied = new Set();
for (const child of targetChildren || []) {
if (child.kind !== "inventory.item" || (sourceId && child.id === sourceId))
continue;
const props = objectProps(child.props);
const x = Math.trunc(numberProp(props.x, 0));
const y = Math.trunc(numberProp(props.y, 0));
for (const cell of inventoryItemCells(props))
occupied.add(`${x + cell.x}:${y + cell.y}`);
}
let reason = "valid";
if (cells.some(cell => cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows)) {
reason = "outside-grid";
}
else if (validCells.size > 0 && cells.some(cell => !validCells.has(`${cell.x}:${cell.y}`))) {
reason = "outside-valid-shape";
}
else if (cells.some(cell => occupied.has(`${cell.x}:${cell.y}`))) {
reason = "occupied";
}
return { valid: reason === "valid", reason, cells };
}
function inventoryItemCells(item) {
const explicit = parseInventoryCells(item.shapeCells);
if (explicit.length > 0)
return explicit;
let width = Math.max(1, positiveInt(item.shapeWidth, 1));
let height = Math.max(1, positiveInt(item.shapeHeight, 1));
const rotation = firstString(item.rotation, "").toLowerCase();
if (["clockwise", "counterclockwise", "right", "left", "rotate90", "rotate270", "clockwise90", "clockwise270"].includes(rotation)) {
[width, height] = [height, width];
}
return Array.from({ length: width * height }, (_, index) => ({ x: index % width, y: Math.floor(index / width) }));
}
function parseInventoryCells(value) {
if (typeof value !== "string")
return [];
return value.split(";").flatMap(token => {
const [x, y] = token.split(",").map(Number);
return Number.isInteger(x) && Number.isInteger(y) ? [{ x, y }] : [];
});
}
function copyInventoryPayloadProps(source, payload) {
for (const [key, value] of Object.entries(source)) {
if (key.startsWith("payload.") && key.length > "payload.".length)
payload[key.slice("payload.".length)] = value;
}
}
function clearInventoryPlacementPreview(board) {
board.querySelectorAll(".cultui-inventory-placement-preview").forEach(element => element.remove());
}
function showInventoryPlacementPreview(board, source, target, targetChildren, x, y, cellSize) {
clearInventoryPlacementPreview(board);
const preview = createInventoryPlacementPreview(source, target, targetChildren, x, y);
for (const cell of preview.cells) {
const ghost = el("div", `cultui-inventory-placement-preview ${preview.valid ? "is-valid" : "is-invalid"}`);
ghost.style.gridColumn = String(cell.x + 1);
ghost.style.gridRow = String(cell.y + 1);
ghost.style.width = cssSize(String(cellSize));
ghost.style.height = cssSize(String(cellSize));
ghost.style.pointerEvents = "none";
board.append(ghost);
}
}
function renderInputBindingMap(node, props, options) {
const root = el("section", "cultui-binding-map");
assignId(root, node);

View file

@ -1,12 +1,12 @@
{
"name": "@gamecult/eve-browser-lowering",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gamecult/eve-browser-lowering",
"version": "0.1.0",
"version": "0.1.1",
"devDependencies": {
"@types/jsdom": "^30.0.0",
"jsdom": "^30.0.1",

View file

@ -1,6 +1,6 @@
{
"name": "@gamecult/eve-browser-lowering",
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View file

@ -1254,8 +1254,27 @@ function renderInventoryGrid(
if (!hasInventoryDropCommand(props)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
if (!encoded) return;
try {
const source = JSON.parse(encoded) as Record<string, unknown>;
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.floor((event.clientX - rect.left) / pitch));
const y = Math.max(0, Math.floor((event.clientY - rect.top) / pitch));
showInventoryPlacementPreview(board, source, props, node.children || [], x, y, cellSize);
} catch {
clearInventoryPlacementPreview(board);
}
});
board.addEventListener("dragleave", event => {
if (event.relatedTarget instanceof Node && board.contains(event.relatedTarget)) return;
clearInventoryPlacementPreview(board);
});
board.addEventListener("drop", event => {
clearInventoryPlacementPreview(board);
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
if (!encoded) return;
let source: Record<string, unknown>;
@ -1270,6 +1289,7 @@ function renderInventoryGrid(
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)));
if (!createInventoryPlacementPreview(source, props, node.children || [], x, y).valid) return;
const intent = createInventoryDropIntent(source, props, x, y, options);
if (!intent) return;
event.preventDefault();
@ -1324,7 +1344,7 @@ function renderInventoryItem(
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(
"application/vnd.gamecult.eve.inventory-item+json",
JSON.stringify(props),
JSON.stringify({ ...props, id: node.id }),
);
});
let widthCells = positiveInt(props.shapeWidth, 1);
@ -1379,7 +1399,10 @@ export function createInventoryDropIntent(
const sourceIndex = numberProp(source.sourceIndex, -1);
const targetIndex = numberProp(target.targetIndex, -1);
const targetKind = firstString(target.targetKind, "");
const payload: Record<string, unknown> = {
const payload: Record<string, unknown> = {};
copyInventoryPayloadProps(source, payload);
copyInventoryPayloadProps(target, payload);
Object.assign(payload, {
sourceKind,
originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""),
originIndex: sourceIndex,
@ -1388,17 +1411,118 @@ export function createInventoryDropIntent(
quantity: Math.max(1, numberProp(source.quantity, 1)),
sourceX: numberProp(source.x, -2147483648),
sourceY: numberProp(source.y, -2147483648),
sourceRotation: firstString(source.rotation, "None"),
destinationKind: targetKind,
destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""),
destinationIndex: targetIndex,
destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1,
destinationX: Math.trunc(destinationX),
destinationY: Math.trunc(destinationY),
destinationRotation: firstString(target.dropRotation, source.rotation, "None"),
hasDestinationPosition: true,
};
});
return createEveCommandIntent(command, { action: payload }, options);
}
export interface InventoryPlacementCell {
x: number;
y: number;
}
export interface InventoryPlacementPreview {
valid: boolean;
reason: "valid" | "outside-grid" | "outside-valid-shape" | "occupied";
cells: InventoryPlacementCell[];
}
export function createInventoryPlacementPreview(
source: Record<string, unknown>,
target: Record<string, unknown>,
targetChildren: EveSurfaceComponent[],
destinationX: number,
destinationY: number,
): InventoryPlacementPreview {
const columns = Math.max(1, positiveInt(target.columns, 1));
const rows = Math.max(1, positiveInt(target.rows, 1));
const cells = inventoryItemCells(source).map(cell => ({
x: Math.trunc(destinationX) + cell.x,
y: Math.trunc(destinationY) + cell.y,
}));
const validCells = new Set(parseInventoryCells(target.validCells).map(cell => `${cell.x}:${cell.y}`));
const sourceId = firstString(source.id, "");
const occupied = new Set<string>();
for (const child of targetChildren || []) {
if (child.kind !== "inventory.item" || (sourceId && child.id === sourceId)) continue;
const props = objectProps(child.props);
const x = Math.trunc(numberProp(props.x, 0));
const y = Math.trunc(numberProp(props.y, 0));
for (const cell of inventoryItemCells(props)) occupied.add(`${x + cell.x}:${y + cell.y}`);
}
let reason: InventoryPlacementPreview["reason"] = "valid";
if (cells.some(cell => cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows)) {
reason = "outside-grid";
} else if (validCells.size > 0 && cells.some(cell => !validCells.has(`${cell.x}:${cell.y}`))) {
reason = "outside-valid-shape";
} else if (cells.some(cell => occupied.has(`${cell.x}:${cell.y}`))) {
reason = "occupied";
}
return { valid: reason === "valid", reason, cells };
}
function inventoryItemCells(item: Record<string, unknown>): InventoryPlacementCell[] {
const explicit = parseInventoryCells(item.shapeCells);
if (explicit.length > 0) return explicit;
let width = Math.max(1, positiveInt(item.shapeWidth, 1));
let height = Math.max(1, positiveInt(item.shapeHeight, 1));
const rotation = firstString(item.rotation, "").toLowerCase();
if (["clockwise", "counterclockwise", "right", "left", "rotate90", "rotate270", "clockwise90", "clockwise270"].includes(rotation)) {
[width, height] = [height, width];
}
return Array.from({ length: width * height }, (_, index) => ({ x: index % width, y: Math.floor(index / width) }));
}
function parseInventoryCells(value: unknown): InventoryPlacementCell[] {
if (typeof value !== "string") return [];
return value.split(";").flatMap(token => {
const [x, y] = token.split(",").map(Number);
return Number.isInteger(x) && Number.isInteger(y) ? [{ x, y }] : [];
});
}
function copyInventoryPayloadProps(source: Record<string, unknown>, payload: Record<string, unknown>): void {
for (const [key, value] of Object.entries(source)) {
if (key.startsWith("payload.") && key.length > "payload.".length)
payload[key.slice("payload.".length)] = value;
}
}
function clearInventoryPlacementPreview(board: HTMLElement): void {
board.querySelectorAll(".cultui-inventory-placement-preview").forEach(element => element.remove());
}
function showInventoryPlacementPreview(
board: HTMLElement,
source: Record<string, unknown>,
target: Record<string, unknown>,
targetChildren: EveSurfaceComponent[],
x: number,
y: number,
cellSize: number,
): void {
clearInventoryPlacementPreview(board);
const preview = createInventoryPlacementPreview(source, target, targetChildren, x, y);
for (const cell of preview.cells) {
const ghost = el("div", `cultui-inventory-placement-preview ${preview.valid ? "is-valid" : "is-invalid"}`);
ghost.style.gridColumn = String(cell.x + 1);
ghost.style.gridRow = String(cell.y + 1);
ghost.style.width = cssSize(String(cellSize));
ghost.style.height = cssSize(String(cellSize));
ghost.style.pointerEvents = "none";
board.append(ghost);
}
}
function renderInputBindingMap(
node: EveSurfaceComponent,
props: Record<string, unknown>,

View file

@ -1086,6 +1086,22 @@ body[data-pixel-art="true"] .cultui-background {
padding: 14px;
}
.cultui-inventory-placement-preview {
z-index: 20;
border: 1px solid currentColor;
box-sizing: border-box;
}
.cultui-inventory-placement-preview.is-valid {
color: #45e6a8;
background: rgb(69 230 168 / 34%);
}
.cultui-inventory-placement-preview.is-invalid {
color: #ff5148;
background: rgb(255 81 72 / 38%);
}
@media (max-width: 900px) {
.cultui-graph {
right: 12px;

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, createInventoryDropIntent, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectSemanticListItems, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js";
import { applyEveStateBindingValue, createEveCommandIntent, createInventoryDropIntent, createInventoryPlacementPreview, 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 } };
@ -70,6 +70,8 @@ test("inventory drops select the provider-advertised operation and preserve spat
targetEntityKey: "zone.0.entity.1",
targetIndex: -1,
"dropCommand.cargo": "aetheria.daemon.commands.EquipItem",
"payload.shipId": "hangar.ship.1",
"payload.expectedHangarRevision": "42",
}, 7, 9, {
activeSurfaceId: "aetheria.refit",
clientId: "browser.inventory-test",
@ -83,6 +85,31 @@ test("inventory drops select the provider-advertised operation and preserve spat
assert.equal(intent.payload.destinationX, 7);
assert.equal(intent.payload.destinationY, 9);
assert.equal(intent.payload.hasDestinationPosition, true);
assert.equal(intent.payload.shipId, "hangar.ship.1");
assert.equal(intent.payload.expectedHangarRevision, "42");
});
test("inventory placement previews preserve irregular cells and reject occupancy", () => {
const source = { id: "moving", shapeCells: "0,0;1,0;0,1" };
const target = {
columns: 4,
rows: 3,
validCells: "0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1;0,2;1,2;2,2;3,2",
};
const children = [{
id: "installed",
kind: "inventory.item",
props: { x: 2, y: 1, shapeCells: "0,0" },
children: [],
}];
assert.deepEqual(createInventoryPlacementPreview(source, target, children, 0, 0), {
valid: true,
reason: "valid",
cells: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }],
});
assert.equal(createInventoryPlacementPreview(source, target, children, 1, 1).reason, "occupied");
assert.equal(createInventoryPlacementPreview(source, target, children, 3, 2).reason, "outside-grid");
});
test("inventory drops fail closed without an operation for the source kind", () => {

View file

@ -1,5 +1,10 @@
# Eve Surface Contract
Inventory surfaces may publish irregular `shapeCells`, optional grid
`validCells`, and `payload.*` fields. Lowerers use those fields for transient
placement previews and forward the namespaced payload through typed drop
operations; providers remain the final placement authority.
Eve owns this renderer-neutral package. It defines the retained CultUI surface
document, provider advertisement, asset catalog, input vocabulary, and builder
primitives shared by providers and lowerers. Headless daemons depend on this
@ -33,4 +38,3 @@ there.
EveUnity must not carry another live copy of this package. Its Unity Scene and
UI Toolkit packages depend on the released Eve-owned package.

View file

@ -14,6 +14,57 @@ namespace GameCult.Eve.Surface
public const string ItemKind = "inventory.item";
public const string DragSessionKind = "inventory.drag_session";
public static bool TryCreatePlacementPreview(
EveSurfaceComponent source,
EveSurfaceComponent target,
int destinationX,
int destinationY,
out EveInventoryPlacementPreview? preview)
{
preview = null;
if (source == null || target == null ||
!string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) ||
!string.Equals(target.Kind, GridKind, StringComparison.Ordinal))
{
return false;
}
var columns = Math.Max(1, Int(target.GetProp("columns"), 1));
var rows = Math.Max(1, Int(target.GetProp("rows"), 1));
var localCells = ItemCells(source);
var cells = localCells
.Select(cell => new EveInventoryCell(destinationX + cell.X, destinationY + cell.Y))
.ToArray();
var validCells = ParseCells(target.GetProp("validCells"));
var occupied = new HashSet<(int X, int Y)>();
foreach (var item in target.Children.Where(child =>
string.Equals(child.Kind, ItemKind, StringComparison.Ordinal) &&
!ReferenceEquals(child, source) &&
!string.Equals(child.Id, source.Id, StringComparison.Ordinal)))
{
var originX = Int(item.GetProp("x"), 0);
var originY = Int(item.GetProp("y"), 0);
foreach (var cell in ItemCells(item))
occupied.Add((originX + cell.X, originY + cell.Y));
}
var reason = "valid";
if (cells.Any(cell => cell.X < 0 || cell.Y < 0 || cell.X >= columns || cell.Y >= rows))
reason = "outside-grid";
else if (validCells.Count > 0 && cells.Any(cell => !validCells.Contains((cell.X, cell.Y))))
reason = "outside-valid-shape";
else if (cells.Any(cell => occupied.Contains((cell.X, cell.Y))))
reason = "occupied";
preview = new EveInventoryPlacementPreview(
string.Equals(reason, "valid", StringComparison.Ordinal),
reason,
destinationX,
destinationY,
cells);
return true;
}
public static bool TryCreateDropRequest(
EveSurfaceDocument document,
EveSurfaceComponent source,
@ -39,31 +90,34 @@ namespace GameCult.Eve.Surface
var sourceIndex = Int(source.GetProp("sourceIndex"), -1);
var targetIndex = Int(target.GetProp("targetIndex"), -1);
var targetKind = target.GetProp("targetKind");
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
["sourceKind"] = sourceKind,
["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")),
["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture),
["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1)
.ToString(CultureInfo.InvariantCulture),
["itemKey"] = source.GetProp("itemKey"),
["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture),
["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture),
["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture),
["destinationKind"] = targetKind,
["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")),
["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture),
["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1)
.ToString(CultureInfo.InvariantCulture),
["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture),
["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture),
["hasDestinationPosition"] = "true"
};
var payload = new Dictionary<string, string>(StringComparer.Ordinal);
CopyPayloadProps(source, payload);
CopyPayloadProps(target, payload);
payload["sourceKind"] = sourceKind;
payload["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey"));
payload["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture);
payload["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1)
.ToString(CultureInfo.InvariantCulture);
payload["itemKey"] = source.GetProp("itemKey");
payload["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture);
payload["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture);
payload["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture);
payload["sourceRotation"] = source.GetProp("rotation", "None");
payload["destinationKind"] = targetKind;
payload["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey"));
payload["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture);
payload["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1)
.ToString(CultureInfo.InvariantCulture);
payload["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture);
payload["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture);
payload["destinationRotation"] = target.GetProp("dropRotation", source.GetProp("rotation", "None"));
payload["hasDestinationPosition"] = "true";
var template = document.Commands.FirstOrDefault(candidate =>
string.Equals(candidate.Command, command, StringComparison.Ordinal));
var idempotencyKey = "eve-inventory-drop-" + Guid.NewGuid().ToString("N");
var operation = template == null
? CultMesh.OperationInvocation(command)
: CultMesh.OperationInvocation(template.Operation);
? CultMesh.OperationInvocation(command, idempotencyKey: idempotencyKey)
: CultMesh.OperationInvocation(template.Operation, idempotencyKey);
request = new EveSurfaceCommandRequest(
document.ProviderId,
document.Surface.Id,
@ -74,11 +128,96 @@ namespace GameCult.Eve.Surface
return true;
}
private static void CopyPayloadProps(
EveSurfaceComponent component,
IDictionary<string, string> payload)
{
const string prefix = "payload.";
foreach (var prop in component.Props)
{
if (!prop.Key.StartsWith(prefix, StringComparison.Ordinal) ||
prop.Key.Length == prefix.Length)
{
continue;
}
payload[prop.Key.Substring(prefix.Length)] = prop.Value ?? "";
}
}
private static int Int(string value, int fallback) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: fallback;
private static IReadOnlyList<EveInventoryCell> ItemCells(EveSurfaceComponent item)
{
var explicitCells = ParseCells(item.GetProp("shapeCells"));
if (explicitCells.Count > 0)
return explicitCells.Select(cell => new EveInventoryCell(cell.X, cell.Y)).ToArray();
var width = Math.Max(1, Int(item.GetProp("shapeWidth"), 1));
var height = Math.Max(1, Int(item.GetProp("shapeHeight"), 1));
var rotation = item.GetProp("rotation");
if (string.Equals(rotation, "Clockwise90", StringComparison.OrdinalIgnoreCase) ||
string.Equals(rotation, "Clockwise270", StringComparison.OrdinalIgnoreCase))
{
(width, height) = (height, width);
}
return Enumerable.Range(0, height)
.SelectMany(y => Enumerable.Range(0, width).Select(x => new EveInventoryCell(x, y)))
.ToArray();
}
private static HashSet<(int X, int Y)> ParseCells(string value)
{
var cells = new HashSet<(int X, int Y)>();
foreach (var token in (value ?? "").Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
{
var coordinates = token.Split(',');
if (coordinates.Length == 2 &&
int.TryParse(coordinates[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var x) &&
int.TryParse(coordinates[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var y))
{
cells.Add((x, y));
}
}
return cells;
}
}
public sealed class EveInventoryPlacementPreview
{
public EveInventoryPlacementPreview(
bool isValid,
string reason,
int destinationX,
int destinationY,
IReadOnlyList<EveInventoryCell> cells)
{
IsValid = isValid;
Reason = reason ?? "";
DestinationX = destinationX;
DestinationY = destinationY;
Cells = cells ?? Array.Empty<EveInventoryCell>();
}
public bool IsValid { get; }
public string Reason { get; }
public int DestinationX { get; }
public int DestinationY { get; }
public IReadOnlyList<EveInventoryCell> Cells { get; }
}
public readonly struct EveInventoryCell
{
public EveInventoryCell(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
}

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.surface",
"displayName": "Eve Surface Contract",
"version": "0.3.1",
"version": "0.3.2",
"unity": "2021.3",
"description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.",
"dependencies": {