From fc149623cc4e1eeb7a3f094720c6069a8e5f0144 Mon Sep 17 00:00:00 2001 From: CK <31608392+unrealkakeman89@users.noreply.github.com> Date: Wed, 24 Jun 2020 14:20:19 -0400 Subject: [PATCH] Add files via upload --- module/actor/entity.js | 1065 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1065 insertions(+) create mode 100644 module/actor/entity.js diff --git a/module/actor/entity.js b/module/actor/entity.js new file mode 100644 index 00000000..7778b824 --- /dev/null +++ b/module/actor/entity.js @@ -0,0 +1,1065 @@ +import { Dice5e } from "../dice.js"; +import { ShortRestDialog } from "../apps/short-rest.js"; +import { PowerCastDialog } from "../apps/power-cast-dialog.js"; +import { AbilityTemplate } from "../pixi/ability-template.js"; +import {SW5E} from '../config.js'; + + +/** + * Extend the base Actor class to implement additional logic specialized for D&D5e. + */ +export class Actor5e extends Actor { + + /** + * Is this Actor currently polymorphed into some other creature? + * @return {boolean} + */ + get isPolymorphed() { + return this.getFlag("sw5e", "isPolymorphed") || false; + } + + /* -------------------------------------------- */ + + /** + * Augment the basic actor data with additional dynamic data. + */ + prepareData() { + super.prepareData(); + + // Get the Actor's data object + const actorData = this.data; + const data = actorData.data; + const flags = actorData.flags.sw5e || {}; + + // Prepare Character data + if ( actorData.type === "character" ) this._prepareCharacterData(actorData); + else if ( actorData.type === "npc" ) this._prepareNPCData(actorData); + + // Ability modifiers and saves + // Character All Ability Check" and All Ability Save bonuses added when rolled since not a fixed value. + const saveBonus = parseInt(getProperty(data, "bonuses.abilities.save")) || 0; + for (let abl of Object.values(data.abilities)) { + abl.mod = Math.floor((abl.value - 10) / 2); + abl.prof = (abl.proficient || 0) * data.attributes.prof; + abl.save = abl.mod + abl.prof + saveBonus; + } + + // Skill modifiers + const feats = SW5E.characterFlags; + const athlete = flags.remarkableAthlete; + const joat = flags.jackOfAllTrades; + const observant = flags.observantFeat; + let round = Math.floor; + for (let [id, skl] of Object.entries(data.skills)) { + skl.value = parseFloat(skl.value || 0); + skl.bonus = parseInt(skl.bonus || 0); + + // Apply Remarkable Athlete or Jack of all Trades + let multi = skl.value; + if ( athlete && (skl.value === 0) && feats.remarkableAthlete.abilities.includes(skl.ability) ) { + multi = 0.5; + round = Math.ceil; + } + if ( joat && (skl.value === 0 ) ) multi = 0.5; + + // Compute modifier + skl.mod = data.abilities[skl.ability].mod + skl.bonus + round(multi * data.attributes.prof); + + // Compute passive bonus + const passive = observant && (feats.observantFeat.skills.includes(id)) ? 5 : 0; + skl.passive = 10 + skl.mod + passive; + } + + // Determine Initiative Modifier + const init = data.attributes.init; + init.mod = data.abilities.dex.mod; + if ( joat ) init.prof = Math.floor(0.5 * data.attributes.prof); + else if ( athlete ) init.prof = Math.ceil(0.5 * data.attributes.prof); + else init.prof = 0; + init.bonus = init.value + (flags.initiativeAlert ? 5 : 0); + init.total = init.mod + init.prof + init.bonus; + + // Prepare power-casting data + data.attributes.powerdc = this.getPowerDC(data.attributes.powercasting); + // TODO: Only do this IF we have already processed item types (see Entity#initialize) + if ( this.items ) { + this._computePowercastingProgression(actorData); + } + } + + /* -------------------------------------------- */ + + /** + * Prepare Character type specific data + */ + _prepareCharacterData(actorData) { + const data = actorData.data; + + // Determine character level and available hit dice based on owned Class items + const [level, hd] = actorData.items.reduce((arr, item) => { + if ( item.type === "class" ) { + const classLevels = parseInt(item.data.levels) || 1; + arr[0] += classLevels; + arr[1] += classLevels - (parseInt(item.data.hitDiceUsed) || 0); + } + return arr; + }, [0, 0]); + data.details.level = level; + data.attributes.hd = hd; + + // Character proficiency bonus + data.attributes.prof = Math.floor((level + 7) / 4); + + // Experience required for next level + const xp = data.details.xp; + xp.max = this.getLevelExp(level || 1); + const prior = this.getLevelExp(level - 1 || 0); + const required = xp.max - prior; + const pct = Math.round((xp.value - prior) * 100 / required); + xp.pct = Math.clamped(pct, 0, 100); + } + + /* -------------------------------------------- */ + + /** + * Prepare NPC type specific data + */ + _prepareNPCData(actorData) { + const data = actorData.data; + + // Kill Experience + data.details.xp.value = this.getCRExp(data.details.cr); + + // Proficiency + data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4); + + // Powercaster Level + if ( data.attributes.powercasting && !data.details.powerLevel ) { + data.details.powerLevel = Math.max(data.details.cr, 1); + } + } + + /* -------------------------------------------- */ + + /** + * Prepare data related to the power-casting capabilities of the Actor + * @private + */ + _computePowercastingProgression (actorData) { + const powers = actorData.data.powers; + const isNPC = actorData.type === 'npc'; + + // Translate the list of classes into power-casting progression + const progression = { + total: 0, + slot: 0, + pact: 0 + }; + + // Keep track of the last seen caster in case we're in a single-caster situation. + let caster = null; + + // Tabulate the total power-casting progression + const classes = this.data.items.filter(i => i.type === "class"); + for ( let cls of classes ) { + const d = cls.data; + if ( d.powercasting === "none" ) continue; + const levels = d.levels; + const prog = d.powercasting; + + // Accumulate levels + if ( prog !== "pact" ) { + caster = cls; + progression.total++; + } + switch (prog) { + case 'third': progression.slot += Math.floor(levels / 3); break; + case 'half': progression.slot += Math.floor(levels / 2); break; + case 'full': progression.slot += levels; break; + case 'artificer': progression.slot += Math.ceil(levels / 2); break; + case 'pact': progression.pact += levels; break; + } + } + + // EXCEPTION: single-classed non-full progression rounds up, rather than down + const isSingleClass = (progression.total === 1) && (progression.slot > 0); + if (!isNPC && isSingleClass && ['half', 'third'].includes(caster.data.powercasting) ) { + const denom = caster.data.powercasting === 'third' ? 3 : 2; + progression.slot = Math.ceil(caster.data.levels / denom); + } + + // EXCEPTION: NPC with an explicit powercaster level + if (isNPC && actorData.data.details.powerLevel) { + progression.slot = actorData.data.details.powerLevel; + } + + // Look up the number of slots per level from the progression table + const levels = Math.clamped(progression.slot, 0, 20); + const slots = SW5E.SPELL_SLOT_TABLE[levels - 1] || []; + for ( let [n, lvl] of Object.entries(powers) ) { + let i = parseInt(n.slice(-1)); + if ( Number.isNaN(i) ) continue; + if ( Number.isNumeric(lvl.override) ) lvl.max = Math.max(parseInt(lvl.override), 1); + else lvl.max = slots[i-1] || 0; + lvl.value = Math.min(parseInt(lvl.value), lvl.max); + } + + // Determine the number of Warlock pact slots per level + const pl = Math.clamped(progression.pact, 0, 20); + if ( pl > 0) { + powers.pact = powers.pact || {}; + powers.pact.level = Math.ceil(Math.min(10, pl) / 2); + if ( Number.isNumeric(powers.pact.override) ) powers.pact.max = Math.max(parseInt(powers.pact.override), 1); + else powers.pact.max = Math.max(1, Math.min(pl, 2), Math.min(pl - 8, 3), Math.min(pl - 13, 4)); + powers.pact.value = Math.min(powers.pact.value, powers.pact.max); + } + } + + /* -------------------------------------------- */ + + /** + * Return the amount of experience required to gain a certain character level. + * @param level {Number} The desired level + * @return {Number} The XP required + */ + getLevelExp(level) { + const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS; + return levels[Math.min(level, levels.length - 1)]; + } + + /* -------------------------------------------- */ + + /** + * Return the amount of experience granted by killing a creature of a certain CR. + * @param cr {Number} The creature's challenge rating + * @return {Number} The amount of experience granted per kill + */ + getCRExp(cr) { + if (cr < 1.0) return Math.max(200 * cr, 10); + return CONFIG.SW5E.CR_EXP_LEVELS[cr]; + } + + /* -------------------------------------------- */ + + /** + * Return the power DC for this actor using a certain ability score + * @param {string} ability The ability score, i.e. "str" + * @return {number} The power DC + */ + getPowerDC(ability) { + const actorData = this.data.data; + const bonus = parseInt(getProperty(actorData, "bonuses.power.dc")) || 0; + ability = actorData.abilities[ability]; + const prof = actorData.attributes.prof; + return 8 + (ability ? ability.mod : 0) + prof + bonus; + } + + /* -------------------------------------------- */ + + /** @override */ + getRollData() { + const data = super.getRollData(); + data.classes = this.data.items.reduce((obj, i) => { + if ( i.type === "class" ) { + obj[i.name.slugify({strict: true})] = i.data; + } + return obj; + }, {}); + data.prof = this.data.data.attributes.prof; + return data; + } + + /* -------------------------------------------- */ + /* Socket Listeners and Handlers + /* -------------------------------------------- */ + + /** @override */ + static async create(data, options={}) { + data.token = data.token || {}; + if ( data.type === "character" ) { + mergeObject(data.token, { + vision: true, + dimSight: 30, + brightSight: 0, + actorLink: true, + disposition: 1 + }, {overwrite: false}); + } + return super.create(data, options); + } + + /* -------------------------------------------- */ + + /** @override */ + async update(data, options={}) { + + // Apply changes in Actor size to Token width/height + const newSize = data["data.traits.size"]; + if ( newSize && (newSize !== getProperty(this.data, "data.traits.size")) ) { + let size = CONFIG.SW5E.tokenSizes[newSize]; + if ( this.isToken ) this.token.update({height: size, width: size}); + else if ( !data["token.width"] && !hasProperty(data, "token.width") ) { + data["token.height"] = size; + data["token.width"] = size; + } + } + return super.update(data, options); + } + + /* -------------------------------------------- */ + + /** @override */ + async createOwnedItem(itemData, options) { + + // Assume NPCs are always proficient with weapons and always have powers prepared + if ( !this.isPC ) { + let t = itemData.type; + let initial = {}; + if ( t === "weapon" ) initial["data.proficient"] = true; + if ( ["weapon", "equipment"].includes(t) ) initial["data.equipped"] = true; + if ( t === "power" ) initial["data.prepared"] = true; + mergeObject(itemData, initial); + } + return super.createOwnedItem(itemData, options); + } + + /* -------------------------------------------- */ + + /** @override */ + async modifyTokenAttribute(attribute, value, isDelta, isBar) { + if ( attribute !== "attributes.hp" ) return super.modifyTokenAttribute(attribute, value, isDelta, isBar); + + // Get current and delta HP + const hp = getProperty(this.data.data, attribute); + const tmp = parseInt(hp.temp) || 0; + const current = hp.value + tmp; + const max = hp.max + (parseInt(hp.tempmax) || 0); + const delta = isDelta ? value : value - current; + + // For negative changes, deduct from temp HP + let dtmp = delta < 0 ? Math.max(-1*tmp, delta) : 0; + let dhp = delta - dtmp; + return this.update({ + "data.attributes.hp.temp": tmp + dtmp, + "data.attributes.hp.value": Math.clamped(hp.value + dhp, 0, max) + }); + } + + /* -------------------------------------------- */ + /* Rolls */ + /* -------------------------------------------- */ + + /** + * Cast a Power, consuming a power slot of a certain level + * @param {Item5e} item The power being cast by the actor + * @param {Event} event The originating user interaction which triggered the cast + */ + async usePower(item, {configureDialog=true}={}) { + if ( item.data.type !== "power" ) throw new Error("Wrong Item type"); + + // Determine if the power uses slots + let lvl = item.data.data.level; + const usesSlots = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(item.data.data.preparation.mode); + if ( !usesSlots ) return item.roll(); + + // Configure the casting level and whether to consume a power slot + let consume = `power${lvl}`; + let placeTemplate = false; + + // Configure power slot consumption and measured template placement from the form + if ( configureDialog ) { + const powerFormData = await PowerCastDialog.create(this, item); + const isPact = powerFormData.get('level') === 'pact'; + const lvl = isPact ? this.data.data.powers.pact.level : parseInt(powerFormData.get("level")); + if (Boolean(powerFormData.get("consume"))) { + consume = isPact ? 'pact' : `power${lvl}`; + } else { + consume = false; + } + placeTemplate = Boolean(powerFormData.get("placeTemplate")); + + // Create a temporary owned item to approximate the power at a higher level + if ( lvl !== item.data.data.level ) { + item = item.constructor.createOwned(mergeObject(item.data, {"data.level": lvl}, {inplace: false}), this); + } + } + + // Update Actor data + if ( consume && (lvl > 0) ) { + await this.update({ + [`data.powers.${consume}.value`]: Math.max(parseInt(this.data.data.powers[consume].value) - 1, 0) + }); + } + + // Initiate ability template placement workflow if selected + if (item.hasAreaTarget && placeTemplate) { + const template = AbilityTemplate.fromItem(item); + if ( template ) template.drawPreview(event); + if ( this.sheet.rendered ) this.sheet.minimize(); + } + + // Invoke the Item roll + return item.roll(); + } + + /* -------------------------------------------- */ + + /** + * Roll a Skill Check + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {string} skillId The skill id (e.g. "ins") + * @param {Object} options Options which configure how the skill check is rolled + * @return {Promise.} A Promise which resolves to the created Roll instance + */ + rollSkill(skillId, options={}) { + const skl = this.data.data.skills[skillId]; + const parts = ["@mod"]; + const data = {mod: skl.mod}; + + // Include a global actor skill bonus + const actorBonus = getProperty(this.data.data.bonuses, "abilities.skill"); + if ( !!actorBonus ) { + parts.push("@skillBonus"); + data.skillBonus = actorBonus; + } + + // Roll and return + return Dice5e.d20Roll(mergeObject(options, { + parts: parts, + data: data, + title: `${CONFIG.SW5E.skills[skillId]} Skill Check`, + speaker: ChatMessage.getSpeaker({actor: this}), + halflingLucky: this.getFlag("sw5e", "halflingLucky") + })); + } + + /* -------------------------------------------- */ + + /** + * Roll a generic ability test or saving throw. + * Prompt the user for input on which variety of roll they want to do. + * @param {String}abilityId The ability id (e.g. "str") + * @param {Object} options Options which configure how ability tests or saving throws are rolled + */ + rollAbility(abilityId, options={}) { + const label = CONFIG.SW5E.abilities[abilityId]; + new Dialog({ + title: `${label} Ability Check`, + content: `

What type of ${label} check?

`, + buttons: { + test: { + label: "Ability Test", + callback: () => this.rollAbilityTest(abilityId, options) + }, + save: { + label: "Saving Throw", + callback: () => this.rollAbilitySave(abilityId, options) + } + } + }).render(true); + } + + /* -------------------------------------------- */ + + /** + * Roll an Ability Test + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {String} abilityId The ability ID (e.g. "str") + * @param {Object} options Options which configure how ability tests are rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollAbilityTest(abilityId, options={}) { + const label = CONFIG.SW5E.abilities[abilityId]; + const abl = this.data.data.abilities[abilityId]; + const parts = ["@mod"]; + const data = {mod: abl.mod}; + const feats = this.data.flags.sw5e || {}; + + // Add feat-related proficiency bonuses + if ( feats.remarkableAthlete && SW5E.characterFlags.remarkableAthlete.abilities.includes(abilityId) ) { + parts.push("@proficiency"); + data.proficiency = Math.ceil(0.5 * this.data.data.attributes.prof); + } + else if ( feats.jackOfAllTrades ) { + parts.push("@proficiency"); + data.proficiency = Math.floor(0.5 * this.data.data.attributes.prof); + } + + // Add global actor bonus + let actorBonus = getProperty(this.data.data.bonuses, "abilities.check"); + if ( !!actorBonus ) { + parts.push("@checkBonus"); + data.checkBonus = actorBonus; + } + + // Roll and return + return Dice5e.d20Roll(mergeObject(options, { + parts: parts, + data: data, + title: `${label} Ability Test`, + speaker: ChatMessage.getSpeaker({actor: this}), + halflingLucky: feats.halflingLucky + })); + } + + /* -------------------------------------------- */ + + /** + * Roll an Ability Saving Throw + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {String} abilityId The ability ID (e.g. "str") + * @param {Object} options Options which configure how ability tests are rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollAbilitySave(abilityId, options={}) { + const label = CONFIG.SW5E.abilities[abilityId]; + const abl = this.data.data.abilities[abilityId]; + const parts = ["@mod"]; + const data = {mod: abl.mod}; + + // Include proficiency bonus + if ( abl.prof > 0 ) { + parts.push("@prof"); + data.prof = abl.prof; + } + + // Include a global actor ability save bonus + const actorBonus = getProperty(this.data.data.bonuses, "abilities.save"); + if ( !!actorBonus ) { + parts.push("@saveBonus"); + data.saveBonus = actorBonus; + } + + // Roll and return + return Dice5e.d20Roll(mergeObject(options, { + parts: parts, + data: data, + title: `${label} Saving Throw`, + speaker: ChatMessage.getSpeaker({actor: this}), + halflingLucky: this.getFlag("sw5e", "halflingLucky") + })); + } + + /* -------------------------------------------- */ + + /** + * Perform a death saving throw, rolling a d20 plus any global save bonuses + * @param {Object} options Additional options which modify the roll + * @return {Promise} A Promise which resolves to the Roll instance + */ + async rollDeathSave(options={}) { + + // Evaluate a global saving throw bonus + const speaker = ChatMessage.getSpeaker({actor: this}); + const parts = []; + const data = {}; + const bonus = getProperty(this.data.data.bonuses, "abilities.save"); + if ( bonus ) { + parts.push("@saveBonus"); + data["saveBonus"] = bonus; + } + + // Evaluate the roll + const roll = await Dice5e.d20Roll(mergeObject(options, { + parts: parts, + data: data, + title: `Death Saving Throw`, + speaker: speaker, + halflingLucky: this.getFlag("sw5e", "halflingLucky"), + targetValue: 10 + })); + if ( !roll ) return null; + + // Take action depending on the result + const success = roll.total >= 10; + const death = this.data.data.attributes.death; + + // Save success + if ( success ) { + let successes = (death.success || 0) + (roll.total === 20 ? 2 : 1); + if ( successes === 3 ) { // Survival + await this.update({ + "data.attributes.death.success": 0, + "data.attributes.death.failure": 0, + "data.attributes.hp.value": 1 + }); + await ChatMessage.create({content: `${this.name} has survived with 3 death save successes!`, speaker}); + } + else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)}); + } + + // Save failure + else { + let failures = (death.failure || 0) + (roll.total === 1 ? 2 : 1); + await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)}); + if ( failures === 3 ) { // Death + await ChatMessage.create({content: `${this.name} has died with 3 death save failures!`, speaker}); + } + } + + // Return the rolled result + return roll; + } + + /* -------------------------------------------- */ + + /** + * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier + * @param {string} formula The hit die type to roll. Example "d8" + */ + async rollHitDie(formula) { + + // Find a class (if any) which has an available hit die of the requested denomination + const cls = this.items.find(i => { + const d = i.data.data; + return (d.hitDice === formula) && ((d.levels || 1) - (d.hitDiceUsed || 0) > 0); + }); + + // If no class is available, display an error notification + if ( !cls ) { + return ui.notifications.error(`${this.name} has no available ${formula} Hit Dice remaining!`); + } + + // Prepare roll data + const parts = [formula, "@abilities.con.mod"]; + const title = `Roll Hit Die`; + const rollData = duplicate(this.data.data); + + // Call the roll helper utility + const roll = await Dice5e.damageRoll({ + event: new Event("hitDie"), + parts: parts, + data: rollData, + title: title, + speaker: ChatMessage.getSpeaker({actor: this}), + allowcritical: false, + dialogOptions: {width: 350} + }); + if ( !roll ) return; + + // Adjust actor data + await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1}); + const hp = this.data.data.attributes.hp; + const dhp = Math.min(hp.max - hp.value, roll.total); + return this.update({"data.attributes.hp.value": hp.value + dhp}); + } + + /* -------------------------------------------- */ + + /** + * Cause this Actor to take a Short Rest + * During a Short Rest resources and limited item uses may be recovered + * @param {boolean} dialog Present a dialog window which allows for rolling hit dice as part of the Short Rest + * @param {boolean} chat Summarize the results of the rest workflow as a chat message + * @return {Promise} A Promise which resolves once the short rest workflow has completed + */ + async shortRest({dialog=true, chat=true}={}) { + const data = this.data.data; + + // Take note of the initial hit points and number of hit dice the Actor has + const hd0 = data.attributes.hd; + const hp0 = data.attributes.hp.value; + + // Display a Dialog for rolling hit dice + if ( dialog ) { + const rested = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0}); + if ( !rested ) return; + } + + // Note the change in HP and HD which occurred + const dhd = data.attributes.hd - hd0; + const dhp = data.attributes.hp.value - hp0; + + // Recover character resources + const updateData = {}; + for ( let [k, r] of Object.entries(data.resources) ) { + if ( r.max && r.sr ) { + updateData[`data.resources.${k}.value`] = r.max; + } + } + + // Recover pact slots. + const pact = data.powers.pact; + updateData['data.powers.pact.value'] = pact.override || pact.max; + await this.update(updateData); + + // Recover item uses + const items = this.items.filter(item => item.data.data.uses && (item.data.data.uses.per === "sr")); + const updateItems = items.map(item => { + return { + _id: item._id, + "data.uses.value": item.data.data.uses.max + }; + }); + await this.updateManyEmbeddedEntities("OwnedItem", updateItems); + + // Display a Chat Message summarizing the rest effects + if ( chat ) { + let msg = `${this.name} takes a short rest spending ${-dhd} Hit Dice to recover ${dhp} Hit Points.`; + ChatMessage.create({ + user: game.user._id, + speaker: {actor: this, alias: this.name}, + content: msg, + type: CONST.CHAT_MESSAGE_TYPES.OTHER + }); + } + + // Return data summarizing the rest effects + return { + dhd: dhd, + dhp: dhp, + updateData: updateData, + updateItems: updateItems + } + } + + /* -------------------------------------------- */ + + /** + * Take a long rest, recovering HP, HD, resources, and power slots + * @param {boolean} dialog Present a confirmation dialog window whether or not to take a long rest + * @param {boolean} chat Summarize the results of the rest workflow as a chat message + * @return {Promise} A Promise which resolves once the long rest workflow has completed + */ + async longRest({dialog=true, chat=true}={}) { + const data = this.data.data; + + // Maybe present a confirmation dialog + if ( dialog ) { + try { + await ShortRestDialog.longRestDialog(this); + } catch(err) { + return; + } + } + + // Recover hit points to full, and eliminate any existing temporary HP + const dhp = data.attributes.hp.max - data.attributes.hp.value; + const updateData = { + "data.attributes.hp.value": data.attributes.hp.max, + "data.attributes.hp.temp": 0, + "data.attributes.hp.tempmax": 0 + }; + + // Recover character resources + for ( let [k, r] of Object.entries(data.resources) ) { + if ( r.max && (r.sr || r.lr) ) { + updateData[`data.resources.${k}.value`] = r.max; + } + } + + // Recover power slots + for ( let [k, v] of Object.entries(data.powers) ) { + if ( !v.max && !v.override ) continue; + updateData[`data.powers.${k}.value`] = v.override || v.max; + } + + // Recover pact slots. + const pact = data.powers.pact; + updateData['data.powers.pact.value'] = pact.override || pact.max; + + // Determine the number of hit dice which may be recovered + let recoverHD = Math.max(Math.floor(data.details.level / 2), 1); + let dhd = 0; + + // Sort classes which can recover HD, assuming players prefer recovering larger HD first. + const updateItems = this.items.filter(item => item.data.type === "class").sort((a, b) => { + let da = parseInt(a.data.data.hitDice.slice(1)) || 0; + let db = parseInt(b.data.data.hitDice.slice(1)) || 0; + return db - da; + }).reduce((updates, item) => { + const d = item.data.data; + if ( (recoverHD > 0) && (d.hitDiceUsed > 0) ) { + let delta = Math.min(d.hitDiceUsed || 0, recoverHD); + recoverHD -= delta; + dhd += delta; + updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); + } + return updates; + }, []); + + // Iterate over owned items, restoring uses per day and recovering Hit Dice + for ( let item of this.items ) { + const d = item.data.data; + if ( d.uses && ["sr", "lr"].includes(d.uses.per) ) { + updateItems.push({_id: item.id, "data.uses.value": d.uses.max}); + } + else if ( d.recharge && d.recharge.value ) { + updateItems.push({_id: item.id, "data.recharge.charged": true}); + } + } + + // Perform the updates + await this.update(updateData); + if ( updateItems.length ) await this.updateManyEmbeddedEntities("OwnedItem", updateItems); + + // Display a Chat Message summarizing the rest effects + if ( chat ) { + ChatMessage.create({ + user: game.user._id, + speaker: {actor: this, alias: this.name}, + content: `${this.name} takes a long rest and recovers ${dhp} Hit Points and ${dhd} Hit Dice.` + }); + } + + // Return data summarizing the rest effects + return { + dhd: dhd, + dhp: dhp, + updateData: updateData, + updateItems: updateItems + } + } + + + /* -------------------------------------------- */ + + /** + * Convert all carried currency to the highest possible denomination to reduce the number of raw coins being + * carried by an Actor. + * @return {Promise} + */ + convertCurrency() { + const curr = duplicate(this.data.data.currency); + const convert = { + cp: {into: "sp", each: 10}, + sp: {into: "ep", each: 5 }, + ep: {into: "gp", each: 2 }, + gp: {into: "pp", each: 10} + }; + for ( let [c, t] of Object.entries(convert) ) { + let change = Math.floor(curr[c] / t.each); + curr[c] -= (change * t.each); + curr[t.into] += change; + } + return this.update({"data.currency": curr}); + } + + /* -------------------------------------------- */ + + /** + * Transform this Actor into another one. + * + * @param {Actor} target The target Actor. + * @param {boolean} [keepPhysical] Keep physical abilities (str, dex, con) + * @param {boolean} [keepMental] Keep mental abilities (int, wis, cha) + * @param {boolean} [keepSaves] Keep saving throw proficiencies + * @param {boolean} [keepSkills] Keep skill proficiencies + * @param {boolean} [mergeSaves] Take the maximum of the save proficiencies + * @param {boolean} [mergeSkills] Take the maximum of the skill proficiencies + * @param {boolean} [keepClass] Keep proficiency bonus + * @param {boolean} [keepFeats] Keep features + * @param {boolean} [keepPowers] Keep powers + * @param {boolean} [keepItems] Keep items + * @param {boolean} [keepBio] Keep biography + * @param {boolean} [keepVision] Keep vision + * @param {boolean} [transformTokens] Transform linked tokens too + */ + async transformInto(target, { keepPhysical=false, keepMental=false, keepSaves=false, keepSkills=false, + mergeSaves=false, mergeSkills=false, keepClass=false, keepFeats=false, keepPowers=false, + keepItems=false, keepBio=false, keepVision=false, transformTokens=true}={}) { + + // Ensure the player is allowed to polymorph + const allowed = game.settings.get("sw5e", "allowPolymorphing"); + if ( !allowed && !game.user.isGM ) { + return ui.notifications.warn(`You are not allowed to polymorph this actor!`); + } + + // Get the original Actor data and the new source data + const o = duplicate(this.data); + o.flags.sw5e = o.flags.sw5e || {}; + const source = duplicate(target.data); + + // Prepare new data to merge from the source + const d = { + type: o.type, // Remain the same actor type + name: `${o.name} (${source.name})`, // Append the new shape to your old name + data: source.data, // Get the data model of your new form + items: source.items, // Get the items of your new form + token: source.token, // New token configuration + img: source.img, // New appearance + permission: o.permission, // Use the original actor permissions + folder: o.folder, // Be displayed in the same sidebar folder + flags: o.flags // Use the original actor flags + }; + + // Additional adjustments + delete d.data.resources; // Don't change your resource pools + delete d.data.currency; // Don't lose currency + delete d.data.bonuses; // Don't lose global bonuses + delete d.token.actorId; // Don't reference the old actor ID + d.token.actorLink = o.token.actorLink; // Keep your actor link + d.token.name = d.name; // Token name same as actor name + d.data.details.alignment = o.data.details.alignment; // Don't change alignment + d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level + d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration + + // Handle wildcard + if ( source.token.randomImg ) { + const images = await target.getTokenImages(); + d.token.img = images[0]; + } + + // Keep Token configurations + const tokenConfig = ["displayName", "vision", "actorLink", "disposition", "displayBars", "bar1", "bar2"]; + if ( keepVision ) { + tokenConfig.push(...['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle']); + } + for ( let c of tokenConfig ) { + d.token[c] = o.token[c]; + } + + // Transfer ability scores + const abilities = d.data.abilities; + for ( let k of Object.keys(abilities) ) { + const oa = o.data.abilities[k]; + if ( keepPhysical && ["str", "dex", "con"].includes(k) ) abilities[k] = oa; + else if ( keepMental && ["int", "wis", "cha"].includes(k) ) abilities[k] = oa; + if ( keepSaves ) abilities[k].proficient = oa.proficient; + else if ( mergeSaves ) abilities[k].proficient = Math.max(abilities[k].proficient, oa.proficient) + } + + // Transfer skills + const skills = d.data.skills; + if ( keepSkills ) d.data.skills = o.data.skills; + else if ( mergeSkills ) { + for ( let [k, s] of Object.entries(skills) ) { + s.value = Math.max(s.proficient, o.data.skills[k].value); + } + } + + // Keep specific items from the original data + d.items = d.items.concat(o.items.filter(i => { + if ( i.type === "class" ) return true; // Always keep class levels + else if ( i.type === "feat" ) return keepFeats; + else if ( i.type === "power" ) return keepPowers; + else return keepItems; + })); + + // Keep biography + if (keepBio) d.data.details.biography = o.data.details.biography; + + // Keep senses + if (keepVision) d.data.traits.senses = o.data.traits.senses; + + // Set new data flags + if ( !this.isPolymorphed || !d.flags.sw5e.originalActor ) d.flags.sw5e.originalActor = this.id; + d.flags.sw5e.isPolymorphed = true; + + // Update unlinked Tokens in place since they can simply be re-dropped from the base actor + if (this.isToken) { + const tokenData = d.token; + tokenData.actorData = d; + delete tokenData.actorData.token; + return this.token.update(tokenData); + } + + // Update regular Actors by creating a new Actor with the Polymorphed data + await this.sheet.close(); + const newActor = await this.constructor.create(d, {renderSheet: true}); + + // Update placed Token instances + if ( !transformTokens ) return; + const tokens = this.getActiveTokens(true); + const updates = tokens.map(t => { + const newTokenData = duplicate(d.token); + if ( !t.data.actorLink ) newTokenData.actorData = newActor.data; + newTokenData._id = t.data._id; + newTokenData.actorId = newActor.id; + return newTokenData; + }); + return canvas.scene.updateManyEmbeddedEntities("Token", updates); + } + + /* -------------------------------------------- */ + + /** + * If this actor was transformed with transformTokens enabled, then its + * active tokens need to be returned to their original state. If not, then + * we can safely just delete this actor. + */ + async revertOriginalForm() { + if ( !this.isPolymorphed ) return; + if ( !this.owner ) { + return ui.notifications.warn(`You do not have permission to revert this Actor's polymorphed state.`); + } + + // If we are reverting an unlinked token, simply replace it with the base actor prototype + if ( this.isToken ) { + const baseActor = game.actors.get(this.token.data.actorId); + const prototypeTokenData = duplicate(baseActor.token); + prototypeTokenData.actorData = null; + return this.token.update(prototypeTokenData); + } + + // Obtain a reference to the original actor + const original = game.actors.get(this.getFlag('sw5e', 'originalActor')); + if ( !original ) return; + + // Get the Tokens which represent this actor + const tokens = this.getActiveTokens(true); + const tokenUpdates = tokens.map(t => { + const tokenData = duplicate(original.data.token); + tokenData._id = t.id; + tokenData.actorId = original.id; + return tokenData; + }); + canvas.scene.updateManyEmbeddedEntities("Token", tokenUpdates); + + // Delete the polymorphed Actor and maybe re-render the original sheet + const isRendered = this.sheet.rendered; + if ( game.user.isGM ) await this.delete(); + original.sheet.render(isRendered); + return original; + } + + /* -------------------------------------------- */ + + /** + * Apply rolled dice damage to the token or tokens which are currently controlled. + * This allows for damage to be scaled by a multiplier to account for healing, critical hits, or resistance + * + * @param {HTMLElement} roll The chat entry which contains the roll data + * @param {Number} multiplier A damage multiplier to apply to the rolled damage. + * @return {Promise} + */ + static async applyDamage(roll, multiplier) { + let value = Math.floor(parseFloat(roll.find('.dice-total').text()) * multiplier); + const promises = []; + for ( let t of canvas.tokens.controlled ) { + let a = t.actor, + hp = a.data.data.attributes.hp, + tmp = parseInt(hp.temp) || 0, + dt = value > 0 ? Math.min(tmp, value) : 0; + promises.push(t.actor.update({ + "data.attributes.hp.temp": tmp - dt, + "data.attributes.hp.value": Math.clamped(hp.value - (value - dt), 0, hp.max) + })); + } + return Promise.all(promises); + } + + /* -------------------------------------------- */ + + /** + * Add additional system-specific sidebar directory context menu options for D&D5e Actor entities + * @param {jQuery} html The sidebar HTML + * @param {Array} entryOptions The default array of context menu options + */ + static addDirectoryContextOptions(html, entryOptions) { + entryOptions.push({ + name: 'SW5E.PolymorphRestoreTransformation', + icon: '', + callback: li => { + const actor = game.actors.get(li.data('entityId')); + return actor.revertOriginalForm(); + }, + condition: li => { + const allowed = game.settings.get("sw5e", "allowPolymorphing"); + if ( !allowed && !game.user.isGM ) return false; + const actor = game.actors.get(li.data('entityId')); + return actor && actor.isPolymorphed; + } + }); + } +}