/** * @typedef {Object} CombatHistoryData * @property {number|null} round * @property {number|null} turn * @property {string|null} tokenId * @property {string|null} combatantId */ /** * The client-side Combat document which extends the common BaseCombat model. * * @extends foundry.documents.BaseCombat * @mixes ClientDocumentMixin * * @see {@link Combats} The world-level collection of Combat documents * @see {@link Combatant} The Combatant embedded document which exists within a Combat document * @see {@link CombatConfig} The Combat configuration application */ class Combat extends ClientDocumentMixin(foundry.documents.BaseCombat) { /** * Track the sorted turn order of this combat encounter * @type {Combatant[]} */ turns = this.turns || []; /** * Record the current round, turn, and tokenId to understand changes in the encounter state * @type {CombatHistoryData} */ current = this._getCurrentState(); /** * Track the previous round, turn, and tokenId to understand changes in the encounter state * @type {CombatHistoryData} */ previous = undefined; /** * The configuration setting used to record Combat preferences * @type {string} */ static CONFIG_SETTING = "combatTrackerConfig"; /* -------------------------------------------- */ /* Properties */ /* -------------------------------------------- */ /** * Get the Combatant who has the current turn. * @type {Combatant} */ get combatant() { return this.turns[this.turn]; } /* -------------------------------------------- */ /** * Get the Combatant who has the next turn. * @type {Combatant} */ get nextCombatant() { if ( this.turn === this.turns.length - 1 ) return this.turns[0]; return this.turns[this.turn + 1]; } /* -------------------------------------------- */ /** * Return the object of settings which modify the Combat Tracker behavior * @type {object} */ get settings() { return CombatEncounters.settings; } /* -------------------------------------------- */ /** * Has this combat encounter been started? * @type {boolean} */ get started() { return this.round > 0; } /* -------------------------------------------- */ /** @inheritdoc */ get visible() { return true; } /* -------------------------------------------- */ /** * Is this combat active in the current scene? * @type {boolean} */ get isActive() { if ( !this.scene ) return this.active; return this.scene.isView && this.active; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Set the current Combat encounter as active within the Scene. * Deactivate all other Combat encounters within the viewed Scene and set this one as active * @param {object} [options] Additional context to customize the update workflow * @returns {Promise} */ async activate(options) { const updates = this.collection.reduce((arr, c) => { if ( c.isActive ) arr.push({_id: c.id, active: false}); return arr; }, []); updates.push({_id: this.id, active: true}); return this.constructor.updateDocuments(updates, options); } /* -------------------------------------------- */ /** @override */ prepareDerivedData() { if ( this.combatants.size && !this.turns?.length ) this.setupTurns(); } /* -------------------------------------------- */ /** * Get a Combatant using its Token id * @param {string|TokenDocument} token A Token ID or a TokenDocument instance * @returns {Combatant[]} An array of Combatants which represent the Token */ getCombatantsByToken(token) { const tokenId = token instanceof TokenDocument ? token.id : token; return this.combatants.filter(c => c.tokenId === tokenId); } /* -------------------------------------------- */ /** * Get a Combatant that represents the given Actor or Actor ID. * @param {string|Actor} actor An Actor ID or an Actor instance * @returns {Combatant[]} */ getCombatantsByActor(actor) { const isActor = actor instanceof Actor; if ( isActor && actor.isToken ) return this.getCombatantsByToken(actor.token); const actorId = isActor ? actor.id : actor; return this.combatants.filter(c => c.actorId === actorId); } /* -------------------------------------------- */ /** * Begin the combat encounter, advancing to round 1 and turn 1 * @returns {Promise} */ async startCombat() { this._playCombatSound("startEncounter"); const updateData = {round: 1, turn: 0}; Hooks.callAll("combatStart", this, updateData); return this.update(updateData); } /* -------------------------------------------- */ /** * Advance the combat to the next round * @returns {Promise} */ async nextRound() { let turn = this.turn === null ? null : 0; // Preserve the fact that it's no-one's turn currently. if ( this.settings.skipDefeated && (turn !== null) ) { turn = this.turns.findIndex(t => !t.isDefeated); if (turn === -1) { ui.notifications.warn("COMBAT.NoneRemaining", {localize: true}); turn = 0; } } let advanceTime = Math.max(this.turns.length - this.turn, 0) * CONFIG.time.turnTime; advanceTime += CONFIG.time.roundTime; let nextRound = this.round + 1; // Update the document, passing data through a hook first const updateData = {round: nextRound, turn}; const updateOptions = {direction: 1, worldTime: {delta: advanceTime}}; Hooks.callAll("combatRound", this, updateData, updateOptions); return this.update(updateData, updateOptions); } /* -------------------------------------------- */ /** * Rewind the combat to the previous round * @returns {Promise} */ async previousRound() { let turn = ( this.round === 0 ) ? 0 : Math.max(this.turns.length - 1, 0); if ( this.turn === null ) turn = null; let round = Math.max(this.round - 1, 0); if ( round === 0 ) turn = null; let advanceTime = -1 * (this.turn || 0) * CONFIG.time.turnTime; if ( round > 0 ) advanceTime -= CONFIG.time.roundTime; // Update the document, passing data through a hook first const updateData = {round, turn}; const updateOptions = {direction: -1, worldTime: {delta: advanceTime}}; Hooks.callAll("combatRound", this, updateData, updateOptions); return this.update(updateData, updateOptions); } /* -------------------------------------------- */ /** * Advance the combat to the next turn * @returns {Promise} */ async nextTurn() { let turn = this.turn ?? -1; let skip = this.settings.skipDefeated; // Determine the next turn number let next = null; if ( skip ) { for ( let [i, t] of this.turns.entries() ) { if ( i <= turn ) continue; if ( t.isDefeated ) continue; next = i; break; } } else next = turn + 1; // Maybe advance to the next round let round = this.round; if ( (this.round === 0) || (next === null) || (next >= this.turns.length) ) { return this.nextRound(); } // Update the document, passing data through a hook first const updateData = {round, turn: next}; const updateOptions = {direction: 1, worldTime: {delta: CONFIG.time.turnTime}}; Hooks.callAll("combatTurn", this, updateData, updateOptions); return this.update(updateData, updateOptions); } /* -------------------------------------------- */ /** * Rewind the combat to the previous turn * @returns {Promise} */ async previousTurn() { if ( (this.turn === 0) && (this.round === 0) ) return this; else if ( (this.turn <= 0) && (this.turn !== null) ) return this.previousRound(); let previousTurn = (this.turn ?? this.turns.length) - 1; // Update the document, passing data through a hook first const updateData = {round: this.round, turn: previousTurn}; const updateOptions = {direction: -1, worldTime: {delta: -1 * CONFIG.time.turnTime}}; Hooks.callAll("combatTurn", this, updateData, updateOptions); return this.update(updateData, updateOptions); } /* -------------------------------------------- */ /** * Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker * @returns {Promise} */ async endCombat() { return Dialog.confirm({ title: game.i18n.localize("COMBAT.EndTitle"), content: `

${game.i18n.localize("COMBAT.EndConfirmation")}

`, yes: () => this.delete() }); } /* -------------------------------------------- */ /** * Toggle whether this combat is linked to the scene or globally available. * @returns {Promise} */ async toggleSceneLink() { const scene = this.scene ? null : (game.scenes.current?.id || null); if ( (scene !== null) && this.combatants.some(c => c.sceneId && (c.sceneId !== scene)) ) { ui.notifications.error("COMBAT.CannotLinkToScene", {localize: true}); return this; } return this.update({scene}); } /* -------------------------------------------- */ /** * Reset all combatant initiative scores, setting the turn back to zero * @returns {Promise} */ async resetAll() { for ( let c of this.combatants ) { c.updateSource({initiative: null}); } return this.update({turn: this.started ? 0 : null, combatants: this.combatants.toObject()}, {diff: false}); } /* -------------------------------------------- */ /** * Roll initiative for one or multiple Combatants within the Combat document * @param {string|string[]} ids A Combatant id or Array of ids for which to roll * @param {object} [options={}] Additional options which modify how initiative rolls are created or presented. * @param {string|null} [options.formula] A non-default initiative formula to roll. Otherwise, the system * default is used. * @param {boolean} [options.updateTurn=true] Update the Combat turn after adding new initiative scores to * keep the turn on the same Combatant. * @param {object} [options.messageOptions={}] Additional options with which to customize created Chat Messages * @returns {Promise} A promise which resolves to the updated Combat document once updates are complete. */ async rollInitiative(ids, {formula=null, updateTurn=true, messageOptions={}}={}) { // Structure input data ids = typeof ids === "string" ? [ids] : ids; const currentId = this.combatant?.id; const chatRollMode = game.settings.get("core", "rollMode"); // Iterate over Combatants, performing an initiative roll for each const updates = []; const messages = []; for ( let [i, id] of ids.entries() ) { // Get Combatant data (non-strictly) const combatant = this.combatants.get(id); if ( !combatant?.isOwner ) continue; // Produce an initiative roll for the Combatant const roll = combatant.getInitiativeRoll(formula); await roll.evaluate(); updates.push({_id: id, initiative: roll.total}); // Construct chat message data let messageData = foundry.utils.mergeObject({ speaker: ChatMessage.getSpeaker({ actor: combatant.actor, token: combatant.token, alias: combatant.name }), flavor: game.i18n.format("COMBAT.RollsInitiative", {name: combatant.name}), flags: {"core.initiativeRoll": true} }, messageOptions); const chatData = await roll.toMessage(messageData, {create: false}); // If the combatant is hidden, use a private roll unless an alternative rollMode was explicitly requested chatData.rollMode = "rollMode" in messageOptions ? messageOptions.rollMode : (combatant.hidden ? CONST.DICE_ROLL_MODES.PRIVATE : chatRollMode ); // Play 1 sound for the whole rolled set if ( i > 0 ) chatData.sound = null; messages.push(chatData); } if ( !updates.length ) return this; // Update multiple combatants await this.updateEmbeddedDocuments("Combatant", updates); // Ensure the turn order remains with the same combatant if ( updateTurn && currentId ) { await this.update({turn: this.turns.findIndex(t => t.id === currentId)}); } // Create multiple chat messages await ChatMessage.implementation.create(messages); return this; } /* -------------------------------------------- */ /** * Roll initiative for all combatants which have not already rolled * @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method */ async rollAll(options) { const ids = this.combatants.reduce((ids, c) => { if ( c.isOwner && (c.initiative === null) ) ids.push(c.id); return ids; }, []); return this.rollInitiative(ids, options); } /* -------------------------------------------- */ /** * Roll initiative for all non-player actors who have not already rolled * @param {object} [options={}] Additional options forwarded to the Combat.rollInitiative method */ async rollNPC(options={}) { const ids = this.combatants.reduce((ids, c) => { if ( c.isOwner && c.isNPC && (c.initiative === null) ) ids.push(c.id); return ids; }, []); return this.rollInitiative(ids, options); } /* -------------------------------------------- */ /** * Assign initiative for a single Combatant within the Combat encounter. * Update the Combat turn order to maintain the same combatant as the current turn. * @param {string} id The combatant ID for which to set initiative * @param {number} value A specific initiative value to set */ async setInitiative(id, value) { const combatant = this.combatants.get(id, {strict: true}); await combatant.update({initiative: value}); } /* -------------------------------------------- */ /** * Return the Array of combatants sorted into initiative order, breaking ties alphabetically by name. * @returns {Combatant[]} */ setupTurns() { this.turns ||= []; // Determine the turn order and the current turn const turns = this.combatants.contents.sort(this._sortCombatants); if ( this.turn !== null) this.turn = Math.clamp(this.turn, 0, turns.length-1); // Update state tracking let c = turns[this.turn]; this.current = this._getCurrentState(c); // One-time initialization of the previous state if ( !this.previous ) this.previous = this.current; // Return the array of prepared turns return this.turns = turns; } /* -------------------------------------------- */ /** * Debounce changes to the composition of the Combat encounter to de-duplicate multiple concurrent Combatant changes. * If this is the currently viewed encounter, re-render the CombatTracker application. * @type {Function} */ debounceSetup = foundry.utils.debounce(() => { this.current.round = this.round; this.current.turn = this.turn; this.setupTurns(); if ( ui.combat.viewed === this ) ui.combat.render(); }, 50); /* -------------------------------------------- */ /** * Update active effect durations for all actors present in this Combat encounter. */ updateCombatantActors() { for ( const combatant of this.combatants ) combatant.actor?.render(false, {renderContext: "updateCombat"}); } /* -------------------------------------------- */ /** * Loads the registered Combat Theme (if any) and plays the requested type of sound. * If multiple exist for that type, one is chosen at random. * @param {string} announcement The announcement that should be played: "startEncounter", "nextUp", or "yourTurn". * @protected */ _playCombatSound(announcement) { if ( !CONST.COMBAT_ANNOUNCEMENTS.includes(announcement) ) { throw new Error(`"${announcement}" is not a valid Combat announcement type`); } const theme = CONFIG.Combat.sounds[game.settings.get("core", "combatTheme")]; if ( !theme || theme === "none" ) return; const sounds = theme[announcement]; if ( !sounds ) return; const src = sounds[Math.floor(Math.random() * sounds.length)]; game.audio.play(src, {context: game.audio.interface}); } /* -------------------------------------------- */ /** * Define how the array of Combatants is sorted in the displayed list of the tracker. * This method can be overridden by a system or module which needs to display combatants in an alternative order. * The default sorting rules sort in descending order of initiative using combatant IDs for tiebreakers. * @param {Combatant} a Some combatant * @param {Combatant} b Some other combatant * @protected */ _sortCombatants(a, b) { const ia = Number.isNumeric(a.initiative) ? a.initiative : -Infinity; const ib = Number.isNumeric(b.initiative) ? b.initiative : -Infinity; return (ib - ia) || (a.id > b.id ? 1 : -1); } /* -------------------------------------------- */ /** * Refresh the Token HUD under certain circumstances. * @param {Combatant[]} documents A list of Combatant documents that were added or removed. * @protected */ _refreshTokenHUD(documents) { if ( documents.some(doc => doc.token?.object?.hasActiveHUD) ) canvas.tokens.hud.render(); } /* -------------------------------------------- */ /* Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onCreate(data, options, userId) { super._onCreate(data, options, userId); if ( !this.collection.viewed && this.collection.combats.includes(this) ) { ui.combat.initialize({combat: this, render: false}); } this._manageTurnEvents(); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); const priorState = foundry.utils.deepClone(this.current); if ( !this.previous ) this.previous = priorState; // Just in case // Determine the new turn order if ( "combatants" in changed ) this.setupTurns(); // Update all combatants else this.current = this._getCurrentState(); // Update turn or round // Record the prior state and manage turn events const stateChanged = this.#recordPreviousState(priorState); if ( stateChanged && (options.turnEvents !== false) ) this._manageTurnEvents(); // Render applications for Actors involved in the Combat this.updateCombatantActors(); // Render the CombatTracker sidebar if ( (changed.active === true) && this.isActive ) ui.combat.initialize({combat: this}); else if ( "scene" in changed ) ui.combat.initialize(); // Trigger combat sound cues in the active encounter if ( this.active && this.started && priorState.round ) { const play = c => c && (game.user.isGM ? !c.hasPlayerOwner : c.isOwner); if ( play(this.combatant) ) this._playCombatSound("yourTurn"); else if ( play(this.nextCombatant) ) this._playCombatSound("nextUp"); } } /* -------------------------------------------- */ /** @inheritDoc */ _onDelete(options, userId) { super._onDelete(options, userId); if ( this.collection.viewed === this ) ui.combat.initialize(); if ( userId === game.userId ) this.collection.viewed?.activate(); } /* -------------------------------------------- */ /* Combatant Management Workflows */ /* -------------------------------------------- */ /** @inheritDoc */ _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) { super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId); this.#onModifyCombatants(parent, documents, options); } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) { super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId); this.#onModifyCombatants(parent, documents, options); } /* -------------------------------------------- */ /** @inheritDoc */ _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) { super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId); this.#onModifyCombatants(parent, documents, options); } /* -------------------------------------------- */ /** * Shared actions taken when Combatants are modified within this Combat document. * @param {Document} parent The direct parent of the created Documents, may be this Document or a child * @param {Document[]} documents The array of created Documents * @param {object} options Options which modified the operation */ #onModifyCombatants(parent, documents, options) { const {combatTurn, turnEvents, render} = options; if ( parent === this ) this._refreshTokenHUD(documents); const priorState = foundry.utils.deepClone(this.current); if ( typeof combatTurn === "number" ) this.updateSource({turn: combatTurn}); this.setupTurns(); const turnChange = this.#recordPreviousState(priorState); if ( turnChange && (turnEvents !== false) ) this._manageTurnEvents(); if ( (ui.combat.viewed === parent) && (render !== false) ) ui.combat.render(); } /* -------------------------------------------- */ /** * Get the current history state of the Combat encounter. * @param {Combatant} [combatant] The new active combatant * @returns {CombatHistoryData} * @protected */ _getCurrentState(combatant) { combatant ||= this.combatant; return { round: this.round, turn: this.turn ?? null, combatantId: combatant?.id || null, tokenId: combatant?.tokenId || null }; } /* -------------------------------------------- */ /** * Update the previous turn data. * Compare the state with the new current state. Only update the previous state if there is a difference. * @param {CombatHistoryData} priorState A cloned copy of the current history state before changes * @returns {boolean} Has the combat round or current combatant changed? */ #recordPreviousState(priorState) { const {round, combatantId} = this.current; const turnChange = (combatantId !== priorState.combatantId) || (round !== priorState.round); Object.assign(this.previous, priorState); return turnChange; } /* -------------------------------------------- */ /* Turn Events */ /* -------------------------------------------- */ /** * Manage the execution of Combat lifecycle events. * This method orchestrates the execution of four events in the following order, as applicable: * 1. End Turn * 2. End Round * 3. Begin Round * 4. Begin Turn * Each lifecycle event is an async method, and each is awaited before proceeding. * @returns {Promise} * @protected */ async _manageTurnEvents() { if ( !this.started ) return; // Gamemaster handling only if ( game.users.activeGM?.isSelf ) { const advanceRound = this.current.round > (this.previous.round ?? -1); const advanceTurn = advanceRound || (this.current.turn > (this.previous.turn ?? -1)); const changeCombatant = this.current.combatantId !== this.previous.combatantId; if ( !(advanceTurn || advanceRound || changeCombatant) ) return; // Conclude the prior Combatant turn const prior = this.combatants.get(this.previous.combatantId); if ( (advanceTurn || changeCombatant) && prior ) await this._onEndTurn(prior); // Conclude the prior round if ( advanceRound && this.previous.round ) await this._onEndRound(); // Begin the new round if ( advanceRound ) await this._onStartRound(); // Begin a new Combatant turn const next = this.combatant; if ( (advanceTurn || changeCombatant) && next ) await this._onStartTurn(this.combatant); } // Hooks handled by all clients Hooks.callAll("combatTurnChange", this, this.previous, this.current); } /* -------------------------------------------- */ /** * A workflow that occurs at the end of each Combat Turn. * This workflow occurs after the Combat document update, prior round information exists in this.previous. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @param {Combatant} combatant The Combatant whose turn just ended * @returns {Promise} * @protected */ async _onEndTurn(combatant) { if ( CONFIG.debug.combat ) { console.debug(`${vtt} | Combat End Turn: ${this.combatants.get(this.previous.combatantId).name}`); } // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_END, [combatant]); } /* -------------------------------------------- */ /** * A workflow that occurs at the end of each Combat Round. * This workflow occurs after the Combat document update, prior round information exists in this.previous. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @returns {Promise} * @protected */ async _onEndRound() { if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat End Round: ${this.previous.round}`); // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_END, this.combatants); } /* -------------------------------------------- */ /** * A workflow that occurs at the start of each Combat Round. * This workflow occurs after the Combat document update, new round information exists in this.current. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @returns {Promise} * @protected */ async _onStartRound() { if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Round: ${this.round}`); // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_START, this.combatants); } /* -------------------------------------------- */ /** * A workflow that occurs at the start of each Combat Turn. * This workflow occurs after the Combat document update, new turn information exists in this.current. * This can be overridden to implement system-specific combat tracking behaviors. * This method only executes for one designated GM user. If no GM users are present this method will not be called. * @param {Combatant} combatant The Combatant whose turn just started * @returns {Promise} * @protected */ async _onStartTurn(combatant) { if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Turn: ${this.combatant.name}`); // noinspection ES6MissingAwait this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_START, [combatant]); } /* -------------------------------------------- */ /** * Trigger Region events for Combat events. * @param {string} eventName The event name * @param {Iterable} combatants The combatants to trigger the event for * @returns {Promise} */ async #triggerRegionEvents(eventName, combatants) { const promises = []; for ( const combatant of combatants ) { const token = combatant.token; if ( !token ) continue; for ( const region of token.regions ) { promises.push(region._triggerEvent(eventName, {token, combatant})); } } await Promise.allSettled(promises); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ updateEffectDurations() { const msg = "Combat#updateEffectDurations is renamed to Combat#updateCombatantActors"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.updateCombatantActors(); } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getCombatantByActor(actor) { const combatants = this.getCombatantsByActor(actor); return combatants?.[0] || null; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ getCombatantByToken(token) { const combatants = this.getCombatantsByToken(token); return combatants?.[0] || null; } }