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

1064 lines
39 KiB
JavaScript

/**
* The client-side Token document which extends the common BaseToken document model.
* @extends foundry.documents.BaseToken
* @mixes CanvasDocumentMixin
*
* @see {@link Scene} The Scene document type which contains Token documents
* @see {@link TokenConfig} The Token configuration application
*/
class TokenDocument extends CanvasDocumentMixin(foundry.documents.BaseToken) {
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A singleton collection which holds a reference to the synthetic token actor by its base actor's ID.
* @type {Collection<Actor>}
*/
actors = (function() {
const collection = new foundry.utils.Collection();
collection.documentClass = Actor.implementation;
return collection;
})();
/* -------------------------------------------- */
/**
* A reference to the Actor this Token modifies.
* If actorLink is true, then the document is the primary Actor document.
* Otherwise, the Actor document is a synthetic (ephemeral) document constructed using the Token's ActorDelta.
* @returns {Actor|null}
*/
get actor() {
return (this.isLinked ? this.baseActor : this.delta?.syntheticActor) ?? null;
}
/* -------------------------------------------- */
/**
* A reference to the base, World-level Actor this token represents.
* @returns {Actor}
*/
get baseActor() {
return game.actors.get(this.actorId);
}
/* -------------------------------------------- */
/**
* An indicator for whether the current User has full control over this Token document.
* @type {boolean}
*/
get isOwner() {
if ( game.user.isGM ) return true;
return this.actor?.isOwner ?? false;
}
/* -------------------------------------------- */
/**
* A convenient reference for whether this TokenDocument is linked to the Actor it represents, or is a synthetic copy
* @type {boolean}
*/
get isLinked() {
return this.actorLink;
}
/* -------------------------------------------- */
/**
* Does this TokenDocument have the SECRET disposition and is the current user lacking the necessary permissions
* that would reveal this secret?
* @type {boolean}
*/
get isSecret() {
return (this.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !this.testUserPermission(game.user, "OBSERVER");
}
/* -------------------------------------------- */
/**
* Return a reference to a Combatant that represents this Token, if one is present in the current encounter.
* @type {Combatant|null}
*/
get combatant() {
return game.combat?.combatants.find(c => c.tokenId === this.id) || null;
}
/* -------------------------------------------- */
/**
* An indicator for whether this Token is currently involved in the active combat encounter.
* @type {boolean}
*/
get inCombat() {
return !!this.combatant;
}
/* -------------------------------------------- */
/**
* The Regions this Token is currently in.
* @type {Set<RegionDocument>}
*/
regions = game._documentsReady ? new Set() : null;
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
_initialize(options = {}) {
super._initialize(options);
this.baseActor?._registerDependentToken(this);
}
/* -------------------------------------------- */
/** @override */
prepareBaseData() {
// Initialize regions
if ( this.regions === null ) {
this.regions = new Set();
if ( !this.parent ) return;
for ( const id of this._regions ) {
const region = this.parent.regions.get(id);
if ( !region ) continue;
this.regions.add(region);
region.tokens.add(this);
}
}
this.name ||= this.actor?.name || "Unknown";
if ( this.hidden ) this.alpha = Math.min(this.alpha, game.user.isGM ? 0.5 : 0);
this._prepareDetectionModes();
}
/* -------------------------------------------- */
/** @inheritdoc */
prepareEmbeddedDocuments() {
if ( game._documentsReady && !this.delta ) this.updateSource({ delta: { _id: this.id } });
}
/* -------------------------------------------- */
/** @inheritDoc */
prepareDerivedData() {
if ( this.ring.enabled && !this.ring.subject.texture ) {
this.ring.subject.texture = this._inferRingSubjectTexture();
}
}
/* -------------------------------------------- */
/**
* Infer the subject texture path to use for a token ring.
* @returns {string}
* @protected
*/
_inferRingSubjectTexture() {
let tex = this.texture.src;
for ( const [prefix, replacement] of Object.entries(CONFIG.Token.ring.subjectPaths) ) {
if ( tex.startsWith(prefix) ) return tex.replace(prefix, replacement);
}
return tex;
}
/* -------------------------------------------- */
/**
* Prepare detection modes which are available to the Token.
* Ensure that every Token has the basic sight detection mode configured.
* @protected
*/
_prepareDetectionModes() {
if ( !this.sight.enabled ) return;
const lightMode = this.detectionModes.find(m => m.id === "lightPerception");
if ( !lightMode ) this.detectionModes.push({id: "lightPerception", enabled: true, range: null});
const basicMode = this.detectionModes.find(m => m.id === "basicSight");
if ( !basicMode ) this.detectionModes.push({id: "basicSight", enabled: true, range: this.sight.range});
}
/* -------------------------------------------- */
/**
* A helper method to retrieve the underlying data behind one of the Token's attribute bars
* @param {string} barName The named bar to retrieve the attribute for
* @param {object} [options]
* @param {string} [options.alternative] An alternative attribute path to get instead of the default one
* @returns {object|null} The attribute displayed on the Token bar, if any
*/
getBarAttribute(barName, {alternative}={}) {
const attribute = alternative || this[barName]?.attribute;
if ( !attribute || !this.actor ) return null;
const system = this.actor.system;
const isSystemDataModel = system instanceof foundry.abstract.DataModel;
const templateModel = game.model.Actor[this.actor.type];
// Get the current attribute value
const data = foundry.utils.getProperty(system, attribute);
if ( (data === null) || (data === undefined) ) return null;
// Single values
if ( Number.isNumeric(data) ) {
let editable = foundry.utils.hasProperty(templateModel, attribute);
if ( isSystemDataModel ) {
const field = system.schema.getField(attribute);
if ( field ) editable = field instanceof foundry.data.fields.NumberField;
}
return {type: "value", attribute, value: Number(data), editable};
}
// Attribute objects
else if ( ("value" in data) && ("max" in data) ) {
let editable = foundry.utils.hasProperty(templateModel, `${attribute}.value`);
if ( isSystemDataModel ) {
const field = system.schema.getField(`${attribute}.value`);
if ( field ) editable = field instanceof foundry.data.fields.NumberField;
}
return {type: "bar", attribute, value: parseInt(data.value || 0), max: parseInt(data.max || 0), editable};
}
// Otherwise null
return null;
}
/* -------------------------------------------- */
/**
* Test whether a Token has a specific status effect.
* @param {string} statusId The status effect ID as defined in CONFIG.statusEffects
* @returns {boolean} Does the Actor of the Token have this status effect?
*/
hasStatusEffect(statusId) {
return this.actor?.statuses.has(statusId) ?? false;
}
/* -------------------------------------------- */
/* Combat Operations */
/* -------------------------------------------- */
/**
* Add or remove this Token from a Combat encounter.
* @param {object} [options={}] Additional options passed to TokenDocument.createCombatants or
* TokenDocument.deleteCombatants
* @param {boolean} [options.active] Require this token to be an active Combatant or to be removed.
* Otherwise, the current combat state of the Token is toggled.
* @returns {Promise<boolean>} Is this Token now an active Combatant?
*/
async toggleCombatant({active, ...options}={}) {
active ??= !this.inCombat;
if ( active ) await this.constructor.createCombatants([this], options);
else await this.constructor.deleteCombatants([this], options);
return this.inCombat;
}
/* -------------------------------------------- */
/**
* Create or remove Combatants for an array of provided Token objects.
* @param {TokenDocument[]} tokens The tokens which should be added to the Combat
* @param {object} [options={}] Options which modify the toggle operation
* @param {Combat} [options.combat] A specific Combat instance which should be modified. If undefined, the
* current active combat will be modified if one exists. Otherwise, a new
* Combat encounter will be created if the requesting user is a Gamemaster.
* @returns {Promise<Combatant[]>} An array of created Combatant documents
*/
static async createCombatants(tokens, {combat}={}) {
// Identify the target Combat encounter
combat ??= game.combats.viewed;
if ( !combat ) {
if ( game.user.isGM ) {
const cls = getDocumentClass("Combat");
combat = await cls.create({scene: canvas.scene.id, active: true}, {render: false});
}
else throw new Error(game.i18n.localize("COMBAT.NoneActive"));
}
// Add tokens to the Combat encounter
const createData = new Set(tokens).reduce((arr, token) => {
if ( token.inCombat ) return arr;
arr.push({tokenId: token.id, sceneId: token.parent.id, actorId: token.actorId, hidden: token.hidden});
return arr;
}, []);
return combat.createEmbeddedDocuments("Combatant", createData);
}
/* -------------------------------------------- */
/**
* Remove Combatants for the array of provided Tokens.
* @param {TokenDocument[]} tokens The tokens which should removed from the Combat
* @param {object} [options={}] Options which modify the operation
* @param {Combat} [options.combat] A specific Combat instance from which Combatants should be deleted
* @returns {Promise<Combatant[]>} An array of deleted Combatant documents
*/
static async deleteCombatants(tokens, {combat}={}) {
combat ??= game.combats.viewed;
const tokenIds = new Set(tokens.map(t => t.id));
const combatantIds = combat.combatants.reduce((ids, c) => {
if ( tokenIds.has(c.tokenId) ) ids.push(c.id);
return ids;
}, []);
return combat.deleteEmbeddedDocuments("Combatant", combatantIds);
}
/* -------------------------------------------- */
/* Actor Data Operations */
/* -------------------------------------------- */
/**
* Convenience method to change a token vision mode.
* @param {string} visionMode The vision mode to apply to this token.
* @param {boolean} [defaults=true] If the vision mode should be updated with its defaults.
* @returns {Promise<*>}
*/
async updateVisionMode(visionMode, defaults=true) {
if ( !(visionMode in CONFIG.Canvas.visionModes) ) {
throw new Error("The provided vision mode does not exist in CONFIG.Canvas.visionModes");
}
let update = {sight: {visionMode: visionMode}};
if ( defaults ) {
const defaults = CONFIG.Canvas.visionModes[visionMode].vision.defaults;
for ( const [key, value] of Object.entries(defaults)) {
if ( value === undefined ) continue;
update.sight[key] = value;
}
}
return this.update(update);
}
/* -------------------------------------------- */
/** @inheritdoc */
getEmbeddedCollection(embeddedName) {
if ( this.isLinked ) return super.getEmbeddedCollection(embeddedName);
switch ( embeddedName ) {
case "Actor":
this.actors.set(this.actorId, this.actor);
return this.actors;
case "Item":
return this.actor.items;
case "ActiveEffect":
return this.actor.effects;
}
return super.getEmbeddedCollection(embeddedName);
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @inheritDoc */
_onCreate(data, options, userId) {
// Initialize the regions of this token
for ( const id of this._regions ) {
const region = this.parent.regions.get(id);
if ( !region ) continue;
this.regions.add(region);
region.tokens.add(this);
}
super._onCreate(data, options, userId);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _preUpdate(changed, options, user) {
const allowed = await super._preUpdate(changed, options, user);
if ( allowed === false ) return false;
if ( "actorId" in changed ) options.previousActorId = this.actorId;
if ( "actorData" in changed ) {
foundry.utils.logCompatibilityWarning("This update operation includes an update to the Token's actorData "
+ "property, which is deprecated. Please perform updates via the synthetic Actor instead, accessible via the "
+ "'actor' getter.", {since: 11, until: 13});
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_onUpdate(changed, options, userId) {
const configs = Object.values(this.apps).filter(app => app instanceof TokenConfig);
configs.forEach(app => {
if ( app.preview ) options.animate = false;
app._previewChanges(changed);
});
// If the Actor association has changed, expire the cached Token actor
if ( ("actorId" in changed) || ("actorLink" in changed) ) {
const previousActor = game.actors.get(options.previousActorId);
if ( previousActor ) {
Object.values(previousActor.apps).forEach(app => app.close({submit: false}));
previousActor._unregisterDependentToken(this);
}
this.delta._createSyntheticActor({ reinitializeCollections: true });
}
// Handle region changes
const priorRegionIds = options._priorRegions?.[this.id];
if ( priorRegionIds ) this.#onUpdateRegions(priorRegionIds);
// Handle movement
if ( game.user.id === userId ) {
const origin = options._priorPosition?.[this.id];
if ( origin ) this.#triggerMoveRegionEvents(origin, options.teleport === true, options.forced === true);
}
// Post-update the Token itself
super._onUpdate(changed, options, userId);
configs.forEach(app => app._previewChanges());
}
/* -------------------------------------------- */
/**
* Handle changes to the regions this token is in.
* @param {string[]} priorRegionIds The IDs of the prior regions
*/
#onUpdateRegions(priorRegionIds) {
// Update the regions of this token
this.regions.clear();
for ( const id of this._regions ) {
const region = this.parent.regions.get(id);
if ( !region ) continue;
this.regions.add(region);
}
// Update tokens of regions
const priorRegions = new Set();
for ( const id of priorRegionIds ) {
const region = this.parent.regions.get(id);
if ( region ) priorRegions.add(region);
}
for ( const region of priorRegions ) region.tokens.delete(this);
for ( const region of this.regions ) region.tokens.add(this);
}
/* -------------------------------------------- */
/**
* Trigger TOKEN_MOVE, TOKEN_MOVE_IN, and TOKEN_MOVE_OUT events.
* @param {{x: number, y: number, elevation: number}} [origin] The origin of movement
* @param {boolean} teleport Teleporation?
* @param {boolean} forced Forced movement?
*/
#triggerMoveRegionEvents(origin, teleport, forced) {
if ( !this.parent.isView || !this.object ) return;
const E = CONST.REGION_EVENTS;
const elevation = this.elevation;
const destination = {x: this.x, y: this.y, elevation};
for ( const region of this.parent.regions ) {
if ( !region.object ) continue;
if ( !region.behaviors.some(b => !b.disabled && (b.hasEvent(E.TOKEN_MOVE)
|| b.hasEvent(E.TOKEN_MOVE_IN) || b.hasEvent(E.TOKEN_MOVE_OUT))) ) continue;
const segments = this.object.segmentizeRegionMovement(region.object, [origin, destination], {teleport});
if ( segments.length === 0 ) continue;
const T = Region.MOVEMENT_SEGMENT_TYPES;
const first = segments[0].type;
const last = segments.at(-1).type;
const eventData = {token: this, origin, destination, teleport, forced, segments};
if ( (first === T.ENTER) && (last !== T.EXIT) ) region._triggerEvent(E.TOKEN_MOVE_IN, eventData);
region._triggerEvent(E.TOKEN_MOVE, eventData);
if ( (first !== T.ENTER) && (last === T.EXIT) ) region._triggerEvent(E.TOKEN_MOVE_OUT, eventData);
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_onDelete(options, userId) {
if ( game.user.id === userId ) {
// noinspection ES6MissingAwait
game.combats._onDeleteToken(this.parent.id, this.id);
}
super._onDelete(options, userId);
this.baseActor?._unregisterDependentToken(this);
}
/* -------------------------------------------- */
/**
* Identify the Regions the Token currently is or is going to be in after the changes are applied.
* @param {object} [changes] The changes.
* @returns {string[]|void} The Region IDs the token is (sorted), if it could be determined.
*/
#identifyRegions(changes={}) {
if ( !this.parent?.isView ) return;
const regionIds = [];
let token;
for ( const region of this.parent.regions ) {
if ( !region.object ) continue;
token ??= this.clone(changes);
const isInside = token.object.testInsideRegion(region.object);
if ( isInside ) regionIds.push(region.id);
}
token?.object.destroy({children: true});
return regionIds.sort();
}
/* -------------------------------------------- */
/** @inheritdoc */
static async _preCreateOperation(documents, operation, user) {
const allowed = await super._preCreateOperation(documents, operation, user);
if ( allowed === false ) return false;
// Identify and set the regions the token is in
for ( const document of documents ) document.updateSource({_regions: document.#identifyRegions() ?? []});
}
/* -------------------------------------------- */
/** @inheritDoc */
static async _preUpdateOperation(documents, operation, user) {
const allowed = await super._preUpdateOperation(documents, operation, user);
if ( allowed === false ) return false;
await TokenDocument.#preUpdateMovement(documents, operation, user);
TokenDocument.#preUpdateRegions(documents, operation, user);
}
/* -------------------------------------------- */
/**
* Handle Regions potentially stopping movement.
* @param {TokenDocument[]} documents Document instances to be updated
* @param {DatabaseUpdateOperation} operation Parameters of the database update operation
* @param {User} user The User requesting the update operation
*/
static async #preUpdateMovement(documents, operation, user) {
if ( !operation.parent.isView ) return;
// Handle regions stopping movement
const teleport = operation.teleport === true;
for ( let i = 0; i < documents.length; i++ ) {
const document = documents[i];
if ( !document.object ) continue;
const changes = operation.updates[i];
// No action need unless position/elevation is changed
if ( !(("x" in changes) || ("y" in changes) || ("elevation" in changes)) ) continue;
// Prepare origin and destination
const {x: originX, y: originY, elevation: originElevation} = document;
const origin = {x: originX, y: originY, elevation: originElevation};
const destinationX = changes.x ?? originX;
const destinationY = changes.y ?? originY;
const destinationElevation = changes.elevation ?? originElevation;
const destination = {x: destinationX, y: destinationY, elevation: destinationElevation};
// We look for the closest position to the origin where movement is broken
let stopDestination;
let stopDistance;
// Iterate regions and test movement
for ( const region of document.parent.regions ) {
if ( !region.object ) continue;
// Collect behaviors that can break movement
const behaviors = region.behaviors.filter(b => !b.disabled && b.hasEvent(CONST.REGION_EVENTS.TOKEN_PRE_MOVE));
if ( behaviors.length === 0 ) continue;
// Reset token so that it isn't in an animated state
if ( document.object.animationContexts.size !== 0 ) document.reset();
// Break the movement into its segments
const segments = document.object.segmentizeRegionMovement(region.object, [origin, destination], {teleport});
if ( segments.length === 0 ) continue;
// Create the TOKEN_PRE_MOVE event
const event = {
name: CONST.REGION_EVENTS.TOKEN_PRE_MOVE,
data: {token: document, origin, destination, teleport, segments},
region,
user
};
// Find the closest destination where movement is broken
for ( const behavior of behaviors ) {
// Dispatch event
try {
await behavior._handleRegionEvent(event);
} catch(e) {
console.error(e);
}
// Check if the destination of the event data was modified
const destination = event.data.destination;
if ( (destination.x === destinationX) && (destination.y === destinationY)
&& (destination.elevation === destinationElevation) ) continue;
// Choose the closer destination
const distance = Math.hypot(
destination.x - origin.x,
destination.y - origin.y,
(destination.elevation - origin.elevation) * canvas.dimensions.distancePixels
);
if ( !stopDestination || (distance < stopDistance) ) {
stopDestination = {x: destination.x, y: destination.y, elevation: destination.elevation};
stopDistance = distance;
}
// Reset the destination
event.data.destination = {x: destinationX, y: destinationY, elevation: destinationElevation};
}
}
// Update the destination to the stop position if the movement is broken
if ( stopDestination ) {
changes.x = stopDestination.x;
changes.y = stopDestination.y;
changes.elevation = stopDestination.elevation;
}
}
}
/* -------------------------------------------- */
/**
* Identify and update the regions this Token is going to be in if necessary.
* @param {TokenDocument[]} documents Document instances to be updated
* @param {DatabaseUpdateOperation} operation Parameters of the database update operation
*/
static #preUpdateRegions(documents, operation) {
if ( !operation.parent.isView ) return;
// Update the regions the token is in
for ( let i = 0; i < documents.length; i++ ) {
const document = documents[i];
const changes = operation.updates[i];
if ( document._couldRegionsChange(changes) ) changes._regions = document.#identifyRegions(changes);
}
}
/* -------------------------------------------- */
/** @override */
static async _onCreateOperation(documents, operation, user) {
for ( const token of documents ) {
for ( const region of token.regions ) {
// noinspection ES6MissingAwait
region._handleEvent({
name: CONST.REGION_EVENTS.TOKEN_ENTER,
data: {token},
region,
user
});
}
}
}
/* -------------------------------------------- */
/** @override */
static async _onUpdateOperation(documents, operation, user) {
if ( !operation._priorRegions ) return;
for ( const token of documents ) {
const priorRegionIds = operation._priorRegions[token.id];
if ( !priorRegionIds ) continue;
const priorRegions = new Set();
for ( const id of priorRegionIds ) {
const region = token.parent.regions.get(id);
if ( region ) priorRegions.add(region);
}
const addedRegions = token.regions.difference(priorRegions);
const removedRegions = priorRegions.difference(token.regions);
for ( const region of removedRegions ) {
// noinspection ES6MissingAwait
region._handleEvent({
name: CONST.REGION_EVENTS.TOKEN_EXIT,
data: {token},
region,
user
});
}
for ( const region of addedRegions ) {
// noinspection ES6MissingAwait
region._handleEvent({
name: CONST.REGION_EVENTS.TOKEN_ENTER,
data: {token},
region,
user
});
}
}
}
/* -------------------------------------------- */
/** @override */
static async _onDeleteOperation(documents, operation, user) {
const regionEvents = [];
for ( const token of documents ) {
for ( const region of token.regions ) {
region.tokens.delete(token);
regionEvents.push({
name: CONST.REGION_EVENTS.TOKEN_EXIT,
data: {token},
region,
user
});
}
token.regions.clear();
}
for ( const event of regionEvents ) {
// noinspection ES6MissingAwait
event.region._handleEvent(event);
}
}
/* -------------------------------------------- */
/**
* Is to Token document updated such that the Regions the Token is contained in may change?
* Called as part of the preUpdate workflow.
* @param {object} changes The changes.
* @returns {boolean} Could this Token update change Region containment?
* @protected
*/
_couldRegionsChange(changes) {
const positionChange = ("x" in changes) || ("y" in changes);
const elevationChange = "elevation" in changes;
const sizeChange = ("width" in changes) || ("height" in changes);
const shapeChange = this.parent.grid.isHexagonal && ("hexagonalShape" in changes);
return positionChange || elevationChange || sizeChange || shapeChange;
}
/* -------------------------------------------- */
/* Actor Delta Operations */
/* -------------------------------------------- */
/**
* Support the special case descendant document changes within an ActorDelta.
* The descendant documents themselves are configured to have a synthetic Actor as their parent.
* We need this to ensure that the ActorDelta receives these events which do not bubble up.
* @inheritDoc
*/
_preCreateDescendantDocuments(parent, collection, data, options, userId) {
if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
}
/* -------------------------------------------- */
/** @inheritDoc */
_preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
}
/* -------------------------------------------- */
/** @inheritDoc */
_preDeleteDescendantDocuments(parent, collection, ids, options, userId) {
if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
}
/* -------------------------------------------- */
/** @inheritDoc */
_onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
this._onRelatedUpdate(data, options);
}
/* -------------------------------------------- */
/** @inheritDoc */
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
this._onRelatedUpdate(changes, options);
}
/* -------------------------------------------- */
/** @inheritDoc */
_onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
this._onRelatedUpdate({}, options);
}
/* -------------------------------------------- */
/**
* When the base Actor for a TokenDocument changes, we may need to update its Actor instance
* @param {object} update
* @param {object} options
* @internal
*/
_onUpdateBaseActor(update={}, options={}) {
// Update synthetic Actor data
if ( !this.isLinked && this.delta ) {
this.delta.updateSyntheticActor();
for ( const collection of Object.values(this.delta.collections) ) collection.initialize({ full: true });
this.actor.sheet.render(false, {renderContext: "updateActor"});
}
this._onRelatedUpdate(update, options);
}
/* -------------------------------------------- */
/**
* Whenever the token's actor delta changes, or the base actor changes, perform associated refreshes.
* @param {object} [update] The update delta.
* @param {Partial<DatabaseUpdateOperation>} [operation] The database operation that was performed
* @protected
*/
_onRelatedUpdate(update={}, operation={}) {
// Update tracked Combat resource
const c = this.combatant;
if ( c && foundry.utils.hasProperty(update.system || {}, game.combat.settings.resource) ) {
c.updateResource();
}
if ( this.inCombat ) ui.combat.render();
// Trigger redraws on the token
if ( this.parent.isView ) {
if ( this.object?.hasActiveHUD ) canvas.tokens.hud.render();
this.object?.renderFlags.set({refreshBars: true, redrawEffects: true});
const configs = Object.values(this.apps).filter(app => app instanceof TokenConfig);
configs.forEach(app => {
app.preview?.updateSource({delta: this.toObject().delta}, {diff: false, recursive: false});
app.preview?.object?.renderFlags.set({refreshBars: true, redrawEffects: true});
});
}
}
/* -------------------------------------------- */
/**
* @typedef {object} TrackedAttributesDescription
* @property {string[][]} bar A list of property path arrays to attributes with both a value and a max property.
* @property {string[][]} value A list of property path arrays to attributes that have only a value property.
*/
/**
* Get an Array of attribute choices which could be tracked for Actors in the Combat Tracker
* @param {object|DataModel|typeof DataModel|SchemaField|string} [data] The object to explore for attributes, or an
* Actor type.
* @param {string[]} [_path]
* @returns {TrackedAttributesDescription}
*/
static getTrackedAttributes(data, _path=[]) {
// Case 1 - Infer attributes from schema structure.
if ( (data instanceof foundry.abstract.DataModel) || foundry.utils.isSubclass(data, foundry.abstract.DataModel) ) {
return this._getTrackedAttributesFromSchema(data.schema, _path);
}
if ( data instanceof foundry.data.fields.SchemaField ) return this._getTrackedAttributesFromSchema(data, _path);
// Case 2 - Infer attributes from object structure.
if ( ["Object", "Array"].includes(foundry.utils.getType(data)) ) {
return this._getTrackedAttributesFromObject(data, _path);
}
// Case 3 - Retrieve explicitly configured attributes.
if ( !data || (typeof data === "string") ) {
const config = this._getConfiguredTrackedAttributes(data);
if ( config ) return config;
data = undefined;
}
// Track the path and record found attributes
if ( data !== undefined ) return {bar: [], value: []};
// Case 4 - Infer attributes from system template.
const bar = new Set();
const value = new Set();
for ( let [type, model] of Object.entries(game.model.Actor) ) {
const dataModel = CONFIG.Actor.dataModels?.[type];
const inner = this.getTrackedAttributes(dataModel ?? model, _path);
inner.bar.forEach(attr => bar.add(attr.join(".")));
inner.value.forEach(attr => value.add(attr.join(".")));
}
return {
bar: Array.from(bar).map(attr => attr.split(".")),
value: Array.from(value).map(attr => attr.split("."))
};
}
/* -------------------------------------------- */
/**
* Retrieve an Array of attribute choices from a plain object.
* @param {object} data The object to explore for attributes.
* @param {string[]} _path
* @returns {TrackedAttributesDescription}
* @protected
*/
static _getTrackedAttributesFromObject(data, _path=[]) {
const attributes = {bar: [], value: []};
// Recursively explore the object
for ( let [k, v] of Object.entries(data) ) {
let p = _path.concat([k]);
// Check objects for both a "value" and a "max"
if ( v instanceof Object ) {
if ( k === "_source" ) continue;
const isBar = ("value" in v) && ("max" in v);
if ( isBar ) attributes.bar.push(p);
else {
const inner = this.getTrackedAttributes(data[k], p);
attributes.bar.push(...inner.bar);
attributes.value.push(...inner.value);
}
}
// Otherwise, identify values which are numeric or null
else if ( Number.isNumeric(v) || (v === null) ) {
attributes.value.push(p);
}
}
return attributes;
}
/* -------------------------------------------- */
/**
* Retrieve an Array of attribute choices from a SchemaField.
* @param {SchemaField} schema The schema to explore for attributes.
* @param {string[]} _path
* @returns {TrackedAttributesDescription}
* @protected
*/
static _getTrackedAttributesFromSchema(schema, _path=[]) {
const attributes = {bar: [], value: []};
for ( const [name, field] of Object.entries(schema.fields) ) {
const p = _path.concat([name]);
if ( field instanceof foundry.data.fields.NumberField ) attributes.value.push(p);
const isSchema = field instanceof foundry.data.fields.SchemaField;
const isModel = field instanceof foundry.data.fields.EmbeddedDataField;
if ( isSchema || isModel ) {
const schema = isModel ? field.model.schema : field;
const isBar = schema.has("value") && schema.has("max");
if ( isBar ) attributes.bar.push(p);
else {
const inner = this.getTrackedAttributes(schema, p);
attributes.bar.push(...inner.bar);
attributes.value.push(...inner.value);
}
}
}
return attributes;
}
/* -------------------------------------------- */
/**
* Retrieve any configured attributes for a given Actor type.
* @param {string} [type] The Actor type.
* @returns {TrackedAttributesDescription|void}
* @protected
*/
static _getConfiguredTrackedAttributes(type) {
// If trackable attributes are not configured fallback to the system template
if ( foundry.utils.isEmpty(CONFIG.Actor.trackableAttributes) ) return;
// If the system defines trackableAttributes per type
let config = foundry.utils.deepClone(CONFIG.Actor.trackableAttributes[type]);
// Otherwise union all configured trackable attributes
if ( foundry.utils.isEmpty(config) ) {
const bar = new Set();
const value = new Set();
for ( const attrs of Object.values(CONFIG.Actor.trackableAttributes) ) {
attrs.bar.forEach(bar.add, bar);
attrs.value.forEach(value.add, value);
}
config = { bar: Array.from(bar), value: Array.from(value) };
}
// Split dot-separate attribute paths into arrays
Object.keys(config).forEach(k => config[k] = config[k].map(attr => attr.split(".")));
return config;
}
/* -------------------------------------------- */
/**
* Inspect the Actor data model and identify the set of attributes which could be used for a Token Bar.
* @param {object} attributes The tracked attributes which can be chosen from
* @returns {object} A nested object of attribute choices to display
*/
static getTrackedAttributeChoices(attributes) {
attributes = attributes || this.getTrackedAttributes();
const barGroup = game.i18n.localize("TOKEN.BarAttributes");
const valueGroup = game.i18n.localize("TOKEN.BarValues");
const bars = attributes.bar.map(v => {
const a = v.join(".");
return {group: barGroup, value: a, label: a};
});
bars.sort((a, b) => a.value.compare(b.value));
const values = attributes.value.map(v => {
const a = v.join(".");
return {group: valueGroup, value: a, label: a};
});
values.sort((a, b) => a.value.compare(b.value));
return bars.concat(values);
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
getActor() {
foundry.utils.logCompatibilityWarning("TokenDocument#getActor has been deprecated. Please use the "
+ "TokenDocument#actor getter to retrieve the Actor instance that the TokenDocument represents, or use "
+ "TokenDocument#delta#apply to generate a new synthetic Actor instance.");
return this.delta?.apply() ?? this.baseActor ?? null;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get actorData() {
foundry.utils.logCompatibilityWarning("You are accessing TokenDocument#actorData which is deprecated. Source data "
+ "may be retrieved via TokenDocument#delta but all modifications/access should be done via the synthetic Actor "
+ "at TokenDocument#actor if possible.", {since: 11, until: 13});
return this.delta.toObject();
}
set actorData(actorData) {
foundry.utils.logCompatibilityWarning("You are accessing TokenDocument#actorData which is deprecated. Source data "
+ "may be retrieved via TokenDocument#delta but all modifications/access should be done via the synthetic Actor "
+ "at TokenDocument#actor if possible.", {since: 11, until: 13});
const id = this.delta.id;
this.delta = new ActorDelta.implementation({...actorData, _id: id}, {parent: this});
}
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
async toggleActiveEffect(effectData, {overlay=false, active}={}) {
foundry.utils.logCompatibilityWarning("TokenDocument#toggleActiveEffect is deprecated in favor of "
+ "Actor#toggleStatusEffect", {since: 12, until: 14});
if ( !this.actor || !effectData.id ) return false;
return !!(await this.actor.toggleStatusEffect(effectData.id, {active, overlay}));
}
}
/* -------------------------------------------- */
/* Proxy Prototype Token Methods */
/* -------------------------------------------- */
foundry.data.PrototypeToken.prototype.getBarAttribute = TokenDocument.prototype.getBarAttribute;