Files
Foundry-VTT-Docker/resources/app/client/data/documents/actor.js
2025-01-04 00:34:03 +01:00

673 lines
24 KiB
JavaScript

/**
* 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<Scene, IterableWeakSet<TokenDocument>>}
* @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<string>}
*/
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<string, Item[]>}
*/
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<TokenDocument|Token>} 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<ActiveEffect, void, void>}
*/
*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<TokenDocument>} 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<string[]>}
*/
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<documents.Actor>} 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<documents.Combat|null>} 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<ActiveEffect|boolean|undefined>} 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<string[]>} 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);
}
}
}