From f4f0f0fcee4616357653d9d92dc0852bee4c1745 Mon Sep 17 00:00:00 2001 From: Haxxer Date: Sun, 25 Aug 2024 01:02:41 +0100 Subject: [PATCH] API improvements, bug fixes --- changelog.md | 1 + docs/api.md | 141 ++++++++++-------- src/API/api.js | 65 ++++++++ src/API/private-api.js | 92 ++++++++++++ .../item-pile-inventory-shell.svelte | 2 +- src/socket.js | 5 + 6 files changed, 246 insertions(+), 60 deletions(-) diff --git a/changelog.md b/changelog.md index d5df5f23..c293a853 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,7 @@ - Added tree-like display for items in containers - Added support for currency exchange in custom item purchase prices - If you have configured an item that costs 1 gold piece and 1 magical rock, and you only have 1 platinum piece and 1 magical rock, you now get 9 gold pieces back as change, whereas before you needed exactly 1 gold piece and 1 magical rock. +- Added `game.itempiles.API.combineItemPiles` which allows you to combine several item piles' inventory into a single item pile - Added option for a custom sell price on items - Added detection for when the GM is unresponsive for item piles to make changes for players - Fixed localization issue with `ITEM-PILES.Trade`, now moved to `ITEM-PILES.PlayerList.TradeButton` diff --git a/docs/api.md b/docs/api.md index 27115e7a..597e02cc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,6 +68,7 @@ * [transferAttributes](#transferAttributes) * [transferAllAttributes](#transferAllAttributes) * [transferEverything](#transferEverything) + * [combineItemPiles](#combineItemPiles) * [updateCurrencies](#updateCurrencies) * [addCurrencies](#addCurrencies) * [removeCurrencies](#removeCurrencies) @@ -522,10 +523,10 @@ Causes the item pile to play a sound as it was attempted to be opened, but was l Whether an item pile is locked. If it is not enabled or not a container, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -535,10 +536,10 @@ Whether an item pile is locked. If it is not enabled or not a container, it is a Whether an item pile is closed. If it is not enabled or not a container, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -548,10 +549,10 @@ Whether an item pile is closed. If it is not enabled or not a container, it is a Whether an item pile is a valid item pile. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -561,10 +562,10 @@ Whether an item pile is a valid item pile. If it is not enabled, it is always fa Whether an item pile is a regular item pile. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -574,10 +575,10 @@ Whether an item pile is a regular item pile. If it is not enabled, it is always Whether an item pile is a container. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -587,10 +588,10 @@ Whether an item pile is a container. If it is not enabled, it is always false. Whether an item pile is a lootable. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -600,10 +601,10 @@ Whether an item pile is a lootable. If it is not enabled, it is always false. Whether an item pile is a vault. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -613,10 +614,10 @@ Whether an item pile is a vault. If it is not enabled, it is always false. Whether an item pile is a merchant. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -626,10 +627,10 @@ Whether an item pile is a merchant. If it is not enabled, it is always false. Whether an item pile is a auctioneer. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | -| [data] | `Object/boolean` | `false` | Existing data flags to use | +| Param | Type | Default | Description | +|--------|-----------------------|---------|----------------------------| +| target | `Token/TokenDocument` | | Target token to check | +| [data] | `Object/boolean` | `false` | Existing data flags to use | --- @@ -639,13 +640,12 @@ Whether an item pile is a auctioneer. If it is not enabled, it is always false. Whether an item pile is a empty pile. If it is not enabled, it is always false. -| Param | Type | Default | Description | -|--------------------|-------------------------------|---------|-------------------------------| -| target | `Token/TokenDocument` | | Target token to check | +| Param | Type | Default | Description | +|--------|-----------------------|---------|-----------------------| +| target | `Token/TokenDocument` | | Target token to check | --- - ### updateItemPile `game.itempiles.API.updateItemPile(target, newData, options)` ⇒ `Promise` @@ -951,13 +951,33 @@ Transfers all items and attributes between the source and the target. **Returns**: `Promise` - An object containing all items and attributes transferred to the target -| Param | Type | Default | Description | -|-------------------------|-----------------------------|---------|-----------------------------------------------------------------------------------| -| source | `Actor/Token/TokenDocument` | | The actor to transfer all items and attributes from | -| target | `Actor/Token/TokenDocument` | | The actor to receive all the items and attributes | -| options | `object` | | Options to pass to the function | -| [options.itemFilters] | `Array/boolean` | `false` | Array of item types disallowed - will default to module settings if none provided | -| [options.interactionId] | `string/boolean` | `false` | The ID of this interaction | +| Param | Type | Default | Description | +|----------------------------|-----------------------------|---------|-----------------------------------------------------------------------------------| +| source | `Actor/Token/TokenDocument` | | The actor to transfer all items and attributes from | +| target | `Actor/Token/TokenDocument` | | The actor to receive all the items and attributes | +| options | `object` | | Options to pass to the function | +| [options.itemFilters] | `Array/boolean` | `false` | Array of item types disallowed - will default to module settings if none provided | +| [options.skipVaultLogging] | `boolean` | `false` | Whether to skip logging this action to the target actor if it is a vault | +| [options.interactionId] | `string/boolean` | `false` | The ID of this interaction | + +--- + +### combineItemPiles + +`game.itempiles.API.combineItemPiles(target, sources, options)` ⇒ `Promise` + +Transfers all items and attributes between the source and the target. + +**Returns**: `Promise` - An object containing all items and attributes transferred to the target + +| Param | Type | Default | Description | +|-------------------------------|-----------------------------|---------|-----------------------------------------------------------------------------------| +| target | `Actor/Token/TokenDocument` | | The actor to transfer items and currencies to | +| sources | `Actor/Token/TokenDocument` | | The actors to transfer items and currencies from | +| options | `object` | | Options to pass to the function | +| [options.itemFilters] | `Array/boolean` | `false` | Array of item types disallowed - will default to module settings if none provided | +| [options.targetItemPileFlags] | `object/boolean` | `false` | Item pile flags to set on the target as a part of this transfer | +| [options.interactionId] | `string/boolean` | `false` | The ID of this interaction | --- @@ -965,7 +985,7 @@ Transfers all items and attributes between the source and the target. `game.itempiles.API.updateCurrencies(target, currencies, options)` ⇒ `Promise` -Updates currencies to the target. +Updates currencies to the target. It differs from the add and remove operations in that it "sets" the currency of the target with the passed value. This is useful in cases where you want to pre-fill something with a specific amount of currency. @@ -1108,9 +1128,11 @@ Transfers all currencies between the source and the target. Turns an array containing the data and quantities for each currency into a string of currencies -**NOTE:** This is just a utility method for module intercompatibility to use the other currencies api methods based on a string input. +**NOTE: +** This is just a utility method for module intercompatibility to use the other currencies api methods based on a string input. -**Returns**: `Array.` - An array of string containing the abbreviation for each currency registered (eg, ["GP","SP"]) +**Returns +**: `Array.` - An array of string containing the abbreviation for each currency registered (eg, ["GP","SP"]) --- @@ -1120,16 +1142,17 @@ Turns an array containing the data and quantities for each currency into a strin Turns an array containing the data and quantities for each currency into a string of currencies -**NOTE:** This is just a utility method for module intercompatibility to use the other currencies api methods based on a string input. +**NOTE: +** This is just a utility method for module intercompatibility to use the other currencies api methods based on a string input. **Returns**: `string` - A string of currencies to add (eg, "5gp 25sp") -| Param | Type | Default | Description | -|---------------------------|----------|---------|------------------------------------------------| -| currencies | `Array` | | An array of object containing the data and quantity for each currency | -| currencies[].cost | `number` | | The quantity of the currency | -| currencies[].abbreviation | `string` | | The abbreviation of the currency, which are usually {#}GP, which when {#} is replaced with the number it becomes 5GP. | -| currencies[].percent | `boolean`| | The cost of the currency is in percentage (NOTE: for work the 'abbreviation' property must includes '%' substring) | +| Param | Type | Default | Description | +|---------------------------|-----------|---------|-----------------------------------------------------------------------------------------------------------------------| +| currencies | `Array` | | An array of object containing the data and quantity for each currency | +| currencies[].cost | `number` | | The quantity of the currency | +| currencies[].abbreviation | `string` | | The abbreviation of the currency, which are usually {#}GP, which when {#} is replaced with the number it becomes 5GP. | +| currencies[].percent | `boolean` | | The cost of the currency is in percentage (NOTE: for work the 'abbreviation' property must includes '%' substring) | --- @@ -1189,8 +1212,8 @@ Turns a string of currencies or a number into an object containing payment data, const tokenOrActor = game.actors.getName("Bharash"); const currencies = { - cost: 10, - abbreviation: "GP", + cost: 10, + abbreviation: "GP", }; const currencyS = game.itempiles.API.getStringFromCurrencies(currencies); const currencyData = game.itempiles.API.getPaymentData(currencyS, { target: tokenOrActor }); diff --git a/src/API/api.js b/src/API/api.js index e0890594..60e07f2b 100644 --- a/src/API/api.js +++ b/src/API/api.js @@ -1831,6 +1831,71 @@ class API { } + /** + * Combines several item piles into a single item pile by transferring + * + * @param {Actor/Token/TokenDocument} target The actor to transfer items and currencies to + * @param {Array} sources The actors to transfer items and currencies from + * @param {object} options Options to pass to the function + * @param {Array/boolean} [options.itemFilters=false] Array of item types disallowed - will default to module settings if none provided + * @param {object/boolean} [options.targetItemPileFlags=false] Item pile flags to set on the target as a part of this transfer + * @param {string/boolean} [options.interactionId=false] The ID of this interaction + * + * @returns {Promise} An object containing all items and attributes transferred to the target + */ + static combineItemPiles(target, sources, { + itemFilters = false, + targetItemPileFlags = false, + interactionId = false + } = {}) { + + const targetActor = Utilities.getActor(target); + if (!targetActor) throw Helpers.custom_error(`combineItemPiles | Could not determine the target actor, please provide a valid target`); + const targetUuid = Utilities.getUuid(targetActor); + + if (!Array.isArray(sources)) { + try { + sources = Array.from(sources); + } catch (err) { + throw Helpers.custom_error(`combineItemPiles | sources must be of type array or set`); + } + } + + const sourceActors = sources.map(Utilities.getActor); + if (!sourceActors.every(Boolean) || !sourceActors.length) throw Helpers.custom_error(`combineItemPiles | Could not determine one of the source actors, please provide valid sources`); + if (!sourceActors.every(actor => PileUtilities.isValidItemPile(actor))) throw Helpers.custom_error(`combineItemPiles | One or more of the source actors are not item piles`); + const sourceUuids = sourceActors.map(Utilities.getUuid); + + if (sourceActors.find(actor => actor === targetActor)) { + throw Helpers.custom_error(`combineItemPiles | Sources may not contain the target`); + } + + if (itemFilters) { + if (!Array.isArray(itemFilters)) throw Helpers.custom_error(`combineItemPiles | itemFilters must be of type array`); + itemFilters.forEach(entry => { + if (typeof entry?.path !== "string") throw Helpers.custom_error(`combineItemPiles | each entry in the itemFilters must have a "path" property that is of type string`); + if (typeof entry?.filter !== "string") throw Helpers.custom_error(`combineItemPiles | each entry in the itemFilters must have a "filter" property that is of type string`); + }) + } + + if (targetItemPileFlags && typeof targetItemPileFlags !== "object") throw Helpers.custom_error(`combineItemPiles | options.targetItemPileFlags must be of type object`); + + if (PileUtilities.isItemPileVault(targetActor)) { + const sourceActorItems = sourceActors.map(sourceActor => PileUtilities.getActorItems(sourceActor, { getItemCurrencies: true })); + const canItemsFit = PileUtilities.fitItemsIntoVault(sourceActorItems, targetActor, { itemFilters }); + if (!canItemsFit) throw Helpers.custom_error(`combineItemPiles | The target vault actor ${targetActor.name} cannot fit these items`); + } + + if (interactionId) { + if (typeof interactionId !== "string") throw Helpers.custom_error(`combineItemPiles | interactionId must be of type string`); + } + + return ItemPileSocket.executeAsGM(ItemPileSocket.HANDLERS.COMBINE_ITEM_PILES, targetUuid, sourceUuids, game.user.id, { + itemFilters, interactionId + }); + + } + /** * Return all th registered currencies abbreviations * @returns {Array} An array of string containing the abbreviation for each currency registered diff --git a/src/API/private-api.js b/src/API/private-api.js index da70a1a6..c551a222 100644 --- a/src/API/private-api.js +++ b/src/API/private-api.js @@ -1010,6 +1010,98 @@ export default class PrivateAPI { } + static async _combineItemPiles(targetUuid, sourceUuids, userId, { + itemFilters = false, + targetItemPileFlags = false, + interactionId + } = {}) { + + const sourceActors = sourceUuids.map(Utilities.getActor); + const targetActor = Utilities.getActor(targetUuid); + + const targetTransaction = new Transaction(targetActor); + + const sourceTransactions = []; + for (const sourceActor of sourceActors) { + + const itemsToTransfer = PileUtilities.getActorItems(sourceActor, { itemFilters }) + .map(item => item.toObject()); + + const sourceCurrencies = PileUtilities.getActorCurrencies(sourceActor); + + const itemCurrenciesToTransfer = sourceCurrencies + .filter(currency => currency.type === "item") + .map(currency => ({ id: currency.id, quantity: currency.quantity })); + + const attributesToTransfer = sourceCurrencies + .filter(entry => entry.type === "attribute") + .map(currency => ({ path: currency.data.path, quantity: currency.quantity })); + + const sourceTransaction = new Transaction(sourceActor); + await sourceTransaction.appendItemChanges(itemsToTransfer, { remove: true }); + await sourceTransaction.appendItemChanges(itemCurrenciesToTransfer, { + remove: true, type: "currency" + }); + await sourceTransaction.appendDocumentChanges(attributesToTransfer, { remove: true, type: "currency" }); + const sourceUpdates = sourceTransaction.prepare(); + + await targetTransaction.appendItemChanges(sourceUpdates.itemDeltas); + await targetTransaction.appendDocumentChanges(sourceUpdates.attributeDeltas); + + sourceTransactions.push({ transaction: sourceTransaction, updates: sourceUpdates }); + + } + + const targetUpdates = targetTransaction.prepare(); + const sourceUpdates = sourceTransactions.map(data => data.updates) + + const hookResult = Helpers.hooks.call(CONSTANTS.HOOKS.PRE_TRANSFER_EVERYTHING, sourceActors, sourceUpdates, targetActor, targetUpdates, interactionId); + if (hookResult === false) return false; + + await Promise.allSettled(sourceTransactions.map(data => data.transaction.commit())); + const { itemDeltas, attributeDeltas } = await targetTransaction.commit(); + + if (targetItemPileFlags) { + const flags = PileUtilities.cleanFlagData(foundry.utils.mergeObject(CONSTANTS.PILE_DEFAULTS, targetItemPileFlags)); + await PileUtilities.updateItemPileData(targetActor, flags); + } + + await ItemPileSocket.executeForEveryone(ItemPileSocket.HANDLERS.CALL_HOOK, CONSTANTS.HOOKS.TRANSFER_EVERYTHING, sourceUuids, targetUuid, itemDeltas, attributeDeltas, userId, interactionId); + + const macroData = { + action: CONSTANTS.MACRO_EXECUTION_TYPES.TRANSFER_EVERYTHING, + sources: sourceUuids, + target: targetUuid, + items: itemDeltas, + attributes: attributeDeltas, + userId: userId, + interactionId: interactionId + }; + await Promise.allSettled(sourceUuids.map(uuid => this._executeItemPileMacro(uuid, { ...macroData, source: uuid }))); + await this._executeItemPileMacro(targetUuid, { ...macroData, source: false }); + + const tokensToDelete = sourceUuids + .filter(PileUtilities.shouldItemPileBeDeleted) + .map(Utilities.getToken) + .map(Utilities.getDocument) + .filter(Boolean) + .reduce((acc, doc) => { + acc[doc.parent.id] ??= []; + acc[doc.parent.id].push(doc.id); + return acc; + }, {}); + + for (const [sceneId, tokenIds] of Object.entries(tokensToDelete)) { + const scene = game.scenes.get(sceneId); + if (scene) await scene.deleteEmbeddedDocuments("Token", tokenIds); + } + + return { + itemsTransferred: itemDeltas, attributesTransferred: attributeDeltas, deletedTokens: tokensToDelete + }; + + } + static async _commitDocumentChanges(documentUuid, { documentChanges = {}, itemsToUpdate = [], itemsToDelete = [], itemsToCreate = [] } = {}) { diff --git a/src/applications/item-pile-inventory-app/item-pile-inventory-shell.svelte b/src/applications/item-pile-inventory-app/item-pile-inventory-shell.svelte index 79815333..1f5303ae 100644 --- a/src/applications/item-pile-inventory-app/item-pile-inventory-shell.svelte +++ b/src/applications/item-pile-inventory-app/item-pile-inventory-shell.svelte @@ -91,7 +91,7 @@ source: source, target: store.actor, itemData: { - item: itemData, quantity: 1 + item: itemData, quantity: 1, uuid: data.uuid }, skipCheck: true }); diff --git a/src/socket.js b/src/socket.js index 71c3a5aa..f723d859 100644 --- a/src/socket.js +++ b/src/socket.js @@ -67,6 +67,7 @@ export default class ItemPileSocket { TRANSFER_ATTRIBUTES: "transferAttributes", TRANSFER_ALL_ATTRIBUTES: "transferAllAttributes", TRANSFER_EVERYTHING: "transferEverything", + COMBINE_ITEM_PILES: "combineItemPiles", COMMIT_DOCUMENT_CHANGES: "commitActorChanges", ROLL_ITEM_TABLE: "rollItemTable", REFRESH_MERCHANT_INVENTORY: "refreshMerchantInventory", @@ -120,6 +121,7 @@ export default class ItemPileSocket { [this.HANDLERS.TRANSFER_ATTRIBUTES]: (...args) => PrivateAPI._transferAttributes(...args), [this.HANDLERS.TRANSFER_ALL_ATTRIBUTES]: (...args) => PrivateAPI._transferAllAttributes(...args), [this.HANDLERS.TRANSFER_EVERYTHING]: (...args) => PrivateAPI._transferEverything(...args), + [this.HANDLERS.COMBINE_ITEM_PILES]: (...args) => PrivateAPI._combineItemPiles(...args), [this.HANDLERS.COMMIT_DOCUMENT_CHANGES]: (...args) => PrivateAPI._commitDocumentChanges(...args), [this.HANDLERS.ROLL_ITEM_TABLE]: (...args) => PrivateAPI._rollItemTable(...args), [this.HANDLERS.REFRESH_MERCHANT_INVENTORY]: (...args) => PrivateAPI._refreshMerchantInventory(...args), @@ -228,6 +230,8 @@ const Requests = { _defaultTimeout: 2000, _unresponsiveTimeout: 10000, async timedSocketRequest(handler, method) { + const activeGM = Helpers.getResponsibleGM(); + if (activeGM === game.user) return method(); if (Requests._unresponsiveGM && Number(Date.now()) < Requests._lastGmUnresponsiveTimestamp) { Helpers.custom_warning(game.i18n.format("ITEM-PILES.Warnings.NoResponseFromGMTimeout", { user_name: Requests._unresponsiveGM, @@ -241,6 +245,7 @@ const Requests = { result = await method(); } catch (err) { Requests._clearPendingTimeout(handler); + console.error(err); return false; } Requests._clearPendingTimeout(handler);