foundry-sw5e/module/actor/entity.js

1612 lines
58 KiB
JavaScript
Raw Normal View History

2020-09-11 17:11:11 -04:00
import { d20Roll, damageRoll } from "../dice.js";
import SelectItemsPrompt from "../apps/select-items-prompt.js";
2020-09-11 17:11:11 -04:00
import ShortRestDialog from "../apps/short-rest.js";
import LongRestDialog from "../apps/long-rest.js";
2020-06-24 14:20:19 -04:00
import {SW5E} from '../config.js';
import Item5e from "../item/entity.js";
2020-06-24 14:20:19 -04:00
/**
* Extend the base Actor class to implement additional system-specific logic.
* @extends {Actor}
2020-06-24 14:20:19 -04:00
*/
2020-09-11 17:11:11 -04:00
export default class Actor5e extends Actor {
2020-06-24 14:20:19 -04:00
/**
* The data source for Actor5e.classes allowing it to be lazily computed.
* @type {Object<string, Item5e>}
* @private
*/
_classes = undefined;
/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */
/**
* A mapping of classes belonging to this Actor.
* @type {Object<string, Item5e>}
*/
get classes() {
if ( this._classes !== undefined ) return this._classes;
if ( this.data.type !== "character" ) return this._classes = {};
return this._classes = this.items.filter((item) => item.type === "class").reduce((obj, cls) => {
obj[cls.name.slugify({strict: true})] = cls;
return obj;
}, {});
}
/* -------------------------------------------- */
2020-06-24 14:20:19 -04:00
/**
* Is this Actor currently polymorphed into some other creature?
* @type {boolean}
2020-06-24 14:20:19 -04:00
*/
get isPolymorphed() {
return this.getFlag("sw5e", "isPolymorphed") || false;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @override */
prepareData() {
super.prepareData();
// iterate over owned items and recompute attributes that depend on prepared actor data
this.items.forEach(item => item.prepareFinalAttributes());
}
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
2020-09-11 17:11:11 -04:00
/** @override */
prepareBaseData() {
switch ( this.data.type ) {
case "character":
return this._prepareCharacterData(this.data);
case "npc":
return this._prepareNPCData(this.data);
case "vehicle":
return this._prepareVehicleData(this.data);
}
}
2020-06-24 14:20:19 -04:00
2020-09-11 17:11:11 -04:00
/* -------------------------------------------- */
/** @override */
prepareDerivedData() {
const actorData = this.data;
const data = actorData.data;
const flags = actorData.flags.sw5e || {};
const bonuses = getProperty(data, "bonuses.abilities") || {};
// Retrieve data for polymorphed actors
let originalSaves = null;
let originalSkills = null;
if (this.isPolymorphed) {
const transformOptions = this.getFlag('sw5e', 'transformOptions');
const original = game.actors?.get(this.getFlag('sw5e', 'originalActor'));
if (original) {
if (transformOptions.mergeSaves) {
originalSaves = original.data.data.abilities;
}
if (transformOptions.mergeSkills) {
originalSkills = original.data.data.skills;
}
2020-06-24 14:20:19 -04:00
}
2020-09-11 17:11:11 -04:00
}
2020-06-24 14:20:19 -04:00
2020-09-11 17:11:11 -04:00
// Ability modifiers and saves
const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0;
2020-09-11 17:11:11 -04:00
const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0;
const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0;
for (let [id, abl] of Object.entries(data.abilities)) {
if ( flags.diamondSoul ) abl.proficient = 1; // Diamond Soul is proficient in all saves
2020-09-11 17:11:11 -04:00
abl.mod = Math.floor((abl.value - 10) / 2);
abl.prof = (abl.proficient || 0) * data.attributes.prof;
abl.saveBonus = saveBonus;
abl.checkBonus = checkBonus;
abl.save = abl.mod + abl.prof + abl.saveBonus;
abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus;
2020-06-24 14:20:19 -04:00
2020-09-11 17:11:11 -04:00
// If we merged saves when transforming, take the highest bonus here.
if (originalSaves && abl.proficient) {
abl.save = Math.max(abl.save, originalSaves[id].save);
}
2020-06-24 14:20:19 -04:00
}
// Inventory encumbrance
data.attributes.encumbrance = this._computeEncumbrance(actorData);
// Prepare skills
2020-09-11 17:11:11 -04:00
this._prepareSkills(actorData, bonuses, checkBonus, originalSkills);
2020-06-24 14:20:19 -04:00
// Reset class store to ensure it is updated with any changes
this._classes = undefined;
2020-06-24 14:20:19 -04:00
// Determine Initiative Modifier
const init = data.attributes.init;
2020-09-11 17:11:11 -04:00
const athlete = flags.remarkableAthlete;
const joat = flags.jackOfAllTrades;
2020-06-24 14:20:19 -04:00
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.value = init.value ?? 0;
init.bonus = init.value + (flags.initiativeAlert ? 5 : 0);
2020-06-24 14:20:19 -04:00
init.total = init.mod + init.prof + init.bonus;
// Cache labels
this.labels = {};
if ( this.type === "npc" ) {
this.labels["creatureType"] = this.constructor.formatCreatureType(data.details.type);
}
2020-06-24 14:20:19 -04:00
// Prepare power-casting data
2020-09-11 17:11:11 -04:00
this._computePowercastingProgression(this.data);
}
/* -------------------------------------------- */
/**
* 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];
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/** @inheritdoc */
2020-09-11 17:11:11 -04:00
getRollData() {
const data = super.getRollData();
data.prof = this.data.data.attributes.prof || 0;
data.classes = Object.entries(this.classes).reduce((obj, e) => {
const [slug, cls] = e;
obj[slug] = cls.data.data;
2020-09-11 17:11:11 -04:00
return obj;
}, {});
return data;
}
/* -------------------------------------------- */
/**
* Given a list of items to add to the Actor, optionally prompt the
* user for which they would like to add.
* @param {Array.<Item5e>} items - The items being added to the Actor.
* @param {boolean} [prompt=true] - Whether or not to prompt the user.
* @returns {Promise<Item5e[]>}
*/
async addEmbeddedItems(items, prompt=true) {
let itemsToAdd = items;
if ( !items.length ) return [];
// Obtain the array of item creation data
let toCreate = [];
if (prompt) {
const itemIdsToAdd = await SelectItemsPrompt.create(items, {
hint: game.i18n.localize('SW5E.AddEmbeddedItemPromptHint')
});
for (let item of items) {
if (itemIdsToAdd.includes(item.id)) toCreate.push(item.toObject());
}
} else {
toCreate = items.map(item => item.toObject());
}
// Create the requested items
if (itemsToAdd.length === 0) return [];
return Item5e.createDocuments(toCreate, {parent: this});
}
/* -------------------------------------------- */
/**
* Get a list of features to add to the Actor when a class item is updated.
* Optionally prompt the user for which they would like to add.
*/
async getClassFeatures({className, archetypeName, level}={}) {
const existing = new Set(this.items.map(i => i.name));
const features = await Actor5e.loadClassFeatures({className, archetypeName, level});
return features.filter(f => !existing.has(f.name)) || [];
}
/* -------------------------------------------- */
/**
* Return the features which a character is awarded for each class level
* @param {string} className The class name being added
* @param {string} archetypeName The archetype of the class being added, if any
* @param {number} level The number of levels in the added class
* @param {number} priorLevel The previous level of the added class
* @return {Promise<Item5e[]>} Array of Item5e entities
*/
static async loadClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) {
className = className.toLowerCase();
archetypeName = archetypeName.slugify();
// Get the configuration of features which may be added
const clsConfig = CONFIG.SW5E.classFeatures[className];
if (!clsConfig) return [];
// Acquire class features
let ids = [];
for ( let [l, f] of Object.entries(clsConfig.features || {}) ) {
l = parseInt(l);
if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f);
}
// Acquire archetype features
const archConfig = clsConfig.archetypes[archetypeName] || {};
for ( let [l, f] of Object.entries(archConfig.features || {}) ) {
l = parseInt(l);
if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f);
}
// Load item data for all identified features
const features = [];
for ( let id of ids ) {
features.push(await fromUuid(id));
}
// Class powers should always be prepared
for ( const feature of features ) {
if ( feature.type === "power" ) {
const preparation = feature.data.data.preparation;
preparation.mode = "always";
preparation.prepared = true;
}
}
return features;
}
2020-09-11 17:11:11 -04:00
/* -------------------------------------------- */
/* Data Preparation Helpers */
/* -------------------------------------------- */
2020-06-24 14:20:19 -04:00
/**
* 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] = this.items.reduce((arr, item) => {
2020-06-24 14:20:19 -04:00
if ( item.type === "class" ) {
const classLevels = parseInt(item.data.data.levels) || 1;
2020-06-24 14:20:19 -04:00
arr[0] += classLevels;
arr[1] += classLevels - (parseInt(item.data.data.hitDiceUsed) || 0);
2020-06-24 14:20:19 -04:00
}
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
2020-09-11 17:11:11 -04:00
if ( data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel) ) {
2020-06-24 14:20:19 -04:00
data.details.powerLevel = Math.max(data.details.cr, 1);
}
}
/* -------------------------------------------- */
2020-09-11 17:11:11 -04:00
/**
* Prepare vehicle type-specific data
* @param actorData
* @private
*/
_prepareVehicleData(actorData) {}
/* -------------------------------------------- */
/**
* Prepare skill checks.
* @param actorData
* @param bonuses Global bonus data.
* @param checkBonus Ability check specific bonus.
* @param originalSkills A transformed actor's original actor's skills.
* @private
*/
_prepareSkills(actorData, bonuses, checkBonus, originalSkills) {
if (actorData.type === 'vehicle') return;
const data = actorData.data;
const flags = actorData.flags.sw5e || {};
// Skill modifiers
const feats = SW5E.characterFlags;
const athlete = flags.remarkableAthlete;
const joat = flags.jackOfAllTrades;
const observant = flags.observantFeat;
const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0;
for (let [id, skl] of Object.entries(data.skills)) {
skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0;
let round = Math.floor;
2020-09-11 17:11:11 -04:00
// Remarkable
if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) {
skl.value = 0.5;
2020-09-11 17:11:11 -04:00
round = Math.ceil;
}
// Jack of All Trades
if ( joat && (skl.value < 0.5) ) {
skl.value = 0.5;
}
// Polymorph Skill Proficiencies
if ( originalSkills ) {
skl.value = Math.max(skl.value, originalSkills[id].value);
}
2020-09-11 17:11:11 -04:00
// Compute modifier
skl.bonus = checkBonus + skillBonus;
skl.mod = data.abilities[skl.ability].mod;
skl.prof = round(skl.value * data.attributes.prof);
2020-09-11 17:11:11 -04:00
skl.total = skl.mod + skl.prof + skl.bonus;
// Compute passive bonus
const passive = observant && (feats.observantFeat.skills.includes(id)) ? 5 : 0;
skl.passive = 10 + skl.total + passive;
}
}
/* -------------------------------------------- */
2020-06-24 14:20:19 -04:00
/**
* Prepare data related to the power-casting capabilities of the Actor
* @private
*/
_computePowercastingProgression (actorData) {
2020-09-11 17:11:11 -04:00
if (actorData.type === 'vehicle') return;
const ad = actorData.data;
const powers = ad.powers;
2020-06-24 14:20:19 -04:00
const isNPC = actorData.type === 'npc';
// Powercasting DC
const powercastingAbility = ad.abilities[ad.attributes.powercasting];
ad.attributes.powerdc = powercastingAbility ? powercastingAbility.dc : 8 + ad.attributes.prof;
2020-06-24 14:20:19 -04:00
// 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.data;
if ( d.powercasting.progression === "none" ) continue;
2020-06-24 14:20:19 -04:00
const levels = d.levels;
const prog = d.powercasting.progression;
2020-06-24 14:20:19 -04:00
// Accumulate levels
if ( prog !== "pact" ) {
caster = d;
2020-06-24 14:20:19 -04:00
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.powercasting.progression) ) {
const denom = caster.powercasting.progression === 'third' ? 3 : 2;
progression.slot = Math.ceil(caster.levels / denom);
2020-06-24 14:20:19 -04:00
}
// EXCEPTION: NPC with an explicit power-caster level
2020-06-24 14:20:19 -04:00
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), 0);
2020-06-24 14:20:19 -04:00
else lvl.max = slots[i-1] || 0;
lvl.value = parseInt(lvl.value);
2020-06-24 14:20:19 -04:00
}
2020-09-11 17:11:11 -04:00
// Determine the Actor's pact magic level (if any)
let pl = Math.clamped(progression.pact, 0, 20);
powers.pact = powers.pact || {};
if ( (pl === 0) && isNPC && Number.isNumeric(powers.pact.override) ) pl = actorData.data.details.powerLevel;
2020-06-24 14:20:19 -04:00
// Determine the number of Warlock pact slots per level
if ( pl > 0) {
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);
2020-09-11 17:11:11 -04:00
} else {
powers.pact.max = parseInt(powers.pact.override) || 0
powers.pact.level = powers.pact.max > 0 ? 1 : 0;
2020-06-24 14:20:19 -04:00
}
}
/* -------------------------------------------- */
/**
2020-09-11 17:11:11 -04:00
* Compute the level and percentage of encumbrance for an Actor.
*
* Optionally include the weight of carried currency across all denominations by applying the standard rule
* from the PHB pg. 143
* @param {Object} actorData The data object for the Actor being rendered
* @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level
* @private
2020-06-24 14:20:19 -04:00
*/
2020-09-11 17:11:11 -04:00
_computeEncumbrance(actorData) {
// Get the total weight from items
const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"];
let weight = actorData.items.reduce((weight, i) => {
if ( !physicalItems.includes(i.type) ) return weight;
const q = i.data.data.quantity || 0;
const w = i.data.data.weight || 0;
return weight + (q * w);
2020-09-11 17:11:11 -04:00
}, 0);
// [Optional] add Currency Weight (for non-transformed actors)
if ( game.settings.get("sw5e", "currencyWeight") && actorData.data.currency ) {
2020-09-11 17:11:11 -04:00
const currency = actorData.data.currency;
const numCoins = Object.values(currency).reduce((val, denom) => val += Math.max(denom, 0), 0);
weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
2020-09-11 17:11:11 -04:00
}
2020-06-24 14:20:19 -04:00
2020-09-11 17:11:11 -04:00
// Determine the encumbrance size class
let mod = {
tiny: 0.5,
sm: 1,
med: 1,
lg: 2,
huge: 4,
grg: 8
}[actorData.data.traits.size] || 1;
if ( this.getFlag("sw5e", "powerfulBuild") ) mod = Math.min(mod * 2, 8);
// Compute Encumbrance percentage
weight = weight.toNearest(0.1);
2020-09-11 17:11:11 -04:00
const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod;
const pct = Math.clamped((weight * 100) / max, 0, 100);
return { value: weight.toNearest(0.1), max, pct, encumbered: pct > (2/3) };
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/* Event Handlers */
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
/** @inheritdoc */
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
// Token size category
const s = CONFIG.SW5E.tokenSizes[this.data.data.traits.size || "med"];
this.data.token.update({width: s, height: s});
// Player character configuration
if ( this.type === "character" ) {
this.data.token.update({vision: true, actorLink: true, disposition: 1});
}
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/** @inheritdoc */
async _preUpdate(changed, options, user) {
await super._preUpdate(changed, options, user);
2020-06-24 14:20:19 -04:00
// Apply changes in Actor size to Token width/height
const newSize = foundry.utils.getProperty(changed, "data.traits.size");
if ( newSize && (newSize !== foundry.utils.getProperty(this.data, "data.traits.size")) ) {
2020-06-24 14:20:19 -04:00
let size = CONFIG.SW5E.tokenSizes[newSize];
if ( !foundry.utils.hasProperty(changed, "token.width") ) {
changed.token = changed.token || {};
changed.token.height = size;
changed.token.width = size;
2020-06-24 14:20:19 -04:00
}
}
2020-09-11 17:11:11 -04:00
// Reset death save counters
const isDead = this.data.data.attributes.hp.value <= 0;
if ( isDead && (foundry.utils.getProperty(changed, "data.attributes.hp.value") > 0) ) {
foundry.utils.setProperty(changed, "data.attributes.death.success", 0);
foundry.utils.setProperty(changed, "data.attributes.death.failure", 0);
2020-09-11 17:11:11 -04:00
}
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* Assign a class item as the original class for the Actor based on which class has the most levels
* @protected
*/
_assignPrimaryClass() {
const classes = this.itemTypes.class.sort((a, b) => b.data.data.levels - a.data.data.levels);
const newPC = classes[0]?.id || "";
return this.update({"data.details.originalClass": newPC});
}
2020-09-11 17:11:11 -04:00
/* -------------------------------------------- */
/* Gameplay Mechanics */
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
/** @override */
async modifyTokenAttribute(attribute, value, isDelta, isBar) {
2020-09-11 17:11:11 -04:00
if ( attribute === "attributes.hp" ) {
const hp = getProperty(this.data.data, attribute);
const delta = isDelta ? (-1 * value) : (hp.value + hp.temp) - value;
return this.applyDamage(delta);
}
return super.modifyTokenAttribute(attribute, value, isDelta, isBar);
}
/* -------------------------------------------- */
/**
* Apply a certain amount of damage or healing to the health pool for Actor
* @param {number} amount An amount of damage (positive) or healing (negative) to sustain
* @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing
* @return {Promise<Actor>} A Promise which resolves once the damage has been applied
*/
async applyDamage(amount=0, multiplier=1) {
amount = Math.floor(parseInt(amount) * multiplier);
const hp = this.data.data.attributes.hp;
2020-06-24 14:20:19 -04:00
2020-09-11 17:11:11 -04:00
// Deduct damage from temp HP first
2020-06-24 14:20:19 -04:00
const tmp = parseInt(hp.temp) || 0;
2020-09-11 17:11:11 -04:00
const dt = amount > 0 ? Math.min(tmp, amount) : 0;
// Remaining goes to health
const tmpMax = parseInt(hp.tempmax) || 0;
const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax);
// Update the Actor
const updates = {
"data.attributes.hp.temp": tmp - dt,
"data.attributes.hp.value": dh
};
2020-06-24 14:20:19 -04:00
// Delegate damage application to a hook
// TODO replace this in the future with a better modifyTokenAttribute function in the core
const allowed = Hooks.call("modifyTokenAttribute", {
attribute: "attributes.hp",
value: amount,
isDelta: false,
isBar: true
}, updates);
return allowed !== false ? this.update(updates) : this;
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* 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
2020-09-11 17:11:11 -04:00
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
2020-06-24 14:20:19 -04:00
*/
rollSkill(skillId, options={}) {
const skl = this.data.data.skills[skillId];
2020-09-11 17:11:11 -04:00
const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
// Compose roll parts and data
2020-06-24 14:20:19 -04:00
const parts = ["@mod"];
2020-09-11 17:11:11 -04:00
const data = {mod: skl.mod + skl.prof};
2020-06-24 14:20:19 -04:00
2020-09-11 17:11:11 -04:00
// Ability test bonus
if ( bonuses.check ) {
data["checkBonus"] = bonuses.check;
parts.push("@checkBonus");
}
// Skill check bonus
if ( bonuses.skill ) {
data["skillBonus"] = bonuses.skill;
2020-06-24 14:20:19 -04:00
parts.push("@skillBonus");
}
2020-09-11 17:11:11 -04:00
// Add provided extra roll parts now because they will get clobbered by mergeObject below
if (options.parts?.length > 0) {
parts.push(...options.parts);
}
// Reliable Talent applies to any skill check we have full or better proficiency in
const reliableTalent = (skl.value >= 1 && this.getFlag("sw5e", "reliableTalent"));
2020-06-24 14:20:19 -04:00
// Roll and return
const rollData = foundry.utils.mergeObject(options, {
2020-06-24 14:20:19 -04:00
parts: parts,
data: data,
2020-09-11 17:11:11 -04:00
title: game.i18n.format("SW5E.SkillPromptTitle", {skill: CONFIG.SW5E.skills[skillId]}),
halflingLucky: this.getFlag("sw5e", "halflingLucky"),
reliableTalent: reliableTalent,
messageData: {
speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
"flags.sw5e.roll": {type: "skill", skillId }
}
2020-09-11 17:11:11 -04:00
});
return d20Roll(rollData);
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* 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({
2020-09-11 17:11:11 -04:00
title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}),
content: `<p>${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}</p>`,
2020-06-24 14:20:19 -04:00
buttons: {
test: {
2020-09-11 17:11:11 -04:00
label: game.i18n.localize("SW5E.ActionAbil"),
2020-06-24 14:20:19 -04:00
callback: () => this.rollAbilityTest(abilityId, options)
},
save: {
2020-09-11 17:11:11 -04:00
label: game.i18n.localize("SW5E.ActionSave"),
2020-06-24 14:20:19 -04:00
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<Roll>} 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];
2020-09-11 17:11:11 -04:00
// Construct parts
2020-06-24 14:20:19 -04:00
const parts = ["@mod"];
const data = {mod: abl.mod};
// Add feat-related proficiency bonuses
2020-09-11 17:11:11 -04:00
const feats = this.data.flags.sw5e || {};
2020-06-24 14:20:19 -04:00
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
2020-09-11 17:11:11 -04:00
const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
if ( bonuses.check ) {
2020-06-24 14:20:19 -04:00
parts.push("@checkBonus");
2020-09-11 17:11:11 -04:00
data.checkBonus = bonuses.check;
}
// Add provided extra roll parts now because they will get clobbered by mergeObject below
if (options.parts?.length > 0) {
parts.push(...options.parts);
2020-06-24 14:20:19 -04:00
}
// Roll and return
const rollData = foundry.utils.mergeObject(options, {
2020-06-24 14:20:19 -04:00
parts: parts,
data: data,
2020-09-11 17:11:11 -04:00
title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}),
halflingLucky: feats.halflingLucky,
messageData: {
speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
"flags.sw5e.roll": {type: "ability", abilityId }
}
2020-09-11 17:11:11 -04:00
});
return d20Roll(rollData);
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* 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<Roll>} 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];
2020-09-11 17:11:11 -04:00
// Construct parts
2020-06-24 14:20:19 -04:00
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
2020-09-11 17:11:11 -04:00
const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
if ( bonuses.save ) {
2020-06-24 14:20:19 -04:00
parts.push("@saveBonus");
2020-09-11 17:11:11 -04:00
data.saveBonus = bonuses.save;
}
// Add provided extra roll parts now because they will get clobbered by mergeObject below
if (options.parts?.length > 0) {
parts.push(...options.parts);
2020-06-24 14:20:19 -04:00
}
// Roll and return
const rollData = foundry.utils.mergeObject(options, {
2020-06-24 14:20:19 -04:00
parts: parts,
data: data,
2020-09-11 17:11:11 -04:00
title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}),
halflingLucky: this.getFlag("sw5e", "halflingLucky"),
messageData: {
speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
"flags.sw5e.roll": {type: "save", abilityId }
}
2020-09-11 17:11:11 -04:00
});
return d20Roll(rollData);
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* Perform a death saving throw, rolling a d20 plus any global save bonuses
* @param {Object} options Additional options which modify the roll
* @return {Promise<Roll|null>} A Promise which resolves to the Roll instance
*/
async rollDeathSave(options={}) {
2020-09-11 17:11:11 -04:00
// Display a warning if we are not at zero HP or if we already have reached 3
const death = this.data.data.attributes.death;
if ( (this.data.data.attributes.hp.value > 0) || (death.failure >= 3) || (death.success >= 3)) {
ui.notifications.warn(game.i18n.localize("SW5E.DeathSaveUnnecessary"));
return null;
}
2020-06-24 14:20:19 -04:00
// Evaluate a global saving throw bonus
const parts = [];
const data = {};
2020-09-11 17:11:11 -04:00
// Diamond Soul adds proficiency
if ( this.getFlag("sw5e", "diamondSoul") ) {
parts.push("@prof");
data.prof = this.data.data.attributes.prof;
}
2020-09-11 17:11:11 -04:00
// Include a global actor ability save bonus
const bonuses = foundry.utils.getProperty(this.data.data, "bonuses.abilities") || {};
2020-09-11 17:11:11 -04:00
if ( bonuses.save ) {
2020-06-24 14:20:19 -04:00
parts.push("@saveBonus");
2020-09-11 17:11:11 -04:00
data.saveBonus = bonuses.save;
2020-06-24 14:20:19 -04:00
}
// Evaluate the roll
const rollData = foundry.utils.mergeObject(options, {
2020-06-24 14:20:19 -04:00
parts: parts,
data: data,
2020-09-11 17:11:11 -04:00
title: game.i18n.localize("SW5E.DeathSavingThrow"),
2020-06-24 14:20:19 -04:00
halflingLucky: this.getFlag("sw5e", "halflingLucky"),
2020-09-11 17:11:11 -04:00
targetValue: 10,
messageData: {
speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
"flags.sw5e.roll": {type: "death"}
}
2020-09-11 17:11:11 -04:00
});
const roll = await d20Roll(rollData);
2020-06-24 14:20:19 -04:00
if ( !roll ) return null;
// Take action depending on the result
const success = roll.total >= 10;
const d20 = roll.dice[0].total;
2020-06-24 14:20:19 -04:00
let chatString;
2020-06-24 14:20:19 -04:00
// Save success
if ( success ) {
2020-09-11 17:11:11 -04:00
let successes = (death.success || 0) + 1;
// Critical Success = revive with 1hp
if ( d20 === 20 ) {
2020-06-24 14:20:19 -04:00
await this.update({
"data.attributes.death.success": 0,
"data.attributes.death.failure": 0,
"data.attributes.hp.value": 1
});
chatString = "SW5E.DeathSaveCriticalSuccess";
2020-06-24 14:20:19 -04:00
}
2020-09-11 17:11:11 -04:00
// 3 Successes = survive and reset checks
else if ( successes === 3 ) {
await this.update({
"data.attributes.death.success": 0,
"data.attributes.death.failure": 0
});
chatString = "SW5E.DeathSaveSuccess";
2020-09-11 17:11:11 -04:00
}
// Increment successes
2020-06-24 14:20:19 -04:00
else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)});
}
// Save failure
else {
2020-09-11 17:11:11 -04:00
let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1);
2020-06-24 14:20:19 -04:00
await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)});
2020-09-11 17:11:11 -04:00
if ( failures >= 3 ) { // 3 Failures = death
chatString = "SW5E.DeathSaveFailure";
2020-06-24 14:20:19 -04:00
}
}
// Display success/failure chat message
if ( chatString ) {
let chatData = { content: game.i18n.format(chatString, {name: this.name}), speaker };
ChatMessage.applyRollMode(chatData, roll.options.rollMode);
await ChatMessage.create(chatData);
}
2020-06-24 14:20:19 -04:00
// 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
2020-09-11 17:11:11 -04:00
* @param {string} [denomination] The hit denomination of hit die to roll. Example "d8".
* If no denomination is provided, the first available HD will be used
* @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll?
* @return {Promise<Roll|null>} The created Roll instance, or null if no hit die was rolled
2020-06-24 14:20:19 -04:00
*/
2020-09-11 17:11:11 -04:00
async rollHitDie(denomination, {dialog=true}={}) {
// If no denomination was provided, choose the first available
let cls = null;
if ( !denomination ) {
cls = this.itemTypes.class.find(c => c.data.data.hitDiceUsed < c.data.data.levels);
if ( !cls ) return null;
denomination = cls.data.data.hitDice;
}
2020-06-24 14:20:19 -04:00
2020-09-11 17:11:11 -04:00
// Otherwise locate a class (if any) which has an available hit die of the requested denomination
else {
cls = this.items.find(i => {
const d = i.data.data;
return (d.hitDice === denomination) && ((d.hitDiceUsed || 0) < (d.levels || 1));
});
}
2020-06-24 14:20:19 -04:00
// If no class is available, display an error notification
if ( !cls ) {
2020-09-11 17:11:11 -04:00
ui.notifications.error(game.i18n.format("SW5E.HitDiceWarn", {name: this.name, formula: denomination}));
return null;
2020-06-24 14:20:19 -04:00
}
// Prepare roll data
2020-09-11 17:11:11 -04:00
const parts = [`1${denomination}`, "@abilities.con.mod"];
const title = game.i18n.localize("SW5E.HitDiceRoll");
const rollData = foundry.utils.deepClone(this.data.data);
2020-06-24 14:20:19 -04:00
// Call the roll helper utility
2020-09-11 17:11:11 -04:00
const roll = await damageRoll({
2020-06-24 14:20:19 -04:00
event: new Event("hitDie"),
parts: parts,
data: rollData,
title: title,
allowCritical: false,
2020-09-11 17:11:11 -04:00
fastForward: !dialog,
dialogOptions: {width: 350},
messageData: {
speaker: ChatMessage.getSpeaker({actor: this}),
"flags.sw5e.roll": {type: "hitDie"}
}
2020-06-24 14:20:19 -04:00
});
2020-09-11 17:11:11 -04:00
if ( !roll ) return null;
2020-06-24 14:20:19 -04:00
// 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.tempmax ?? 0) - hp.value, roll.total);
2020-09-11 17:11:11 -04:00
await this.update({"data.attributes.hp.value": hp.value + dhp});
return roll;
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* Results from a rest operation.
*
* @typedef {object} RestResult
* @property {number} dhp Hit points recovered during the rest.
* @property {number} dhd Hit dice recovered or spent during the rest.
* @property {object} updateData Updates applied to the actor.
* @property {Array.<object>} updateItems Updates applied to actor's items.
* @property {boolean} newDay Whether a new day occurred during the rest.
2020-06-24 14:20:19 -04:00
*/
/* -------------------------------------------- */
/**
* Take a short rest, possibly spending hit dice and recovering resources, item uses, and pact slots.
*
* @param {object} [options]
* @param {boolean} [options.dialog=true] Present a dialog window which allows for rolling hit dice as part
* of the Short Rest and selecting whether a new day has occurred.
* @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message.
* @param {boolean} [options.autoHD=false] Automatically spend Hit Dice if you are missing 3 or more hit points.
* @param {boolean} [options.autoHDThreshold=3] A number of missing hit points which would trigger an automatic HD roll.
* @return {Promise.<RestResult>} A Promise which resolves once the short rest workflow has completed.
*/
async shortRest({dialog=true, chat=true, autoHD=false, autoHDThreshold=3}={}) {
2020-06-24 14:20:19 -04:00
// Take note of the initial hit points and number of hit dice the Actor has
2020-09-11 17:11:11 -04:00
const hd0 = this.data.data.attributes.hd;
const hp0 = this.data.data.attributes.hp.value;
2020-09-11 17:11:11 -04:00
let newDay = false;
2020-06-24 14:20:19 -04:00
// Display a Dialog for rolling hit dice
if ( dialog ) {
2020-09-11 17:11:11 -04:00
try {
newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0});
} catch(err) {
return;
}
}
// Automatically spend hit dice
else if ( autoHD ) {
await this.autoSpendHitDice({ threshold: autoHDThreshold });
2020-06-24 14:20:19 -04:00
}
return this._rest(chat, newDay, false, this.data.data.attributes.hd - hd0, this.data.data.attributes.hp.value - hp0);
}
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
/**
* Take a long rest, recovering hit points, hit dice, resources, item uses, and power slots.
*
* @param {object} [options]
* @param {boolean} [options.dialog=true] Present a confirmation dialog window whether or not to take a long rest.
* @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message.
* @param {boolean} [options.newDay=true] Whether the long rest carries over to a new day.
* @return {Promise.<RestResult>} A Promise which resolves once the long rest workflow has completed.
*/
async longRest({dialog=true, chat=true, newDay=true}={}) {
// Maybe present a confirmation dialog
if ( dialog ) {
try {
newDay = await LongRestDialog.longRestDialog({actor: this});
} catch(err) {
return;
2020-06-24 14:20:19 -04:00
}
}
return this._rest(chat, newDay, true);
}
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
/**
* Perform all of the changes needed for a short or long rest.
*
* @param {boolean} chat Summarize the results of the rest workflow as a chat message.
* @param {boolean} newDay Has a new day occurred during this rest?
* @param {boolean} longRest Is this a long rest?
* @param {number} [dhd=0] Number of hit dice spent during so far during the rest.
* @param {number} [dhp=0] Number of hit points recovered so far during the rest.
* @return {Promise.<RestResult>} Consolidated results of the rest workflow.
* @private
*/
async _rest(chat, newDay, longRest, dhd=0, dhp=0) {
let hitPointsRecovered = 0;
let hitPointUpdates = {};
let hitDiceRecovered = 0;
let hitDiceUpdates = [];
// Recover hit points & hit dice on long rest
if ( longRest ) {
({ updates: hitPointUpdates, hitPointsRecovered } = this._getRestHitPointRecovery());
({ updates: hitDiceUpdates, hitDiceRecovered } = this._getRestHitDiceRecovery());
}
// Figure out the rest of the changes
const result = {
dhd: dhd + hitDiceRecovered,
dhp: dhp + hitPointsRecovered,
updateData: {
...hitPointUpdates,
...this._getRestResourceRecovery({ recoverShortRestResources: !longRest, recoverLongRestResources: longRest }),
...this._getRestPowerRecovery({ recoverPowers: longRest })
},
updateItems: [
...hitDiceUpdates,
...this._getRestItemUsesRecovery({ recoverLongRestUses: longRest, recoverDailyUses: newDay })
],
newDay: newDay
}
// Perform updates
await this.update(result.updateData);
await this.updateEmbeddedDocuments("Item", result.updateItems);
2020-06-24 14:20:19 -04:00
// Display a Chat Message summarizing the rest effects
if ( chat ) await this._displayRestResultMessage(result, longRest);
// Return data summarizing the rest effects
return result;
}
/* -------------------------------------------- */
/**
* Display a chat message with the result of a rest.
*
* @param {RestResult} result Result of the rest operation.
* @param {boolean} [longRest=false] Is this a long rest?
* @return {Promise.<ChatMessage>} Chat message that was created.
* @protected
*/
async _displayRestResultMessage(result, longRest=false) {
const { dhd, dhp, newDay } = result;
const diceRestored = dhd !== 0;
const healthRestored = dhp !== 0;
const length = longRest ? "Long" : "Short";
let restFlavor, message;
// Summarize the rest duration
switch (game.settings.get("sw5e", "restVariant")) {
case 'normal': restFlavor = (longRest && newDay) ? "SW5E.LongRestOvernight" : `SW5E.${length}RestNormal`; break;
case 'gritty': restFlavor = (!longRest && newDay) ? "SW5E.ShortRestOvernight" : `SW5E.${length}RestGritty`; break;
case 'epic': restFlavor = `SW5E.${length}RestEpic`; break;
2020-06-24 14:20:19 -04:00
}
// Determine the chat message to display
if ( diceRestored && healthRestored ) message = `SW5E.${length}RestResult`;
else if ( longRest && !diceRestored && healthRestored ) message = "SW5E.LongRestResultHitPoints";
else if ( longRest && diceRestored && !healthRestored ) message = "SW5E.LongRestResultHitDice";
else message = `SW5E.${length}RestResultShort`;
// Create a chat message
let chatData = {
user: game.user.id,
speaker: {actor: this, alias: this.name},
flavor: game.i18n.localize(restFlavor),
content: game.i18n.format(message, {
name: this.name,
dice: longRest ? dhd : -dhd,
health: dhp
})
};
ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode"));
return ChatMessage.create(chatData);
}
/* -------------------------------------------- */
/**
* Automatically spend hit dice to recover hit points up to a certain threshold.
*
* @param {object} [options]
* @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll.
* @return {Promise.<number>} Number of hit dice spent.
*/
async autoSpendHitDice({ threshold=3 }={}) {
const max = this.data.data.attributes.hp.max + this.data.data.attributes.hp.tempmax;
let diceRolled = 0;
while ( (this.data.data.attributes.hp.value + threshold) <= max ) {
const r = await this.rollHitDie(undefined, {dialog: false});
if ( r === null ) break;
diceRolled += 1;
2020-06-24 14:20:19 -04:00
}
return diceRolled;
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* Recovers actor hit points and eliminates any temp HP.
*
* @param {object} [options]
* @param {boolean} [options.recoverTemp=true] Reset temp HP to zero.
* @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero.
* @return {object) Updates to the actor and change in hit points.
* @protected
2020-06-24 14:20:19 -04:00
*/
_getRestHitPointRecovery({ recoverTemp=true, recoverTempMax=true }={}) {
2020-06-24 14:20:19 -04:00
const data = this.data.data;
let updates = {};
let max = data.attributes.hp.max;
2020-06-24 14:20:19 -04:00
if ( recoverTempMax ) {
updates["data.attributes.hp.tempmax"] = 0;
} else {
max += data.attributes.hp.tempmax;
}
updates["data.attributes.hp.value"] = max;
if ( recoverTemp ) {
updates["data.attributes.hp.temp"] = 0;
2020-06-24 14:20:19 -04:00
}
return { updates, hitPointsRecovered: max - data.attributes.hp.value };
}
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
/**
* Recovers actor resources.
* @param {object} [options]
* @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest.
* @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest.
* @return {object} Updates to the actor.
* @protected
*/
_getRestResourceRecovery({recoverShortRestResources=true, recoverLongRestResources=true}={}) {
let updates = {};
for ( let [k, r] of Object.entries(this.data.data.resources) ) {
if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) {
updates[`data.resources.${k}.value`] = Number(r.max);
2020-06-24 14:20:19 -04:00
}
}
return updates;
}
/* -------------------------------------------- */
2020-06-24 14:20:19 -04:00
/**
* Recovers power slots and pact slots.
*
* @param {object} [options]
* @param {boolean} [options.recoverPact=true] Recover all expended pact slots.
* @param {boolean} [options.recoverPowers=true] Recover all expended power slots.
* @return {object} Updates to the actor.
* @protected
*/
_getRestPowerRecovery({ recoverPact=true, recoverPowers=true }={}) {
let updates = {};
if ( recoverPact ) {
const pact = this.data.data.powers.pact;
updates['data.powers.pact.value'] = pact.override || pact.max;
2020-06-24 14:20:19 -04:00
}
if ( recoverPowers ) {
for ( let [k, v] of Object.entries(this.data.data.powers) ) {
updates[`data.powers.${k}.value`] = Number.isNumeric(v.override) ? v.override : (v.max ?? 0);
}
}
return updates;
}
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
/**
* Recovers class hit dice during a long rest.
*
* @param {object} [options]
* @param {number} [options.maxHitDice] Maximum number of hit dice to recover.
* @return {object} Array of item updates and number of hit dice recovered.
* @protected
*/
_getRestHitDiceRecovery({ maxHitDice=undefined }={}) {
2020-06-24 14:20:19 -04:00
// Determine the number of hit dice which may be recovered
if ( maxHitDice === undefined ) {
maxHitDice = Math.max(Math.floor(this.data.data.details.level / 2), 1);
}
2020-06-24 14:20:19 -04:00
// Sort classes which can recover HD, assuming players prefer recovering larger HD first.
const sortedClasses = Object.values(this.classes).sort((a, b) => {
return (parseInt(b.data.data.hitDice.slice(1)) || 0) - (parseInt(a.data.data.hitDice.slice(1)) || 0);
});
let updates = [];
let hitDiceRecovered = 0;
for ( let item of sortedClasses ) {
2020-06-24 14:20:19 -04:00
const d = item.data.data;
if ( (hitDiceRecovered < maxHitDice) && (d.hitDiceUsed > 0) ) {
let delta = Math.min(d.hitDiceUsed || 0, maxHitDice - hitDiceRecovered);
hitDiceRecovered += delta;
2020-06-24 14:20:19 -04:00
updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta});
}
}
return { updates, hitDiceRecovered };
}
2020-06-24 14:20:19 -04:00
/* -------------------------------------------- */
/**
* Recovers item uses during short or long rests.
*
* @param {object} [options]
* @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest.
* @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest.
* @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day.
* @return {Array.<object>} Array of item updates.
* @protected
*/
_getRestItemUsesRecovery({ recoverShortRestUses=true, recoverLongRestUses=true, recoverDailyUses=true }={}) {
let recovery = [];
if ( recoverShortRestUses ) recovery.push("sr");
if ( recoverLongRestUses ) recovery.push("lr");
if ( recoverDailyUses ) recovery.push("day");
let updates = [];
2020-06-24 14:20:19 -04:00
for ( let item of this.items ) {
const d = item.data.data;
2020-09-11 17:11:11 -04:00
if ( d.uses && recovery.includes(d.uses.per) ) {
updates.push({_id: item.id, "data.uses.value": d.uses.max});
2020-06-24 14:20:19 -04:00
}
if ( recoverLongRestUses && d.recharge && d.recharge.value ) {
updates.push({_id: item.id, "data.recharge.charged": true});
2020-06-24 14:20:19 -04:00
}
}
return updates;
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* Convert all carried currency to the highest possible denomination to reduce the number of raw coins being
* carried by an Actor.
* @return {Promise<Actor5e>}
*/
convertCurrency() {
const curr = foundry.utils.deepClone(this.data.data.currency);
const convert = CONFIG.SW5E.currencyConversion;
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});
}
/* -------------------------------------------- */
2020-06-24 14:20:19 -04:00
/**
* 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 ) {
2020-09-11 17:11:11 -04:00
return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphWarn"));
2020-06-24 14:20:19 -04:00
}
// Get the original Actor data and the new source data
const o = this.toJSON();
2020-06-24 14:20:19 -04:00
o.flags.sw5e = o.flags.sw5e || {};
2020-09-11 17:11:11 -04:00
o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves};
const source = target.toJSON();
2020-06-24 14:20:19 -04:00
// 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
effects: o.effects.concat(source.effects), // Combine active effects from both forms
2020-06-24 14:20:19 -04:00
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
};
// Specifically delete some data attributes
2020-06-24 14:20:19 -04:00
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
// Specific additional adjustments
2020-06-24 14:20:19 -04:00
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
2020-09-11 17:11:11 -04:00
d.data.powers = o.data.powers; // Keep power slots
2020-06-24 14:20:19 -04:00
// Token appearance updates
d.token = {name: d.name};
for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) {
d.token[k] = source.token[k];
}
if ( !keepVision ) {
for ( let k of ['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle'] ) {
d.token[k] = source.token[k];
}
}
2020-06-24 14:20:19 -04:00
if ( source.token.randomImg ) {
const images = await target.getTokenImages();
d.token.img = images[Math.floor(Math.random() * images.length)];
2020-06-24 14:20:19 -04:00
}
// Transfer ability scores
const abilities = d.data.abilities;
for ( let k of Object.keys(abilities) ) {
const oa = o.data.abilities[k];
2020-09-11 17:11:11 -04:00
const prof = abilities[k].proficient;
2020-06-24 14:20:19 -04:00
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;
2020-09-11 17:11:11 -04:00
else if ( mergeSaves ) abilities[k].proficient = Math.max(prof, oa.proficient);
2020-06-24 14:20:19 -04:00
}
// Transfer skills
if ( keepSkills ) d.data.skills = o.data.skills;
else if ( mergeSkills ) {
2020-09-11 17:11:11 -04:00
for ( let [k, s] of Object.entries(d.data.skills) ) {
s.value = Math.max(s.value, o.data.skills[k].value);
2020-06-24 14:20:19 -04:00
}
}
// Keep specific items from the original data
d.items = d.items.concat(o.items.filter(i => {
2020-09-11 17:11:11 -04:00
if ( i.type === "class" ) return keepClass;
2020-06-24 14:20:19 -04:00
else if ( i.type === "feat" ) return keepFeats;
else if ( i.type === "power" ) return keepPowers;
else return keepItems;
}));
2020-09-11 17:11:11 -04:00
// Transfer classes for NPCs
if (!keepClass && d.data.details.cr) {
d.items.push({
type: 'class',
name: game.i18n.localize('SW5E.PolymorphTmpClass'),
data: { levels: d.data.details.cr }
});
}
2020-06-24 14:20:19 -04:00
// 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();
2020-09-11 17:11:11 -04:00
Hooks.callAll('sw5e.transformActor', this, target, d, {
keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills,
keepClass, keepFeats, keepPowers, keepItems, keepBio, keepVision, transformTokens
});
2020-06-24 14:20:19 -04:00
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 = foundry.utils.deepClone(d.token);
2020-06-24 14:20:19 -04:00
if ( !t.data.actorLink ) newTokenData.actorData = newActor.data;
newTokenData._id = t.data._id;
newTokenData.actorId = newActor.id;
return newTokenData;
});
return canvas.scene?.updateEmbeddedDocuments("Token", updates);
2020-06-24 14:20:19 -04:00
}
/* -------------------------------------------- */
/**
* 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.isOwner ) {
2020-09-11 17:11:11 -04:00
return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn"));
2020-06-24 14:20:19 -04:00
}
// 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 = await baseActor.getTokenData();
const tokenUpdate = {actorData: {}};
for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) {
tokenUpdate[k] = prototypeTokenData[k];
}
return this.token.update(tokenUpdate, {recursive: false});
2020-06-24 14:20:19 -04:00
}
// 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
if ( canvas.ready ) {
const tokens = this.getActiveTokens(true);
const tokenData = await original.getTokenData();
const tokenUpdates = tokens.map(t => {
const update = duplicate(tokenData);
update._id = t.id;
delete update.x;
delete update.y;
return update;
});
canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates);
}
2020-06-24 14:20:19 -04:00
// Delete the polymorphed version of the actor, if possible
2020-06-24 14:20:19 -04:00
const isRendered = this.sheet.rendered;
if ( game.user.isGM ) await this.delete();
else if ( isRendered ) this.sheet.close();
if ( isRendered ) original.sheet.render(isRendered);
2020-06-24 14:20:19 -04:00
return original;
}
/* -------------------------------------------- */
/**
* Add additional system-specific sidebar directory context menu options for Actor entities
2020-06-24 14:20:19 -04:00
* @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: '<i class="fas fa-backward"></i>',
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;
}
});
}
/* -------------------------------------------- */
/**
* Format a type object into a string.
* @param {object} typeData The type data to convert to a string.
* @returns {string}
*/
static formatCreatureType(typeData) {
if ( typeof typeData === "string" ) return typeData; // backwards compatibility
let localizedType;
if ( typeData.value === "custom" ) {
localizedType = typeData.custom;
} else {
let code = CONFIG.SW5E.creatureTypes[typeData.value];
localizedType = game.i18n.localize(!!typeData.swarm ? `${code}Pl` : code);
}
let type = localizedType;
if ( !!typeData.swarm ) {
type = game.i18n.format('SW5E.CreatureSwarmPhrase', {
size: game.i18n.localize(CONFIG.SW5E.actorSizes[typeData.swarm]),
type: localizedType
});
}
if (typeData.subtype) type = `${type} (${typeData.subtype})`;
return type;
}
/* -------------------------------------------- */
/* DEPRECATED METHODS */
/* -------------------------------------------- */
/**
* @deprecated since sw5e 0.97
*/
getPowerDC(ability) {
console.warn(`The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc`);
return this.data.data.abilities[ability]?.dc;
}
/* -------------------------------------------- */
/**
* 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
* @deprecated since sw5e 1.2.0
*/
async usePower(item, {configureDialog=true}={}) {
console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`);
if ( item.data.type !== "power" ) throw new Error("Wrong Item type");
return item.roll();
}
2020-06-24 14:20:19 -04:00
}