|
/*
|
|
* This file is part of the warpgate module (https://github.com/trioderegion/warpgate)
|
|
* Copyright (c) 2021 Matthew Haentschke.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, version 3.
|
|
*
|
|
* This program is distributed in the hope that it will be useful, but
|
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
* General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {logger} from './logger.js'
|
|
import {MODULE} from './module.js'
|
|
import {RemoteMutator} from './remote-mutator.js'
|
|
|
|
const NAME = "Mutator";
|
|
|
|
/** @typedef {import('./api.js').ComparisonKeys} ComparisonKeys */
|
|
/** @typedef {import('./api.js').NoticeConfig} NoticeConfig */
|
|
/** @typedef {import('./mutation-stack.js').MutationData} MutationData */
|
|
/** @typedef {import('./api.js').Shorthand} Shorthand */
|
|
/** @typedef {import('./api.js').SpawningOptions} SpawningOptions */
|
|
|
|
//TODO proper objects
|
|
/** @typedef {Object} MutateInfo **/
|
|
|
|
/**
|
|
* Workflow options
|
|
* @typedef {Object} WorkflowOptions
|
|
* @property {Shorthand} [updateOpts] Options for the creation/deletion/updating of (embedded) documents related to this mutation
|
|
* @property {string} [description] Description of this mutation for potential display to the remote owning user.
|
|
* @property {NoticeConfig} [notice] Options for placing a ping or panning to the token after mutation
|
|
* @property {boolean} [noMoveWait = false] If true, will not wait for potential token movement animation to complete before proceeding with remaining actor/embedded updates.
|
|
* @property {Object} [overrides]
|
|
* @property {boolean} [overrides.alwaysAccept = false] Force the receiving clients "auto-accept" state,
|
|
* regardless of world/client settings
|
|
* @property {boolean} [overrides.suppressToast = false] Force the initiating and receiving clients to suppress
|
|
* the "call and response" UI toasts indicating the requests accepted/rejected response.
|
|
* @property {boolean} [overrides.includeRawData = false] Force events produced from this operation to include the
|
|
* raw data used for its operation (such as the final mutation data to be applied, or the resulting packed actor
|
|
* data from a spawn). **Caution, use judiciously** -- enabling this option can result in potentially large
|
|
* socket data transfers during warpgate operation.
|
|
* @property {boolean} [overrides.preserveData = false] If enabled, the provided updates data object will
|
|
* be modified in-place as needed for internal Warp Gate operations and will NOT be re-usable for a
|
|
* subsequent operation. Otherwise, the provided data is copied and modified internally, preserving
|
|
* the original input for subsequent re-use.
|
|
*
|
|
*/
|
|
|
|
/**
|
|
*
|
|
* @typedef {Object} MutationOptions
|
|
* @property {boolean} [permanent=false] Indicates if this should be treated as a permanent change
|
|
* to the actor, which does not store the update delta information required to revert mutation.
|
|
* @property {string} [name=randomId()] User provided name, or identifier, for this particular
|
|
* mutation operation. Used for reverting mutations by name, as opposed to popping last applied.
|
|
* @property {Object} [delta]
|
|
* @property {ComparisonKeys} [comparisonKeys]
|
|
*/
|
|
|
|
/**
|
|
* The post delta creation, pre mutate callback. Called after the update delta has been generated, but before
|
|
* it is stored on the actor. Can be used to modify this delta for storage (ex. Current and Max HP are
|
|
* increased by 10, but when reverted, you want to keep the extra Current HP applied. Update the delta object
|
|
* with the desired HP to return to after revert, or remove it entirely.
|
|
*
|
|
* @typedef {(function(Shorthand,TokenDocument):Promise|undefined)} PostDelta
|
|
* @param {Shorthand} delta Computed change of the actor based on `updates`. Used to "unroll" this mutation when reverted.
|
|
* @param {TokenDocument} tokenDoc Token being modified.
|
|
*
|
|
* @returns {Promise<any>|any}
|
|
*/
|
|
|
|
/**
|
|
* The post mutate callback prototype. Called after the actor has been mutated and after the mutate event
|
|
* has triggered. Useful for animations or changes that should not be tracked by the mutation system.
|
|
*
|
|
* @typedef {function(TokenDocument, Object, boolean):Promise|void} PostMutate
|
|
* @param {TokenDocument} tokenDoc Token that has been modified.
|
|
* @param {Shorthand} updates Current permutation of the original shorthand updates object provided, as
|
|
* applied for this mutation
|
|
* @param {boolean} accepted Whether or not the mutation was accepted by the first owner.
|
|
*
|
|
* @returns {Promise<any>|any}
|
|
*/
|
|
|
|
export class Mutator {
|
|
static register() {
|
|
Mutator.defaults();
|
|
Mutator.hooks();
|
|
}
|
|
|
|
static defaults(){
|
|
MODULE[NAME] = {
|
|
comparisonKey: 'name'
|
|
}
|
|
}
|
|
|
|
static hooks() {
|
|
if(!MODULE.isV10) Hooks.on('preUpdateToken', Mutator._correctActorLink)
|
|
}
|
|
|
|
static _correctActorLink(tokenDoc, update) {
|
|
|
|
/* if the actorId has been updated AND its being set to null,
|
|
* check if we can patch/fix this warpgate spawn
|
|
*/
|
|
if (update.hasOwnProperty('actorId') && update.actorId === null) {
|
|
const sourceActorId = tokenDoc.getFlag(MODULE.data.name, 'sourceActorId') ?? false;
|
|
if (sourceActorId) {
|
|
logger.debug(`Detected spawned token with unowned actor ${sourceActorId}. Correcting token update.`, tokenDoc, update);
|
|
update.actorId = sourceActorId;
|
|
}
|
|
}
|
|
}
|
|
|
|
static #idByQuery( list, key, comparisonPath ) {
|
|
const id = this.#findByQuery(list, key, comparisonPath)?.id ?? null;
|
|
|
|
return id;
|
|
}
|
|
|
|
static #findByQuery( list, key, comparisonPath ) {
|
|
return list.find( element => getProperty(MODULE.isV10 ? element : element.data, comparisonPath) === key )
|
|
}
|
|
|
|
//TODO change to reduce
|
|
static _parseUpdateShorthand(collection, updates, comparisonKey) {
|
|
let parsedUpdates = Object.keys(updates).map((key) => {
|
|
if (updates[key] === warpgate.CONST.DELETE) return { _id: null };
|
|
const _id = this.#idByQuery(collection, key, comparisonKey )
|
|
return {
|
|
...updates[key],
|
|
_id,
|
|
}
|
|
});
|
|
parsedUpdates = parsedUpdates.filter( update => !!update._id);
|
|
return parsedUpdates;
|
|
}
|
|
|
|
//TODO change to reduce
|
|
static _parseDeleteShorthand(collection, updates, comparisonKey) {
|
|
let parsedUpdates = Object.keys(updates).map((key) => {
|
|
if (updates[key] !== warpgate.CONST.DELETE) return null;
|
|
return this.#idByQuery(collection, key, comparisonKey);
|
|
});
|
|
|
|
parsedUpdates = parsedUpdates.filter( update => !!update);
|
|
return parsedUpdates;
|
|
}
|
|
|
|
static _parseAddShorthand(collection, updates, comparisonKey){
|
|
|
|
let parsedAdds = Object.keys(updates).reduce((acc, key) => {
|
|
|
|
/* ignore deletes */
|
|
if (updates[key] === warpgate.CONST.DELETE) return acc;
|
|
|
|
/* ignore item updates for items that exist */
|
|
if (this.#idByQuery(collection, key, comparisonKey)) return acc;
|
|
|
|
let data = updates[key];
|
|
setProperty(data, comparisonKey, key);
|
|
acc.push(data);
|
|
return acc;
|
|
},[]);
|
|
|
|
return parsedAdds;
|
|
|
|
}
|
|
|
|
static _invertShorthand(collection, updates, comparisonKey){
|
|
let inverted = {};
|
|
Object.keys(updates).forEach( (key) => {
|
|
|
|
/* find this item currently and copy off its data */
|
|
const currentData = this.#findByQuery(collection, key, comparisonKey);
|
|
|
|
/* this is a delete */
|
|
if (updates[key] === warpgate.CONST.DELETE) {
|
|
|
|
/* hopefully we found something */
|
|
if(currentData) setProperty(inverted, key, currentData.toObject());
|
|
else logger.debug('Delta Creation: Could not locate shorthand identified document for deletion.', collection, key, updates[key]);
|
|
|
|
return;
|
|
}
|
|
|
|
/* this is an update */
|
|
if (currentData){
|
|
/* grab the current value of any updated fields and store */
|
|
const expandedUpdate = expandObject(updates[key]);
|
|
const sourceData = currentData.toObject();
|
|
const updatedData = mergeObject(sourceData, expandedUpdate, {inplace: false});
|
|
|
|
const diff = MODULE.strictUpdateDiff(updatedData, sourceData);
|
|
|
|
setProperty(inverted, updatedData[comparisonKey], diff);
|
|
return;
|
|
}
|
|
|
|
/* must be an add, so we delete */
|
|
setProperty(inverted, key, warpgate.CONST.DELETE);
|
|
|
|
});
|
|
|
|
return inverted;
|
|
}
|
|
|
|
|
|
|
|
static _errorCheckEmbeddedUpdates( embeddedName, updates ) {
|
|
|
|
/* at the moment, the most pressing error is an Item creation without a 'type' field.
|
|
* This typically indicates a failed lookup for an update operation
|
|
*/
|
|
if( embeddedName == 'Item'){
|
|
const badItemAdd = (updates.add ?? []).find( add => !add.type );
|
|
|
|
if (badItemAdd) {
|
|
logger.info(badItemAdd);
|
|
const message = MODULE.format('error.badMutate.missing.type', {embeddedName});
|
|
|
|
return {error: true, message}
|
|
}
|
|
}
|
|
|
|
return {error:false};
|
|
}
|
|
|
|
/* run the provided updates for the given embedded collection name from the owner */
|
|
static async _performEmbeddedUpdates(owner, embeddedName, updates, comparisonKey = 'name', updateOpts = {}){
|
|
|
|
const collection = owner.getEmbeddedCollection(embeddedName);
|
|
|
|
const parsedAdds = Mutator._parseAddShorthand(collection, updates, comparisonKey);
|
|
const parsedUpdates = Mutator._parseUpdateShorthand(collection, updates, comparisonKey);
|
|
const parsedDeletes = Mutator._parseDeleteShorthand(collection, updates, comparisonKey);
|
|
|
|
logger.debug(`Modify embedded ${embeddedName} of ${owner.name} from`, {adds: parsedAdds, updates: parsedUpdates, deletes: parsedDeletes});
|
|
|
|
const {error, message} = Mutator._errorCheckEmbeddedUpdates( embeddedName, {add: parsedAdds, update: parsedUpdates, delete: parsedDeletes} );
|
|
if(error) {
|
|
logger.error(message);
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (parsedAdds.length > 0) await owner.createEmbeddedDocuments(embeddedName, parsedAdds, updateOpts);
|
|
} catch (e) {
|
|
logger.error(e);
|
|
}
|
|
|
|
try {
|
|
if (parsedUpdates.length > 0) await owner.updateEmbeddedDocuments(embeddedName, parsedUpdates, updateOpts);
|
|
} catch (e) {
|
|
logger.error(e);
|
|
}
|
|
|
|
try {
|
|
if (parsedDeletes.length > 0) await owner.deleteEmbeddedDocuments(embeddedName, parsedDeletes, updateOpts);
|
|
} catch (e) {
|
|
logger.error(e);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/* embeddedUpdates keyed by embedded name, contains shorthand */
|
|
static async _updateEmbedded(owner, embeddedUpdates, comparisonKeys, updateOpts = {}){
|
|
|
|
/* @TODO check for any recursive embeds*/
|
|
if (embeddedUpdates?.embedded) delete embeddedUpdates.embedded;
|
|
|
|
for(const embeddedName of Object.keys(embeddedUpdates ?? {})){
|
|
await Mutator._performEmbeddedUpdates(owner, embeddedName, embeddedUpdates[embeddedName],
|
|
comparisonKeys[embeddedName] ?? MODULE[NAME].comparisonKey,
|
|
updateOpts[embeddedName] ?? {})
|
|
}
|
|
|
|
}
|
|
|
|
/* updates the actor and any embedded documents of this actor */
|
|
/* @TODO support embedded documents within embedded documents */
|
|
static async _updateActor(actor, updates = {}, comparisonKeys = {}, updateOpts = {}) {
|
|
|
|
logger.debug('Performing update on (actor/updates)',actor, updates, comparisonKeys, updateOpts);
|
|
await warpgate.wait(MODULE.setting('updateDelay')); // @workaround for semaphore bug
|
|
|
|
/** perform the updates */
|
|
if (updates.actor) await actor.update(updates.actor, updateOpts.actor ?? {});
|
|
|
|
await Mutator._updateEmbedded(actor, updates.embedded, comparisonKeys, updateOpts.embedded);
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
/**
|
|
* Given an update argument identical to `warpgate.spawn` and a token document, will apply the changes listed
|
|
* in the updates and (by default) store the change delta, which allows these updates to be reverted. Mutating
|
|
* the same token multiple times will "stack" the delta changes, allowing the user to remove them as desired,
|
|
* while preserving changes made "higher" in the stack.
|
|
*
|
|
* @param {TokenDocument} tokenDoc Token document to update, does not accept Token Placeable.
|
|
* @param {Shorthand} [updates] As {@link warpgate.spawn}
|
|
* @param {Object} [callbacks] Two provided callback locations: delta and post. Both are awaited.
|
|
* @param {PostDelta} [callbacks.delta]
|
|
* @param {PostMutate} [callbacks.post]
|
|
* @param {WorkflowOptions & MutationOptions} [options]
|
|
*
|
|
* @return {Promise<MutationData|false>} The mutation stack entry produced by this mutation, if they are tracked (i.e. not permanent).
|
|
*/
|
|
static async mutate(tokenDoc, updates = {}, callbacks = {}, options = {}) {
|
|
|
|
const neededPerms = MODULE.canMutate(game.user)
|
|
if(neededPerms.length > 0) {
|
|
logger.warn(MODULE.format('error.missingPerms', {permList: neededPerms.join(', ')}));
|
|
return false;
|
|
}
|
|
|
|
/* the provided update object will be mangled for our use -- copy it to
|
|
* preserve the user's original input if requested (default).
|
|
*/
|
|
if(!options.overrides?.preserveData) {
|
|
updates = MODULE.copy(updates, 'error.badUpdate.complex');
|
|
if(!updates) return false;
|
|
options = foundry.utils.mergeObject(options, {overrides: {preserveData: true}}, {inplace: false});
|
|
}
|
|
|
|
/* ensure that we are working with clean data */
|
|
await Mutator.clean(updates, options);
|
|
|
|
/* providing a delta means you are managing the
|
|
* entire data change (including mutation stack changes).
|
|
* Typically used by remote requests */
|
|
|
|
/* create a default mutation info assuming we were provided
|
|
* with the final delta already or the change is permanent
|
|
*/
|
|
let mutateInfo = Mutator._createMutateInfo( options.delta ?? {}, options );
|
|
|
|
/* check that this mutation name is unique */
|
|
const present = warpgate.mutationStack(tokenDoc).getName(mutateInfo.name);
|
|
if(!!present) {
|
|
logger.warn(MODULE.format('error.badMutate.duplicate', {name: mutateInfo.name}));
|
|
return false;
|
|
}
|
|
|
|
/* ensure the options parameter has a name field if not provided */
|
|
options.name = mutateInfo.name;
|
|
|
|
/* expand the object to handle property paths correctly */
|
|
MODULE.shimUpdate(updates);
|
|
|
|
/* permanent changes are not tracked */
|
|
if(!options.permanent) {
|
|
|
|
/* if we have the delta provided, trust it */
|
|
let delta = options.delta ?? Mutator._createDelta(tokenDoc, updates, options);
|
|
|
|
/* allow user to modify delta if needed (remote updates will never have callbacks) */
|
|
if (callbacks.delta) {
|
|
|
|
const cont = await callbacks.delta(delta, tokenDoc);
|
|
if(cont === false) return false;
|
|
|
|
}
|
|
|
|
/* update the mutation info with the final updates including mutate stack info */
|
|
mutateInfo = Mutator._mergeMutateDelta(tokenDoc.actor, delta, updates, options);
|
|
|
|
options.delta = mutateInfo.delta;
|
|
|
|
} else if (callbacks.delta) {
|
|
/* call the delta callback if provided, but there is no object to modify */
|
|
const cont = await callbacks.delta({}, tokenDoc);
|
|
if(cont === false) return false;
|
|
}
|
|
|
|
if (tokenDoc.actor.isOwner) {
|
|
|
|
if(options.notice && tokenDoc.object) {
|
|
|
|
const placement = {
|
|
scene: tokenDoc.object.scene,
|
|
...tokenDoc.object.center,
|
|
};
|
|
|
|
warpgate.plugin.notice(placement, options.notice);
|
|
}
|
|
|
|
await Mutator._update(tokenDoc, updates, options);
|
|
|
|
if(callbacks.post) await callbacks.post(tokenDoc, updates, true);
|
|
|
|
await warpgate.event.notify(warpgate.EVENT.MUTATE, {
|
|
uuid: tokenDoc.uuid,
|
|
updates: (options.overrides?.includeRawData ?? false) ? updates : 'omitted',
|
|
options
|
|
});
|
|
|
|
} else {
|
|
/* this is a remote mutation request, hand it over to that system */
|
|
return RemoteMutator.remoteMutate( tokenDoc, {updates, callbacks, options} );
|
|
}
|
|
|
|
return mutateInfo;
|
|
}
|
|
|
|
/**
|
|
* Perform a managed, batch update of multple token documents. Heterogeneous ownership supported
|
|
* and routed through the Remote Mutation system as needed. The same updates, callbacks and options
|
|
* objects will be used for all mutations.
|
|
*
|
|
* Note: If a specific mutation name is not provided, a single random ID will be generated for all
|
|
* resulting individual mutations.
|
|
*
|
|
* @static
|
|
* @param {Array<TokenDocument>} tokenDocs List of tokens on which to apply the provided mutation.
|
|
* @param {Object} details The details of this batch mutation operation.
|
|
* @param {Shorthand} details.updates The updates to apply to each token; as {@link warpgate.spawn}
|
|
* @param {Object} [details.callbacks] Delta and post mutation callbacks; as {@link warpgate.mutate}
|
|
* @param {PostDelta} [details.callbacks.delta]
|
|
* @param {PostMutate} [details.callbacks.post]
|
|
* @param {WorkflowOptions & MutationOptions} [details.options]
|
|
*
|
|
* @returns {Promise<Array<MutateInfo>>} List of mutation results, which resolve
|
|
* once all local mutations have been applied and when all remote mutations have been _accepted_
|
|
* or _rejected_. Currently, local and remote mutations will contain differing object structures.
|
|
* Notably, local mutations contain a `delta` field containing the revert data for
|
|
* this mutation; whereas remote mutations will contain an `accepted` field,
|
|
* indicating if the request was accepted.
|
|
*/
|
|
static async batchMutate( tokenDocs, {updates, callbacks, options} ) {
|
|
|
|
/* break token list into sublists by first owner */
|
|
const tokenLists = MODULE.ownerSublist(tokenDocs);
|
|
|
|
if((tokenLists['none'] ?? []).length > 0) {
|
|
logger.warn(MODULE.localize('error.offlineOwnerBatch'));
|
|
logger.debug('Affected UUIDs:', tokenLists['none'].map( t => t.uuid ));
|
|
delete tokenLists['none'];
|
|
}
|
|
|
|
options.name ??= randomID();
|
|
|
|
let promises = Reflect.ownKeys(tokenLists).flatMap( async (owner) => {
|
|
if(owner == game.userId) {
|
|
//self service mutate
|
|
return await tokenLists[owner].map( tokenDoc => warpgate.mutate(tokenDoc, updates, callbacks, options) );
|
|
}
|
|
|
|
/* is a remote update */
|
|
return await RemoteMutator.remoteBatchMutate( tokenLists[owner], {updates, callbacks, options} );
|
|
|
|
})
|
|
|
|
/* wait for each client batch of mutations to complete */
|
|
promises = await Promise.all(promises);
|
|
|
|
/* flatten all into a single array, and ensure all subqueries are complete */
|
|
return Promise.all(promises.flat());
|
|
}
|
|
|
|
/**
|
|
* Perform a managed, batch update of multple token documents. Heterogeneous ownership supported
|
|
* and routed through the Remote Mutation system as needed. The same updates, callbacks and options
|
|
* objects will be used for all mutations.
|
|
*
|
|
* Note: If a specific mutation name is not provided, a single random ID will be generated for all
|
|
* resulting individual mutations.
|
|
*
|
|
* @static
|
|
* @param {Array<TokenDocument>} tokenDocs List of tokens on which to perform the revert
|
|
* @param {Object} details
|
|
* @param {string} [details.mutationName] Specific mutation name to revert, or the latest mutation
|
|
* for an individual token if not provided. Tokens without mutations or without the specific
|
|
* mutation requested are not processed.
|
|
* @param {WorkflowOptions & MutationOptions} [details.options]
|
|
* @returns {Promise<Array<MutateInfo>>} List of mutation revert results, which resolve
|
|
* once all local reverts have been applied and when all remote reverts have been _accepted_
|
|
* or _rejected_. Currently, local and remote reverts will contain differing object structures.
|
|
* Notably, local revert contain a `delta` field containing the revert data for
|
|
* this mutation; whereas remote reverts will contain an `accepted` field,
|
|
* indicating if the request was accepted.
|
|
|
|
*/
|
|
static async batchRevert( tokenDocs, {mutationName = null, options = {}} = {} ) {
|
|
|
|
const tokenLists = MODULE.ownerSublist(tokenDocs);
|
|
|
|
if((tokenLists['none'] ?? []).length > 0) {
|
|
logger.warn(MODULE.localize('error.offlineOwnerBatch'));
|
|
logger.debug('Affected UUIDs:', tokenLists['none'].map( t => t.uuid ));
|
|
delete tokenLists['none'];
|
|
}
|
|
|
|
let promises = Reflect.ownKeys(tokenLists).map( (owner) => {
|
|
if(owner == game.userId) {
|
|
//self service mutate
|
|
return tokenLists[owner].map( tokenDoc => warpgate.revert(tokenDoc, mutationName, options) );
|
|
}
|
|
|
|
/* is a remote update */
|
|
return RemoteMutator.remoteBatchRevert( tokenLists[owner], {mutationName, options} );
|
|
|
|
})
|
|
|
|
promises = await Promise.all(promises);
|
|
|
|
return Promise.all(promises.flat());
|
|
}
|
|
|
|
/**
|
|
* @returns {MutationData}
|
|
*/
|
|
static _createMutateInfo( delta, options = {} ) {
|
|
options.name ??= randomID();
|
|
return {
|
|
delta: MODULE.stripEmpty(delta),
|
|
user: game.user.id,
|
|
comparisonKeys: MODULE.stripEmpty(options.comparisonKeys ?? {}, false),
|
|
name: options.name,
|
|
updateOpts: MODULE.stripEmpty(options.updateOpts ?? {}, false),
|
|
overrides: MODULE.stripEmpty(options.overrides ?? {}, false),
|
|
};
|
|
}
|
|
|
|
static _cleanInner(single) {
|
|
Object.keys(single).forEach( key => {
|
|
/* dont process embedded */
|
|
if(key == 'embedded') return;
|
|
|
|
/* dont process delete identifiers */
|
|
if(typeof single[key] == 'string') return;
|
|
|
|
/* convert value to plain object if possible */
|
|
if(single[key]?.toObject) single[key] = single[key].toObject();
|
|
|
|
if(single[key] == undefined) {
|
|
single[key] = {};
|
|
}
|
|
|
|
return;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleans and validates mutation data
|
|
* @param {Shorthand} updates
|
|
* @param {SpawningOptions & MutationOptions} [options]
|
|
*/
|
|
static async clean(updates, options = undefined) {
|
|
|
|
if(!!updates) {
|
|
/* ensure we are working with raw objects */
|
|
Mutator._cleanInner(updates);
|
|
|
|
/* perform cleaning on shorthand embedded updates */
|
|
Object.values(updates.embedded ?? {}).forEach( type => Mutator._cleanInner(type));
|
|
|
|
/* if the token is getting an image update, preload it */
|
|
let source;
|
|
if('src' in (updates.token?.texture ?? {})) {
|
|
source = updates.token.texture.src;
|
|
}
|
|
else if( 'img' in (updates.token ?? {})){
|
|
source = updates.token.img;
|
|
}
|
|
|
|
/* load texture if provided */
|
|
try {
|
|
!!source ? await loadTexture(source) : null;
|
|
} catch (err) {
|
|
logger.debug(err);
|
|
}
|
|
}
|
|
|
|
if(!!options) {
|
|
/* insert the better ActiveEffect default ONLY IF
|
|
* one wasn't provided in the options object initially
|
|
*/
|
|
options.comparisonKeys = foundry.utils.mergeObject(
|
|
options.comparisonKeys ?? {},
|
|
{ActiveEffect: 'label'},
|
|
{overwrite:false, inplace:false});
|
|
|
|
/* if `id` is being used as the comparison key,
|
|
* change it to `_id` and set the option to `keepId=true`
|
|
* if either are present
|
|
*/
|
|
options.comparisonKeys ??= {};
|
|
options.updateOpts ??= {};
|
|
Object.keys(options.comparisonKeys).forEach( embName => {
|
|
|
|
/* switch to _id if needed */
|
|
if(options.comparisonKeys[embName] == 'id') options.comparisonKeys[embName] = '_id'
|
|
|
|
/* flag this update to preserve ids */
|
|
if(options.comparisonKeys[embName] == '_id') {
|
|
foundry.utils.mergeObject(options.updateOpts, {embedded: {[embName]: {keepId: true}}});
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
static _mergeMutateDelta(actorDoc, delta, updates, options) {
|
|
|
|
/* Grab the current stack (or make a new one) */
|
|
let mutateStack = actorDoc.getFlag(MODULE.data.name, 'mutate') ?? [];
|
|
|
|
/* create the information needed to revert this mutation and push
|
|
* it onto the stack
|
|
*/
|
|
const mutateInfo = Mutator._createMutateInfo( delta, options );
|
|
mutateStack.push(mutateInfo);
|
|
|
|
/* Create a new mutation stack flag data and store it in the update object */
|
|
const flags = {warpgate: {mutate: mutateStack}};
|
|
updates.actor = mergeObject(updates.actor ?? {}, {flags});
|
|
|
|
return mutateInfo;
|
|
}
|
|
|
|
/* @return {Promise} */
|
|
static async _update(tokenDoc, updates, options = {}) {
|
|
|
|
/* update the token */
|
|
await tokenDoc.update(updates.token ?? {}, options.updateOpts?.token ?? {});
|
|
|
|
if(!options.noMoveWait && !!tokenDoc.object) {
|
|
await CanvasAnimation.getAnimation(tokenDoc.object.animationName)?.promise
|
|
}
|
|
|
|
/* update the actor */
|
|
return Mutator._updateActor(tokenDoc.actor, updates, options.comparisonKeys ?? {}, options.updateOpts ?? {});
|
|
}
|
|
|
|
/**
|
|
* Will peel off the last applied mutation change from the provided token document
|
|
*
|
|
* @param {TokenDocument} tokenDoc Token document to revert the last applied mutation.
|
|
* @param {String} [mutationName]. Specific mutation name to revert. optional.
|
|
* @param {WorkflowOptions} [options]
|
|
*
|
|
* @return {Promise<MutationData|undefined>} The mutation data (updates) used for this
|
|
* revert operation or `undefined` if none occured.
|
|
*/
|
|
static async revertMutation(tokenDoc, mutationName = undefined, options = {}) {
|
|
|
|
const mutateData = await Mutator._popMutation(tokenDoc?.actor, mutationName);
|
|
|
|
if(!mutateData) {
|
|
return;
|
|
}
|
|
|
|
if (tokenDoc.actor?.isOwner) {
|
|
if(options.notice && tokenDoc.object) {
|
|
|
|
const placement = {
|
|
scene: tokenDoc.object.scene,
|
|
...tokenDoc.object.center,
|
|
};
|
|
|
|
warpgate.plugin.notice(placement, options.notice);
|
|
}
|
|
|
|
/* the provided options object will be mangled for our use -- copy it to
|
|
* preserve the user's original input if requested (default).
|
|
*/
|
|
if(!options.overrides?.preserveData) {
|
|
options = MODULE.copy(options, 'error.badUpdate.complex');
|
|
if(!options) return;
|
|
options = foundry.utils.mergeObject(options, {overrides: {preserveData: true}}, {inplace: false});
|
|
}
|
|
|
|
/* perform the revert with the stored delta */
|
|
MODULE.shimUpdate(mutateData.delta);
|
|
mutateData.updateOpts ??= {};
|
|
mutateData.overrides ??= {};
|
|
foundry.utils.mergeObject(mutateData.updateOpts, options.updateOpts ?? {});
|
|
foundry.utils.mergeObject(mutateData.overrides, options.overrides ?? {});
|
|
|
|
await Mutator._update(tokenDoc, mutateData.delta, {
|
|
overrides: mutateData.overrides,
|
|
comparisonKeys: mutateData.comparisonKeys,
|
|
updateOpts: mutateData.updateOpts
|
|
});
|
|
|
|
/* notify clients */
|
|
warpgate.event.notify(warpgate.EVENT.REVERT, {
|
|
uuid: tokenDoc.uuid,
|
|
updates: (options.overrides?.includeRawData ?? false) ? mutateData : 'omitted',
|
|
options});
|
|
|
|
} else {
|
|
return RemoteMutator.remoteRevert(tokenDoc, {mutationId: mutateData.name, options});
|
|
}
|
|
|
|
return mutateData;
|
|
}
|
|
|
|
static async _popMutation(actor, mutationName) {
|
|
|
|
let mutateStack = actor?.getFlag(MODULE.data.name, 'mutate') ?? [];
|
|
|
|
if (mutateStack.length == 0 || !actor){
|
|
logger.debug(`Provided actor is undefined or has no mutation stack. Cannot pop.`);
|
|
return undefined;
|
|
}
|
|
|
|
let mutateData = undefined;
|
|
|
|
if (!!mutationName) {
|
|
/* find specific mutation */
|
|
const index = mutateStack.findIndex( mutation => mutation.name === mutationName );
|
|
|
|
/* check for no result and log */
|
|
if ( index < 0 ) {
|
|
logger.debug(`Could not locate mutation named ${mutationName} in actor ${actor.name}`);
|
|
return undefined;
|
|
}
|
|
|
|
/* otherwise, retrieve and remove */
|
|
mutateData = mutateStack.splice(index, 1)[0];
|
|
|
|
for( let i = index; i < mutateStack.length; i++){
|
|
|
|
/* get the values stored in our delta and push any overlapping ones to
|
|
* the mutation next in the stack
|
|
*/
|
|
const stackUpdate = filterObject(mutateData.delta, mutateStack[i].delta);
|
|
mergeObject(mutateStack[i].delta, stackUpdate);
|
|
|
|
/* remove any changes that exist higher in the stack, we have
|
|
* been overriden and should not restore these values
|
|
*/
|
|
mutateData.delta = MODULE.unique(mutateData.delta, mutateStack[i].delta)
|
|
}
|
|
|
|
} else {
|
|
/* pop the most recent mutation */
|
|
mutateData = mutateStack.pop();
|
|
}
|
|
|
|
const newFlags = {[`${MODULE.data.name}.mutate`]: mutateStack};
|
|
|
|
/* set the current mutation stack in the mutation data */
|
|
foundry.utils.mergeObject(mutateData.delta, {actor: {flags: newFlags}});
|
|
|
|
logger.debug(MODULE.localize('debug.finalRevertUpdate'), mutateData);
|
|
|
|
return mutateData;
|
|
}
|
|
|
|
/* given a token document and the standard update object,
|
|
* parse the changes that need to be applied to *reverse*
|
|
* the mutate operation
|
|
*/
|
|
static _createDelta(tokenDoc, updates, options) {
|
|
|
|
/* get token changes */
|
|
let tokenData = tokenDoc.toObject()
|
|
tokenData.actorData = {};
|
|
|
|
const tokenDelta = MODULE.strictUpdateDiff(updates.token ?? {}, tokenData);
|
|
|
|
/* get the actor changes (no embeds) */
|
|
const actorData = Mutator._getRootActorData(tokenDoc.actor);
|
|
const actorDelta = MODULE.strictUpdateDiff(updates.actor ?? {}, actorData);
|
|
|
|
/* get the changes from the embeds */
|
|
let embeddedDelta = {};
|
|
if(updates.embedded) {
|
|
|
|
for( const embeddedName of Object.keys(updates.embedded) ) {
|
|
const collection = tokenDoc.actor.getEmbeddedCollection(embeddedName);
|
|
const invertedShorthand = Mutator._invertShorthand(collection, updates.embedded[embeddedName], getProperty(options.comparisonKeys, embeddedName) ?? 'name');
|
|
embeddedDelta[embeddedName] = invertedShorthand;
|
|
}
|
|
}
|
|
|
|
logger.debug(MODULE.localize('debug.tokenDelta'), tokenDelta, MODULE.localize('debug.actorDelta'), actorDelta, MODULE.localize('debug.embeddedDelta'), embeddedDelta);
|
|
|
|
return {token: tokenDelta, actor: actorDelta, embedded: embeddedDelta}
|
|
}
|
|
|
|
/* returns the actor data sans ALL embedded collections */
|
|
static _getRootActorData(actorDoc) {
|
|
let actorData = actorDoc.toObject();
|
|
|
|
/* get the key NAME of the embedded document type.
|
|
* ex. not 'ActiveEffect' (the class name), 'effect' the collection's field name
|
|
*/
|
|
let embeddedFields = Object.values(Actor.implementation.metadata.embedded);
|
|
if(!MODULE.isV10) {
|
|
embeddedFields = embeddedFields.map( thisClass => thisClass.metadata.collection );
|
|
}
|
|
|
|
/* delete any embedded fields from the actor data */
|
|
embeddedFields.forEach( field => { delete actorData[field] } )
|
|
|
|
return actorData;
|
|
}
|
|
}
|