/** * The client-side Actor document which extends the common BaseActor model. * * ### Hook Events * {@link hookEvents.applyCompendiumArt} * * @extends foundry.documents.BaseActor * @mixes ClientDocumentMixin * @category - Documents * * @see {@link Actors} The world-level collection of Actor documents * @see {@link ActorSheet} The Actor configuration application * * @example Create a new Actor * ```js * let actor = await Actor.create({ * name: "New Test Actor", * type: "character", * img: "artwork/character-profile.jpg" * }); * ``` * * @example Retrieve an existing Actor * ```js * let actor = game.actors.get(actorId); * ``` */ class Actor extends ClientDocumentMixin(foundry.documents.BaseActor) { /** @inheritdoc */ _configure(options={}) { super._configure(options); /** * Maintain a list of Token Documents that represent this Actor, stored by Scene. * @type {IterableWeakMap>} * @private */ Object.defineProperty(this, "_dependentTokens", { value: new foundry.utils.IterableWeakMap() }); } /* -------------------------------------------- */ /** @inheritDoc */ _initializeSource(source, options={}) { source = super._initializeSource(source, options); // Apply configured Actor art. const pack = game.packs.get(options.pack); if ( !source._id || !pack || !game.compendiumArt.enabled ) return source; const uuid = pack.getUuid(source._id); const art = game.compendiumArt.get(uuid) ?? {}; if ( !art.actor && !art.token ) return source; if ( art.actor ) source.img = art.actor; if ( typeof token === "string" ) source.prototypeToken.texture.src = art.token; else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token); Hooks.callAll("applyCompendiumArt", this.constructor, source, pack, art); return source; } /* -------------------------------------------- */ /** * An object that tracks which tracks the changes to the data model which were applied by active effects * @type {object} */ overrides = this.overrides ?? {}; /** * The statuses that are applied to this actor by active effects * @type {Set} */ statuses = this.statuses ?? new Set(); /** * A cached array of image paths which can be used for this Actor's token. * Null if the list has not yet been populated. * @type {string[]|null} * @private */ _tokenImages = null; /** * Cache the last drawn wildcard token to avoid repeat draws * @type {string|null} */ _lastWildcard = null; /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * Provide a thumbnail image path used to represent this document. * @type {string} */ get thumbnail() { return this.img; } /* -------------------------------------------- */ /** * Provide an object which organizes all embedded Item instances by their type * @type {Record} */ get itemTypes() { const types = Object.fromEntries(game.documentTypes.Item.map(t => [t, []])); for ( const item of this.items.values() ) { types[item.type].push(item); } return types; } /* -------------------------------------------- */ /** * Test whether an Actor document is a synthetic representation of a Token (if true) or a full Document (if false) * @type {boolean} */ get isToken() { if ( !this.parent ) return false; return this.parent instanceof TokenDocument; } /* -------------------------------------------- */ /** * Retrieve the list of ActiveEffects that are currently applied to this Actor. * @type {ActiveEffect[]} */ get appliedEffects() { const effects = []; for ( const effect of this.allApplicableEffects() ) { if ( effect.active ) effects.push(effect); } return effects; } /* -------------------------------------------- */ /** * An array of ActiveEffect instances which are present on the Actor which have a limited duration. * @type {ActiveEffect[]} */ get temporaryEffects() { const effects = []; for ( const effect of this.allApplicableEffects() ) { if ( effect.active && effect.isTemporary ) effects.push(effect); } return effects; } /* -------------------------------------------- */ /** * Return a reference to the TokenDocument which owns this Actor as a synthetic override * @type {TokenDocument|null} */ get token() { return this.parent instanceof TokenDocument ? this.parent : null; } /* -------------------------------------------- */ /** * Whether the Actor has at least one Combatant in the active Combat that represents it. * @returns {boolean} */ get inCombat() { return !!game.combat?.getCombatantsByActor(this).length; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Apply any transformations to the Actor data which are caused by ActiveEffects. */ applyActiveEffects() { const overrides = {}; this.statuses.clear(); // Organize non-disabled effects by their application priority const changes = []; for ( const effect of this.allApplicableEffects() ) { if ( !effect.active ) continue; changes.push(...effect.changes.map(change => { const c = foundry.utils.deepClone(change); c.effect = effect; c.priority = c.priority ?? (c.mode * 10); return c; })); for ( const statusId of effect.statuses ) this.statuses.add(statusId); } changes.sort((a, b) => a.priority - b.priority); // Apply all changes for ( let change of changes ) { if ( !change.key ) continue; const changes = change.effect.apply(this, change); Object.assign(overrides, changes); } // Expand the set of final overrides this.overrides = foundry.utils.expandObject(overrides); } /* -------------------------------------------- */ /** * Retrieve an Array of active tokens which represent this Actor in the current canvas Scene. * If the canvas is not currently active, or there are no linked actors, the returned Array will be empty. * If the Actor is a synthetic token actor, only the exact Token which it represents will be returned. * * @param {boolean} [linked=false] Limit results to Tokens which are linked to the Actor. Otherwise, return all * Tokens even those which are not linked. * @param {boolean} [document=false] Return the Document instance rather than the PlaceableObject * @returns {Array} An array of Token instances in the current Scene which reference this Actor. */ getActiveTokens(linked=false, document=false) { if ( !canvas.ready ) return []; const tokens = []; for ( const t of this.getDependentTokens({ linked, scenes: canvas.scene }) ) { if ( t !== canvas.scene.tokens.get(t.id) ) continue; if ( document ) tokens.push(t); else if ( t.rendered ) tokens.push(t.object); } return tokens; } /* -------------------------------------------- */ /** * Get all ActiveEffects that may apply to this Actor. * If CONFIG.ActiveEffect.legacyTransferral is true, this is equivalent to actor.effects.contents. * If CONFIG.ActiveEffect.legacyTransferral is false, this will also return all the transferred ActiveEffects on any * of the Actor's owned Items. * @yields {ActiveEffect} * @returns {Generator} */ *allApplicableEffects() { for ( const effect of this.effects ) { yield effect; } if ( CONFIG.ActiveEffect.legacyTransferral ) return; for ( const item of this.items ) { for ( const effect of item.effects ) { if ( effect.transfer ) yield effect; } } } /* -------------------------------------------- */ /** * Return a data object which defines the data schema against which dice rolls can be evaluated. * By default, this is directly the Actor's system data, but systems may extend this to include additional properties. * If overriding or extending this method to add additional properties, care must be taken not to mutate the original * object. * @returns {object} */ getRollData() { return this.system; } /* -------------------------------------------- */ /** * Create a new Token document, not yet saved to the database, which represents the Actor. * @param {object} [data={}] Additional data, such as x, y, rotation, etc. for the created token data * @param {object} [options={}] The options passed to the TokenDocument constructor * @returns {Promise} The created TokenDocument instance */ async getTokenDocument(data={}, options={}) { const tokenData = this.prototypeToken.toObject(); tokenData.actorId = this.id; if ( tokenData.randomImg && !data.texture?.src ) { let images = await this.getTokenImages(); if ( (images.length > 1) && this._lastWildcard ) { images = images.filter(i => i !== this._lastWildcard); } const image = images[Math.floor(Math.random() * images.length)]; tokenData.texture.src = this._lastWildcard = image; } if ( !tokenData.actorLink ) { if ( tokenData.appendNumber ) { // Count how many tokens are already linked to this actor const tokens = canvas.scene.tokens.filter(t => t.actorId === this.id); const n = tokens.length + 1; tokenData.name = `${tokenData.name} (${n})`; } if ( tokenData.prependAdjective ) { const adjectives = Object.values( foundry.utils.getProperty(game.i18n.translations, CONFIG.Token.adjectivesPrefix) || foundry.utils.getProperty(game.i18n._fallback, CONFIG.Token.adjectivesPrefix) || {}); const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; tokenData.name = `${adjective} ${tokenData.name}`; } } foundry.utils.mergeObject(tokenData, data); const cls = getDocumentClass("Token"); return new cls(tokenData, options); } /* -------------------------------------------- */ /** * Get an Array of Token images which could represent this Actor * @returns {Promise} */ async getTokenImages() { if ( !this.prototypeToken.randomImg ) return [this.prototypeToken.texture.src]; if ( this._tokenImages ) return this._tokenImages; try { this._tokenImages = await this.constructor._requestTokenImages(this.id, {pack: this.pack}); } catch(err) { this._tokenImages = []; Hooks.onError("Actor#getTokenImages", err, { msg: "Error retrieving wildcard tokens", log: "error", notify: "error" }); } return this._tokenImages; } /* -------------------------------------------- */ /** * Handle how changes to a Token attribute bar are applied to the Actor. * This allows for game systems to override this behavior and deploy special logic. * @param {string} attribute The attribute path * @param {number} value The target attribute value * @param {boolean} isDelta Whether the number represents a relative change (true) or an absolute change (false) * @param {boolean} isBar Whether the new value is part of an attribute bar, or just a direct value * @returns {Promise} The updated Actor document */ async modifyTokenAttribute(attribute, value, isDelta=false, isBar=true) { const attr = foundry.utils.getProperty(this.system, attribute); const current = isBar ? attr.value : attr; const update = isDelta ? current + value : value; if ( update === current ) return this; // Determine the updates to make to the actor data let updates; if ( isBar ) updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)}; else updates = {[`system.${attribute}`]: update}; // Allow a hook to override these changes const allowed = Hooks.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates); return allowed !== false ? this.update(updates) : this; } /* -------------------------------------------- */ /** @inheritdoc */ prepareData() { // Identify which special statuses had been active this.statuses ??= new Set(); const specialStatuses = new Map(); for ( const statusId of Object.values(CONFIG.specialStatusEffects) ) { specialStatuses.set(statusId, this.statuses.has(statusId)); } super.prepareData(); // Apply special statuses that changed to active tokens let tokens; for ( const [statusId, wasActive] of specialStatuses ) { const isActive = this.statuses.has(statusId); if ( isActive === wasActive ) continue; tokens ??= this.getDependentTokens({scenes: canvas.scene}).filter(t => t.rendered).map(t => t.object); for ( const token of tokens ) token._onApplyStatusEffect(statusId, isActive); } } /* -------------------------------------------- */ /** @inheritdoc */ prepareEmbeddedDocuments() { super.prepareEmbeddedDocuments(); this.applyActiveEffects(); } /* -------------------------------------------- */ /** * Roll initiative for all Combatants in the currently active Combat encounter which are associated with this Actor. * If viewing a full Actor document, all Tokens which map to that actor will be targeted for initiative rolls. * If viewing a synthetic Token actor, only that particular Token will be targeted for an initiative roll. * * @param {object} options Configuration for how initiative for this Actor is rolled. * @param {boolean} [options.createCombatants=false] Create new Combatant entries for Tokens associated with * this actor. * @param {boolean} [options.rerollInitiative=false] Re-roll the initiative for this Actor if it has already * been rolled. * @param {object} [options.initiativeOptions={}] Additional options passed to the Combat#rollInitiative method. * @returns {Promise} A promise which resolves to the Combat document once rolls * are complete. */ async rollInitiative({createCombatants=false, rerollInitiative=false, initiativeOptions={}}={}) { // Obtain (or create) a combat encounter let combat = game.combat; if ( !combat ) { if ( game.user.isGM && canvas.scene ) { const cls = getDocumentClass("Combat"); combat = await cls.create({scene: canvas.scene.id, active: true}); } else { ui.notifications.warn("COMBAT.NoneActive", {localize: true}); return null; } } // Create new combatants if ( createCombatants ) { const tokens = this.getActiveTokens(); const toCreate = []; if ( tokens.length ) { for ( let t of tokens ) { if ( t.inCombat ) continue; toCreate.push({tokenId: t.id, sceneId: t.scene.id, actorId: this.id, hidden: t.document.hidden}); } } else toCreate.push({actorId: this.id, hidden: false}); await combat.createEmbeddedDocuments("Combatant", toCreate); } // Roll initiative for combatants const combatants = combat.combatants.reduce((arr, c) => { if ( this.isToken && (c.token !== this.token) ) return arr; if ( !this.isToken && (c.actor !== this) ) return arr; if ( !rerollInitiative && (c.initiative !== null) ) return arr; arr.push(c.id); return arr; }, []); await combat.rollInitiative(combatants, initiativeOptions); return combat; } /* -------------------------------------------- */ /** * Toggle a configured status effect for the Actor. * @param {string} statusId A status effect ID defined in CONFIG.statusEffects * @param {object} [options={}] Additional options which modify how the effect is created * @param {boolean} [options.active] Force the effect to be active or inactive regardless of its current state * @param {boolean} [options.overlay=false] Display the toggled effect as an overlay * @returns {Promise} A promise which resolves to one of the following values: * - ActiveEffect if a new effect need to be created * - true if was already an existing effect * - false if an existing effect needed to be removed * - undefined if no changes need to be made */ async toggleStatusEffect(statusId, {active, overlay=false}={}) { const status = CONFIG.statusEffects.find(e => e.id === statusId); if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to Actor#toggleStatusEffect`); const existing = []; // Find the effect with the static _id of the status effect if ( status._id ) { const effect = this.effects.get(status._id); if ( effect ) existing.push(effect.id); } // If no static _id, find all single-status effects that have this status else { for ( const effect of this.effects ) { const statuses = effect.statuses; if ( (statuses.size === 1) && statuses.has(status.id) ) existing.push(effect.id); } } // Remove the existing effects unless the status effect is forced active if ( existing.length ) { if ( active ) return true; await this.deleteEmbeddedDocuments("ActiveEffect", existing); return false; } // Create a new effect unless the status effect is forced inactive if ( !active && (active !== undefined) ) return; const effect = await ActiveEffect.implementation.fromStatusEffect(statusId); if ( overlay ) effect.updateSource({"flags.core.overlay": true}); return ActiveEffect.implementation.create(effect, {parent: this, keepId: true}); } /* -------------------------------------------- */ /** * Request wildcard token images from the server and return them. * @param {string} actorId The actor whose prototype token contains the wildcard image path. * @param {object} [options] * @param {string} [options.pack] The name of the compendium the actor is in. * @returns {Promise} The list of filenames to token images that match the wildcard search. * @private */ static _requestTokenImages(actorId, options={}) { return new Promise((resolve, reject) => { game.socket.emit("requestTokenImages", actorId, options, result => { if ( result.error ) return reject(new Error(result.error)); resolve(result.files); }); }); } /* -------------------------------------------- */ /* Tokens */ /* -------------------------------------------- */ /** * Get this actor's dependent tokens. * If the actor is a synthetic token actor, only the exact Token which it represents will be returned. * @param {object} [options] * @param {Scene|Scene[]} [options.scenes] A single Scene, or list of Scenes to filter by. * @param {boolean} [options.linked] Limit the results to tokens that are linked to the actor. * @returns {TokenDocument[]} */ getDependentTokens({ scenes, linked=false }={}) { if ( this.isToken && !scenes ) return [this.token]; if ( scenes ) scenes = Array.isArray(scenes) ? scenes : [scenes]; else scenes = Array.from(this._dependentTokens.keys()); if ( this.isToken ) { const parent = this.token.parent; return scenes.includes(parent) ? [this.token] : []; } const allTokens = []; for ( const scene of scenes ) { if ( !scene ) continue; const tokens = this._dependentTokens.get(scene); for ( const token of (tokens ?? []) ) { if ( !linked || token.actorLink ) allTokens.push(token); } } return allTokens; } /* -------------------------------------------- */ /** * Register a token as a dependent of this actor. * @param {TokenDocument} token The token. * @internal */ _registerDependentToken(token) { if ( !token?.parent ) return; if ( !this._dependentTokens.has(token.parent) ) { this._dependentTokens.set(token.parent, new foundry.utils.IterableWeakSet()); } const tokens = this._dependentTokens.get(token.parent); tokens.add(token); } /* -------------------------------------------- */ /** * Remove a token from this actor's dependents. * @param {TokenDocument} token The token. * @internal */ _unregisterDependentToken(token) { if ( !token?.parent ) return; const tokens = this._dependentTokens.get(token.parent); tokens?.delete(token); } /* -------------------------------------------- */ /** * Prune a whole scene from this actor's dependent tokens. * @param {Scene} scene The scene. * @internal */ _unregisterDependentScene(scene) { this._dependentTokens.delete(scene); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { // Update prototype token config references to point to the new PrototypeToken object. Object.values(this.apps).forEach(app => { if ( !(app instanceof TokenConfig) ) return; app.object = this.prototypeToken; app._previewChanges(changed.prototypeToken ?? {}); }); super._onUpdate(changed, options, userId); // Additional options only apply to base Actors if ( this.isToken ) return; this._updateDependentTokens(changed, options); // If the prototype token was changed, expire any cached token images if ( "prototypeToken" in changed ) this._tokenImages = null; // If ownership changed for the actor reset token control if ( ("permission" in changed) && tokens.length ) { canvas.tokens.releaseAll(); canvas.tokens.cycleTokens(true, true); } } /* -------------------------------------------- */ /** @inheritDoc */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { // If this is a grandchild Active Effect creation, call reset to re-prepare and apply active effects, then call // super which will invoke sheet re-rendering. if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset(); super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); this._onEmbeddedDocumentChange(); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { // If this is a grandchild Active Effect update, call reset to re-prepare and apply active effects, then call // super which will invoke sheet re-rendering. if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset(); super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); this._onEmbeddedDocumentChange(); } /* -------------------------------------------- */ /** @inheritDoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { // If this is a grandchild Active Effect deletion, call reset to re-prepare and apply active effects, then call // super which will invoke sheet re-rendering. if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset(); super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId); this._onEmbeddedDocumentChange(); } /* -------------------------------------------- */ /** * Additional workflows to perform when any descendant document within this Actor changes. * @protected */ _onEmbeddedDocumentChange() { if ( !this.isToken ) this._updateDependentTokens(); } /* -------------------------------------------- */ /** * Update the active TokenDocument instances which represent this Actor. * @param {...any} args Arguments forwarded to Token#_onUpdateBaseActor * @protected */ _updateDependentTokens(...args) { for ( const token of this.getDependentTokens() ) { token._onUpdateBaseActor(...args); } } }