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 SW5e. */ 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 SW5e 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; } }); } }