/** * The client-side Combatant document which extends the common BaseCombatant model. * * @extends foundry.documents.BaseCombatant * @mixes ClientDocumentMixin * * @see {@link Combat} The Combat document which contains Combatant embedded documents * @see {@link CombatantConfig} The application which configures a Combatant. */ class Combatant extends ClientDocumentMixin(foundry.documents.BaseCombatant) { /** * The token video source image (if any) * @type {string|null} * @internal */ _videoSrc = null; /** * The current value of the special tracked resource which pertains to this Combatant * @type {object|null} */ resource = null; /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * A convenience alias of Combatant#parent which is more semantically intuitive * @type {Combat|null} */ get combat() { return this.parent; } /* -------------------------------------------- */ /** * This is treated as a non-player combatant if it has no associated actor and no player users who can control it * @type {boolean} */ get isNPC() { return !this.actor || !this.hasPlayerOwner; } /* -------------------------------------------- */ /** * Eschew `ClientDocument`'s redirection to `Combat#permission` in favor of special ownership determination. * @override */ get permission() { if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; return this.getUserLevel(game.user); } /* -------------------------------------------- */ /** @override */ get visible() { return this.isOwner || !this.hidden; } /* -------------------------------------------- */ /** * A reference to the Actor document which this Combatant represents, if any * @type {Actor|null} */ get actor() { if ( this.token ) return this.token.actor; return game.actors.get(this.actorId) || null; } /* -------------------------------------------- */ /** * A reference to the Token document which this Combatant represents, if any * @type {TokenDocument|null} */ get token() { const scene = this.sceneId ? game.scenes.get(this.sceneId) : this.parent?.scene; return scene?.tokens.get(this.tokenId) || null; } /* -------------------------------------------- */ /** * An array of non-Gamemaster Users who have ownership of this Combatant. * @type {User[]} */ get players() { return game.users.filter(u => !u.isGM && this.testUserPermission(u, "OWNER")); } /* -------------------------------------------- */ /** * Has this combatant been marked as defeated? * @type {boolean} */ get isDefeated() { return this.defeated || !!this.actor?.statuses.has(CONFIG.specialStatusEffects.DEFEATED); } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** @inheritdoc */ testUserPermission(user, permission, {exact=false}={}) { if ( user.isGM ) return true; return this.actor?.canUserModify(user, "update") || false; } /* -------------------------------------------- */ /** * Get a Roll object which represents the initiative roll for this Combatant. * @param {string} formula An explicit Roll formula to use for the combatant. * @returns {Roll} The unevaluated Roll instance to use for the combatant. */ getInitiativeRoll(formula) { formula = formula || this._getInitiativeFormula(); const rollData = this.actor?.getRollData() || {}; return Roll.create(formula, rollData); } /* -------------------------------------------- */ /** * Roll initiative for this particular combatant. * @param {string} [formula] A dice formula which overrides the default for this Combatant. * @returns {Promise} The updated Combatant. */ async rollInitiative(formula) { const roll = this.getInitiativeRoll(formula); await roll.evaluate(); return this.update({initiative: roll.total}); } /* -------------------------------------------- */ /** @override */ prepareDerivedData() { // Check for video source and save it if present this._videoSrc = VideoHelper.hasVideoExtension(this.token?.texture.src) ? this.token.texture.src : null; // Assign image for combatant (undefined if the token src image is a video) this.img ||= (this._videoSrc ? undefined : (this.token?.texture.src || this.actor?.img)); this.name ||= this.token?.name || this.actor?.name || game.i18n.localize("COMBAT.UnknownCombatant"); this.updateResource(); } /* -------------------------------------------- */ /** * Update the value of the tracked resource for this Combatant. * @returns {null|object} */ updateResource() { if ( !this.actor || !this.combat ) return this.resource = null; return this.resource = foundry.utils.getProperty(this.actor.system, this.parent.settings.resource) || null; } /* -------------------------------------------- */ /** * Acquire the default dice formula which should be used to roll initiative for this combatant. * Modules or systems could choose to override or extend this to accommodate special situations. * @returns {string} The initiative formula to use for this combatant. * @protected */ _getInitiativeFormula() { return String(CONFIG.Combat.initiative.formula || game.system.initiative); } /* -------------------------------------------- */ /* Database Lifecycle Events */ /* -------------------------------------------- */ /** @override */ static async _preCreateOperation(documents, operation, _user) { const combatant = operation.parent?.combatant; if ( !combatant ) return; const combat = operation.parent.clone(); combat.updateSource({combatants: documents.map(d => d.toObject())}); combat.setupTurns(); operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0); } /* -------------------------------------------- */ /** @override */ static async _preUpdateOperation(_documents, operation, _user) { const combatant = operation.parent?.combatant; if ( !combatant ) return; const combat = operation.parent.clone(); combat.updateSource({combatants: operation.updates}); combat.setupTurns(); if ( operation.turnEvents !== false ) { operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0); } } /* -------------------------------------------- */ /** @override */ static async _preDeleteOperation(_documents, operation, _user) { const combatant = operation.parent?.combatant; if ( !combatant ) return; // Simulate new turns const combat = operation.parent.clone(); for ( const id of operation.ids ) combat.combatants.delete(id); combat.setupTurns(); // If the current combatant was deleted if ( operation.ids.includes(combatant?.id) ) { const {prevSurvivor, nextSurvivor} = operation.parent.turns.reduce((obj, t, i) => { let valid = !operation.ids.includes(t.id); if ( combat.settings.skipDefeated ) valid &&= !t.isDefeated; if ( !valid ) return obj; if ( i < this.turn ) obj.prevSurvivor = t; if ( !obj.nextSurvivor && (i >= this.turn) ) obj.nextSurvivor = t; return obj; }, {}); const survivor = nextSurvivor || prevSurvivor; if ( survivor ) operation.combatTurn = combat.turns.findIndex(t => t.id === survivor.id); } // Otherwise maintain the same combatant turn else operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0); } }