diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index 95b85a3..cbb8b83 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -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, options?: EveBrowserLoweringOptions): EveCommandIntent; export declare function createInventoryDropIntent(source: Record, target: Record, 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, target: Record, 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, 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 e6f5454..cdd4e5c 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -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); diff --git a/packages/eve-browser-lowering/package-lock.json b/packages/eve-browser-lowering/package-lock.json index 10c1e35..adbc042 100644 --- a/packages/eve-browser-lowering/package-lock.json +++ b/packages/eve-browser-lowering/package-lock.json @@ -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", diff --git a/packages/eve-browser-lowering/package.json b/packages/eve-browser-lowering/package.json index c0eccf5..f9103ce 100644 --- a/packages/eve-browser-lowering/package.json +++ b/packages/eve-browser-lowering/package.json @@ -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", diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 24ae685..7234da2 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -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; + 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; @@ -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 = { + const payload: Record = {}; + 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, + target: Record, + 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(); + 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): 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, payload: Record): 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, + target: Record, + 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, diff --git a/packages/eve-browser-lowering/styles.css b/packages/eve-browser-lowering/styles.css index 038aef2..789b7fa 100644 --- a/packages/eve-browser-lowering/styles.css +++ b/packages/eve-browser-lowering/styles.css @@ -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; diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index 608ab91..b4cbb2d 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, 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", () => { diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md index 5138bbd..bd31b4d 100644 --- a/packages/org.gamecult.eve.surface/README.md +++ b/packages/org.gamecult.eve.surface/README.md @@ -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. - diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs index 21490a9..4ea1820 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs @@ -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(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(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 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 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 cells) + { + IsValid = isValid; + Reason = reason ?? ""; + DestinationX = destinationX; + DestinationY = destinationY; + Cells = cells ?? Array.Empty(); + } + + public bool IsValid { get; } + public string Reason { get; } + public int DestinationX { get; } + public int DestinationY { get; } + public IReadOnlyList Cells { get; } + } + + public readonly struct EveInventoryCell + { + public EveInventoryCell(int x, int y) + { + X = x; + Y = y; + } + + public int X { get; } + public int Y { get; } } } - - diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json index 110f784..c4b93b8 100644 --- a/packages/org.gamecult.eve.surface/package.json +++ b/packages/org.gamecult.eve.surface/package.json @@ -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": {