foundry-sw5e/module/item/entity.js

1230 lines
42 KiB
JavaScript
Raw Normal View History

2020-10-23 13:32:23 -04:00
import {d20Roll, damageRoll} from "../dice.js";
import AbilityUseDialog from "../apps/ability-use-dialog.js";
import AbilityTemplate from "../pixi/ability-template.js";
2020-06-24 14:23:26 -04:00
/**
* Override and extend the basic :class:`Item` implementation
*/
2020-10-23 13:32:23 -04:00
export default class Item5e extends Item {
2020-06-24 14:23:26 -04:00
/* -------------------------------------------- */
/* Item Properties */
/* -------------------------------------------- */
/**
* Determine which ability score modifier is used by this item
* @type {string|null}
*/
get abilityMod() {
const itemData = this.data.data;
if (!("ability" in itemData)) return null;
// Case 1 - defined directly by the item
2020-10-23 13:32:23 -04:00
if (itemData.ability) return itemData.ability;
2020-06-24 14:23:26 -04:00
// Case 2 - inferred from a parent actor
2020-10-23 13:32:23 -04:00
else if (this.actor) {
2020-06-24 14:23:26 -04:00
const actorData = this.actor.data.data;
2020-10-23 13:32:23 -04:00
// Powers - Use Actor powercasting modifier
if (this.data.type === "power") return actorData.attributes.powercasting || "int";
// Tools - default to Intelligence
else if (this.data.type === "tool") return "int";
// Weapons
else if (this.data.type === "weapon") {
const wt = itemData.weaponType;
// Melee weapons - Str or Dex if Finesse (PHB pg. 147)
if ( ["simpleVW", "martialVW", "simpleLW", "martialLW"].includes(wt) ) {
if (itemData.properties.fin === true) { // Finesse weapons
return (actorData.abilities["dex"].mod >= actorData.abilities["str"].mod) ? "dex" : "str";
}
return "str";
}
// Ranged weapons - Dex (PH p.194)
else if ( ["simpleB", "martialB"].includes(wt) ) return "dex";
}
return "str";
2020-06-24 14:23:26 -04:00
}
// Case 3 - unknown
return null
}
/* -------------------------------------------- */
/**
* Does the Item implement an attack roll as part of its usage
* @type {boolean}
*/
get hasAttack() {
2020-10-23 13:32:23 -04:00
return ["mwak", "rwak", "mpak", "rpak"].includes(this.data.data.actionType);
2020-06-24 14:23:26 -04:00
}
/* -------------------------------------------- */
/**
* Does the Item implement a damage roll as part of its usage
* @type {boolean}
*/
get hasDamage() {
return !!(this.data.data.damage && this.data.data.damage.parts.length);
}
/* -------------------------------------------- */
/**
* Does the Item implement a versatile damage roll as part of its usage
* @type {boolean}
*/
get isVersatile() {
return !!(this.hasDamage && this.data.data.damage.versatile);
}
/* -------------------------------------------- */
/**
* Does the item provide an amount of healing instead of conventional damage?
* @return {boolean}
*/
get isHealing() {
return (this.data.data.actionType === "heal") && this.data.data.damage.parts.length;
}
/* -------------------------------------------- */
/**
* Does the Item implement a saving throw as part of its usage
* @type {boolean}
*/
get hasSave() {
return !!(this.data.data.save && this.data.data.save.ability);
}
/* -------------------------------------------- */
/**
* Does the Item have a target
* @type {boolean}
*/
get hasTarget() {
const target = this.data.data.target;
return target && !["none",""].includes(target.type);
}
/* -------------------------------------------- */
/**
* Does the Item have an area of effect target
* @type {boolean}
*/
get hasAreaTarget() {
const target = this.data.data.target;
return target && (target.type in CONFIG.SW5E.areaTargetTypes);
}
/* -------------------------------------------- */
/**
* A flag for whether this Item is limited in it's ability to be used by charges or by recharge.
* @type {boolean}
*/
get hasLimitedUses() {
let chg = this.data.data.recharge || {};
let uses = this.data.data.uses || {};
return !!chg.value || (!!uses.per && (uses.max > 0));
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/**
* Augment the basic Item data model with additional dynamic data.
*/
prepareData() {
super.prepareData();
// Get the Item's data
const itemData = this.data;
const data = itemData.data;
const C = CONFIG.SW5E;
const labels = {};
// Classes
if ( itemData.type === "class" ) {
data.levels = Math.clamped(data.levels, 1, 20);
}
// Power Level, School, and Components
if ( itemData.type === "power" ) {
data.preparation.mode = data.preparation.mode || "prepared";
2020-06-24 14:23:26 -04:00
labels.level = C.powerLevels[data.level];
labels.school = C.powerSchools[data.school];
labels.components = Object.entries(data.components).reduce((arr, c) => {
if ( c[1] !== true ) return arr;
arr.push(c[0].titleCase().slice(0, 1));
return arr;
}, []);
2020-10-23 13:32:23 -04:00
labels.materials = data?.materials?.value ?? null;
2020-06-24 14:23:26 -04:00
}
// Feat Items
else if ( itemData.type === "feat" ) {
const act = data.activation;
2020-10-23 13:32:23 -04:00
if ( act && (act.type === C.abilityActivationTypes.legendary) ) labels.featType = game.i18n.localize("SW5E.LegendaryActionLabel");
else if ( act && (act.type === C.abilityActivationTypes.lair) ) labels.featType = game.i18n.localize("SW5E.LairActionLabel");
else if ( act && act.type ) labels.featType = game.i18n.localize(data.damage.length ? "SW5E.Attack" : "SW5E.Action");
else labels.featType = game.i18n.localize("SW5E.Passive");
2020-06-24 14:23:26 -04:00
}
// Species Items
else if ( itemData.type === "species" ) {
// labels.species = C.species[data.species];
}
// Archetype Items
else if ( itemData.type === "archetype" ) {
2020-10-23 12:36:42 -04:00
// labels.archetype = C.archetype[data.archetype];
}
// Background Items
else if ( itemData.type === "background" ) {
// labels.background = C.background[data.background];
}
// Class Feature Items
2020-10-23 12:36:42 -04:00
else if ( itemData.type === "classfeature" ) {
// labels.classFeature = C.classFeature[data.classFeature];
}
// Fighting Style Items
else if ( itemData.type === "fightingstyle" ) {
// labels.fightingstyle = C.fightingstyle[data.fightingstyle];
}
// Fighting Mastery Items
else if ( itemData.type === "fightingmastery" ) {
// labels.fightingmastery = C.fightingmastery[data.fightingmastery];
}
// Lightsaber Form Items
else if ( itemData.type === "lightsaberform" ) {
// labels.lightsaberform = C.lightsaberform[data.lightsaberform];
}
2020-06-24 14:23:26 -04:00
// Equipment Items
else if ( itemData.type === "equipment" ) {
2020-10-23 13:32:23 -04:00
labels.armor = data.armor.value ? `${data.armor.value} ${game.i18n.localize("SW5E.AC")}` : "";
2020-06-24 14:23:26 -04:00
}
// Activated Items
if ( data.hasOwnProperty("activation") ) {
// Ability Activation Label
let act = data.activation || {};
if ( act ) labels.activation = [act.cost, C.abilityActivationTypes[act.type]].filterJoin(" ");
// Target Label
let tgt = data.target || {};
if (["none", "touch", "self"].includes(tgt.units)) tgt.value = null;
if (["none", "self"].includes(tgt.type)) {
tgt.value = null;
tgt.units = null;
}
labels.target = [tgt.value, C.distanceUnits[tgt.units], C.targetTypes[tgt.type]].filterJoin(" ");
// Range Label
let rng = data.range || {};
if (["none", "touch", "self"].includes(rng.units) || (rng.value === 0)) {
rng.value = null;
rng.long = null;
}
labels.range = [rng.value, rng.long ? `/ ${rng.long}` : null, C.distanceUnits[rng.units]].filterJoin(" ");
// Duration Label
let dur = data.duration || {};
if (["inst", "perm"].includes(dur.units)) dur.value = null;
labels.duration = [dur.value, C.timePeriods[dur.units]].filterJoin(" ");
// Recharge Label
let chg = data.recharge || {};
2020-10-23 13:32:23 -04:00
labels.recharge = `${game.i18n.localize("SW5E.Recharge")} [${chg.value}${parseInt(chg.value) < 6 ? "+" : ""}]`;
2020-06-24 14:23:26 -04:00
}
// Item Actions
if ( data.hasOwnProperty("actionType") ) {
2020-10-23 13:32:23 -04:00
// Saving throws for unowned items
const save = data.save;
if ( save?.ability && !this.isOwned ) {
2020-06-24 14:23:26 -04:00
if ( save.scaling !== "flat" ) save.dc = null;
2020-10-23 13:32:23 -04:00
labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability: C.abilities[save.ability]});
2020-06-24 14:23:26 -04:00
}
// Damage
let dam = data.damage || {};
if ( dam.parts ) {
labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- ");
labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", ");
}
}
// Assign labels
this.labels = labels;
}
/* -------------------------------------------- */
/**
* Roll the item to Chat, creating a chat card which contains follow up attack or damage roll options
2020-10-23 13:32:23 -04:00
* @param {boolean} [configureDialog] Display a configuration dialog for the item roll, if applicable?
* @param {string} [rollMode] The roll display mode with which to display (or not) the card
* @param {boolean} [createMessage] Whether to automatically create a chat message (if true) or simply return
* the prepared chat message data (if false).
2020-06-24 14:23:26 -04:00
* @return {Promise}
*/
2020-10-23 13:32:23 -04:00
async roll({configureDialog=true, rollMode=null, createMessage=true}={}) {
2020-06-24 14:23:26 -04:00
// Basic template rendering data
const token = this.actor.token;
const templateData = {
actor: this.actor,
tokenId: token ? `${token.scene._id}.${token.id}` : null,
item: this.data,
data: this.getChatData(),
labels: this.labels,
hasAttack: this.hasAttack,
isHealing: this.isHealing,
hasDamage: this.hasDamage,
isVersatile: this.isVersatile,
isPower: this.data.type === "power",
hasSave: this.hasSave,
hasAreaTarget: this.hasAreaTarget
};
// For feature items, optionally show an ability usage dialog
if (this.data.type === "feat") {
let configured = await this._rollFeat(configureDialog);
if ( configured === false ) return;
2020-10-23 13:32:23 -04:00
} else if ( this.data.type === "consumable" ) {
let configured = await this._rollConsumable(configureDialog);
if ( configured === false ) return;
2020-06-24 14:23:26 -04:00
}
2020-10-23 13:32:23 -04:00
// For items which consume a resource, handle that here
const allowed = await this._handleResourceConsumption({isCard: true, isAttack: false});
if ( allowed === false ) return;
2020-06-24 14:23:26 -04:00
// Render the chat card template
2020-10-23 13:32:23 -04:00
const templateType = ["tool"].includes(this.data.type) ? this.data.type : "item";
2020-06-24 14:23:26 -04:00
const template = `systems/sw5e/templates/chat/${templateType}-card.html`;
const html = await renderTemplate(template, templateData);
// Basic chat message data
const chatData = {
user: game.user._id,
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
content: html,
flavor: this.data.data.chatFlavor || this.name,
2020-06-24 14:23:26 -04:00
speaker: {
actor: this.actor._id,
token: this.actor.token,
alias: this.actor.name
2020-10-23 13:32:23 -04:00
},
flags: {"core.canPopout": true}
2020-06-24 14:23:26 -04:00
};
2020-10-23 13:32:23 -04:00
// If the consumable was destroyed in the process - embed the item data in the surviving message
if ( (this.data.type === "consumable") && !this.actor.items.has(this.id) ) {
chatData.flags["sw5e.itemData"] = this.data;
}
2020-06-24 14:23:26 -04:00
// Toggle default roll mode
2020-10-23 13:32:23 -04:00
rollMode = rollMode || game.settings.get("core", "rollMode");
if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperRecipients("GM");
2020-06-24 14:23:26 -04:00
if ( rollMode === "blindroll" ) chatData["blind"] = true;
// Create the chat message
2020-10-23 13:32:23 -04:00
if ( createMessage ) return ChatMessage.create(chatData);
else return chatData;
}
/* -------------------------------------------- */
/**
2020-10-23 13:32:23 -04:00
* For items which consume a resource, handle the consumption of that resource when the item is used.
* There are four types of ability consumptions which are handled:
* 1. Ammunition (on attack rolls)
* 2. Attributes (on card usage)
* 3. Materials (on card usage)
* 4. Item Charges (on card usage)
*
* @param {boolean} isCard Is the item card being played?
* @param {boolean} isAttack Is an attack roll being made?
* @return {Promise<boolean>} Can the item card or attack roll be allowed to proceed?
* @private
*/
async _handleResourceConsumption({isCard=false, isAttack=false}={}) {
const itemData = this.data.data;
const consume = itemData.consume || {};
if ( !consume.type ) return true;
const actor = this.actor;
const typeLabel = CONFIG.SW5E.abilityConsumptionTypes[consume.type];
// Only handle certain types for certain actions
if ( ((consume.type === "ammo") && !isAttack ) || ((consume.type !== "ammo") && !isCard) ) return true;
// No consumed target set
if ( !consume.target ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel}));
return false;
}
// Identify the consumed resource and it's quantity
let consumed = null;
let amount = parseInt(consume.amount || 1);
2020-10-23 13:32:23 -04:00
let quantity = 0;
switch ( consume.type ) {
case "attribute":
consumed = getProperty(actor.data.data, consume.target);
quantity = consumed || 0;
break;
case "ammo":
case "material":
consumed = actor.items.get(consume.target);
quantity = consumed ? consumed.data.data.quantity : 0;
break;
case "charges":
consumed = actor.items.get(consume.target);
if ( !consumed ) break;
const uses = consumed.data.data.uses;
if ( uses.per && uses.max ) quantity = uses.value;
else if ( consumed.data.data.recharge?.value ) {
quantity = consumed.data.data.recharge.charged ? 1 : 0;
amount = 1;
}
2020-10-23 13:32:23 -04:00
break;
}
// Verify that the consumed resource is available
if ( [null, undefined].includes(consumed) ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoSource", {name: this.name, type: typeLabel}));
return false;
}
let remaining = quantity - amount;
if ( remaining < 0) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoQuantity", {name: this.name, type: typeLabel}));
return false;
}
// Update the consumed resource
switch ( consume.type ) {
case "attribute":
await this.actor.update({[`data.${consume.target}`]: remaining});
break;
case "ammo":
case "material":
await consumed.update({"data.quantity": remaining});
break;
case "charges":
const uses = consumed.data.data.uses || {};
const recharge = consumed.data.data.recharge || {};
if ( uses.per && uses.max ) await consumed.update({"data.uses.value": remaining});
else if ( recharge.value ) await consumed.update({"data.recharge.charged": false});
break;
2020-10-23 13:32:23 -04:00
}
return true;
2020-06-24 14:23:26 -04:00
}
/* -------------------------------------------- */
/**
* Additional rolling steps when rolling a feat-type item
* @private
* @return {boolean} whether the roll should be prevented
*/
async _rollFeat(configureDialog) {
if ( this.data.type !== "feat" ) throw new Error("Wrong Item type");
// Configure whether to consume a limited use or to place a template
2020-10-23 13:32:23 -04:00
const charge = this.data.data.recharge;
2020-06-24 14:23:26 -04:00
const uses = this.data.data.uses;
let usesCharges = !!uses.per && !!uses.max;
2020-06-24 14:23:26 -04:00
let placeTemplate = false;
2020-10-23 13:32:23 -04:00
let consume = charge.value || usesCharges;
2020-06-24 14:23:26 -04:00
// Determine whether the feat uses charges
configureDialog = configureDialog && (consume || this.hasAreaTarget);
if ( configureDialog ) {
const usage = await AbilityUseDialog.create(this);
if ( usage === null ) return false;
2020-10-23 13:32:23 -04:00
consume = Boolean(usage.get("consumeUse"));
2020-06-24 14:23:26 -04:00
placeTemplate = Boolean(usage.get("placeTemplate"));
}
// Update Item data
const current = getProperty(this.data, "data.uses.value") || 0;
2020-10-23 13:32:23 -04:00
if ( consume && charge.value ) {
if ( !charge.charged ) {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
return false;
}
else await this.update({"data.recharge.charged": false});
2020-06-24 14:23:26 -04:00
}
else if ( consume && usesCharges ) {
2020-10-23 13:32:23 -04:00
if ( uses.value <= 0 ) {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
return false;
}
2020-06-24 14:23:26 -04:00
await this.update({"data.uses.value": Math.max(current - 1, 0)});
}
// Maybe initiate template placement workflow
if ( this.hasAreaTarget && placeTemplate ) {
const template = AbilityTemplate.fromItem(this);
2020-10-23 13:32:23 -04:00
if ( template ) template.drawPreview();
2020-06-24 14:23:26 -04:00
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
}
return true;
}
/* -------------------------------------------- */
/* Chat Cards */
/* -------------------------------------------- */
/**
* Prepare an object of chat data used to display a card for the Item in the chat log
* @param {Object} htmlOptions Options used by the TextEditor.enrichHTML function
* @return {Object} An object of chat data to render
*/
2020-10-23 13:32:23 -04:00
getChatData(htmlOptions={}) {
2020-06-24 14:23:26 -04:00
const data = duplicate(this.data.data);
const labels = this.labels;
// Rich text description
data.description.value = TextEditor.enrichHTML(data.description.value, htmlOptions);
// Item type specific properties
const props = [];
const fn = this[`_${this.data.type}ChatData`];
if ( fn ) fn.bind(this)(data, labels, props);
// General equipment properties
if ( data.hasOwnProperty("equipped") && !["loot", "tool"].includes(this.data.type) ) {
props.push(
2020-10-23 13:32:23 -04:00
game.i18n.localize(data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"),
game.i18n.localize(data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"),
2020-06-24 14:23:26 -04:00
);
}
// Ability activation properties
if ( data.hasOwnProperty("activation") ) {
props.push(
2020-10-23 13:32:23 -04:00
labels.activation + (data.activation?.condition ? ` (${data.activation.condition})` : ""),
2020-06-24 14:23:26 -04:00
labels.target,
labels.range,
labels.duration
);
}
// Filter properties and return
data.properties = props.filter(p => !!p);
return data;
}
/* -------------------------------------------- */
/**
* Prepare chat card data for equipment type items
* @private
*/
_equipmentChatData(data, labels, props) {
props.push(
CONFIG.SW5E.equipmentTypes[data.armor.type],
labels.armor || null,
2020-10-23 13:32:23 -04:00
data.stealth.value ? game.i18n.localize("SW5E.StealthDisadvantage") : null
2020-06-24 14:23:26 -04:00
);
}
/* -------------------------------------------- */
/**
* Prepare chat card data for weapon type items
* @private
*/
_weaponChatData(data, labels, props) {
props.push(
CONFIG.SW5E.weaponTypes[data.weaponType],
);
}
/* -------------------------------------------- */
/**
* Prepare chat card data for consumable type items
* @private
*/
_consumableChatData(data, labels, props) {
props.push(
CONFIG.SW5E.consumableTypes[data.consumableType],
2020-10-23 13:32:23 -04:00
data.uses.value + "/" + data.uses.max + " " + game.i18n.localize("SW5E.Charges")
2020-06-24 14:23:26 -04:00
);
data.hasCharges = data.uses.value >= 0;
}
/* -------------------------------------------- */
/**
* Prepare chat card data for tool type items
* @private
*/
_toolChatData(data, labels, props) {
props.push(
CONFIG.SW5E.abilities[data.ability] || null,
CONFIG.SW5E.proficiencyLevels[data.proficient || 0]
);
}
/* -------------------------------------------- */
/**
* Prepare chat card data for tool type items
* @private
*/
_lootChatData(data, labels, props) {
props.push(
2020-10-23 13:32:23 -04:00
game.i18n.localize("SW5E.ItemTypeLoot"),
data.weight ? data.weight + " " + game.i18n.localize("SW5E.AbbreviationLbs") : null
2020-06-24 14:23:26 -04:00
);
}
/* -------------------------------------------- */
/**
* Render a chat card for Power type data
* @return {Object}
* @private
*/
_powerChatData(data, labels, props) {
props.push(
labels.level,
2020-10-23 13:32:23 -04:00
labels.components + (labels.materials ? ` (${labels.materials})` : "")
2020-06-24 14:23:26 -04:00
);
}
/* -------------------------------------------- */
/**
* Prepare chat card data for items of the "Feat" type
* @private
*/
_featChatData(data, labels, props) {
props.push(data.requirements);
}
/* -------------------------------------------- */
/* Item Rolls - Attack, Damage, Saves, Checks */
/* -------------------------------------------- */
/**
* Place an attack roll using an item (weapon, feat, power, or equipment)
2020-10-23 13:32:23 -04:00
* Rely upon the d20Roll logic for the core implementation
2020-06-24 14:23:26 -04:00
*
2020-10-23 13:32:23 -04:00
* @param {object} options Roll options which are configured and provided to the d20Roll function
* @return {Promise<Roll|null>} A Promise which resolves to the created Roll instance
2020-06-24 14:23:26 -04:00
*/
2020-10-23 13:32:23 -04:00
async rollAttack(options={}) {
2020-06-24 14:23:26 -04:00
const itemData = this.data.data;
const actorData = this.actor.data.data;
const flags = this.actor.data.flags.sw5e || {};
if ( !this.hasAttack ) {
throw new Error("You may not place an Attack Roll with this Item.");
}
2020-10-23 13:32:23 -04:00
let title = `${this.name} - ${game.i18n.localize("SW5E.AttackRoll")}`;
2020-06-24 14:23:26 -04:00
const rollData = this.getRollData();
// Define Roll bonuses
const parts = [`@mod`];
if ( (this.data.type !== "weapon") || itemData.proficient ) {
parts.push("@prof");
}
// Attack Bonus
if ( itemData.attackBonus ) parts.push(itemData.attackBonus);
2020-10-23 13:32:23 -04:00
const actorBonus = actorData?.bonuses?.[itemData.actionType] || {};
if ( actorBonus.attack ) parts.push(actorBonus.attack);
// Ammunition Bonus
delete this._ammo;
const consume = itemData.consume;
if ( consume?.type === "ammo" ) {
const ammo = this.actor.items.get(consume.target);
if(ammo?.data){
const q = ammo.data.data.quantity;
const consumeAmount = consume.amount ?? 0;
if ( q && (q - consumeAmount >= 0) ) {
this._ammo = ammo;
let ammoBonus = ammo.data.data.attackBonus;
if ( ammoBonus ) {
parts.push("@ammo");
rollData["ammo"] = ammoBonus;
title += ` [${ammo.name}]`;
2020-10-23 13:32:23 -04:00
}
}
}
}
2020-06-24 14:23:26 -04:00
// Compose roll options
2020-10-23 13:32:23 -04:00
const rollConfig = mergeObject({
2020-06-24 14:23:26 -04:00
parts: parts,
actor: this.actor,
data: rollData,
2020-10-23 13:32:23 -04:00
title: title,
flavor: title,
2020-06-24 14:23:26 -04:00
speaker: ChatMessage.getSpeaker({actor: this.actor}),
dialogOptions: {
width: 400,
top: options.event ? options.event.clientY - 80 : null,
left: window.innerWidth - 710
2020-10-23 13:32:23 -04:00
},
messageData: {"flags.sw5e.roll": {type: "attack", itemId: this.id }}
}, options);
rollConfig.event = options.event;
2020-06-24 14:23:26 -04:00
// Expanded critical hit thresholds
2020-06-24 14:23:26 -04:00
if (( this.data.type === "weapon" ) && flags.weaponCriticalThreshold) {
rollConfig.critical = parseInt(flags.weaponCriticalThreshold);
} else if (( this.data.type === "power" ) && flags.powerCriticalThreshold) {
rollConfig.critical = parseInt(flags.powerCriticalThreshold);
2020-06-24 14:23:26 -04:00
}
// Elven Accuracy
if ( ["weapon", "power"].includes(this.data.type) ) {
if (flags.elvenAccuracy && ["dex", "int", "wis", "cha"].includes(this.abilityMod)) {
rollConfig.elvenAccuracy = true;
}
}
// Apply Halfling Lucky
if ( flags.halflingLucky ) rollConfig.halflingLucky = true;
// Invoke the d20 roll helper
2020-10-23 13:32:23 -04:00
const roll = await d20Roll(rollConfig);
if ( roll === false ) return null;
// Handle resource consumption if the attack roll was made
const allowed = await this._handleResourceConsumption({isCard: false, isAttack: true});
if ( allowed === false ) return null;
return roll;
2020-06-24 14:23:26 -04:00
}
/* -------------------------------------------- */
/**
* Place a damage roll using an item (weapon, feat, power, or equipment)
* Rely upon the damageRoll logic for the core implementation.
* @param {MouseEvent} [event] An event which triggered this roll, if any
* @param {number} [powerLevel] If the item is a power, override the level for damage scaling
* @param {boolean} [versatile] If the item is a weapon, roll damage using the versatile formula
* @param {object} [options] Additional options passed to the damageRoll function
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
2020-06-24 14:23:26 -04:00
*/
rollDamage({event, powerLevel=null, versatile=false, options={}}={}) {
if ( !this.hasDamage ) throw new Error("You may not make a Damage Roll with this Item.");
2020-06-24 14:23:26 -04:00
const itemData = this.data.data;
const actorData = this.actor.data.data;
2020-10-23 13:32:23 -04:00
const messageData = {"flags.sw5e.roll": {type: "damage", itemId: this.id }};
// Get roll data
const parts = itemData.damage.parts.map(d => d[0]);
2020-06-24 14:23:26 -04:00
const rollData = this.getRollData();
if ( powerLevel ) rollData.item.level = powerLevel;
// Configure the damage roll
2020-10-23 13:32:23 -04:00
const title = `${this.name} - ${game.i18n.localize("SW5E.DamageRoll")}`;
const rollConfig = {
event: event,
parts: parts,
actor: this.actor,
data: rollData,
title: title,
flavor: this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title,
speaker: ChatMessage.getSpeaker({actor: this.actor}),
dialogOptions: {
width: 400,
top: event ? event.clientY - 80 : null,
left: window.innerWidth - 710
},
messageData: messageData
};
2020-10-23 13:32:23 -04:00
// Adjust damage from versatile usage
if ( versatile && itemData.damage.versatile ) {
parts[0] = itemData.damage.versatile;
messageData["flags.sw5e.roll"].versatile = true;
}
// Scale damage from up-casting powers
2020-06-24 14:23:26 -04:00
if ( (this.data.type === "power") ) {
2020-10-23 13:32:23 -04:00
if ( (itemData.scaling.mode === "atwill") ) {
const level = this.actor.data.type === "character" ? actorData.details.level : actorData.details.powerLevel;
this._scaleAtWillDamage(parts, itemData.scaling.formula, level, rollData);
}
else if ( powerLevel && (itemData.scaling.mode === "level") && itemData.scaling.formula ) {
const scaling = itemData.scaling.formula;
this._scalePowerDamage(parts, itemData.level, powerLevel, scaling, rollData);
2020-06-24 14:23:26 -04:00
}
}
// Add damage bonus formula
2020-10-23 13:32:23 -04:00
const actorBonus = getProperty(actorData, `bonuses.${itemData.actionType}`) || {};
if ( actorBonus.damage && (parseInt(actorBonus.damage) !== 0) ) {
parts.push(actorBonus.damage);
2020-06-24 14:23:26 -04:00
}
// Add ammunition damage
2020-10-23 13:32:23 -04:00
if ( this._ammo ) {
parts.push("@ammo");
rollData["ammo"] = this._ammo.data.data.damage.parts.map(p => p[0]).join("+");
rollConfig.flavor += ` [${this._ammo.name}]`;
2020-10-23 13:32:23 -04:00
delete this._ammo;
}
// Scale melee critical hit damage
if ( itemData.actionType === "mwak" ) {
rollConfig.criticalBonusDice = this.actor.getFlag("sw5e", "meleeCriticalDamageDice") ?? 0;
}
2020-06-24 14:23:26 -04:00
// Call the roll helper utility
return damageRoll(mergeObject(rollConfig, options));
2020-06-24 14:23:26 -04:00
}
/* -------------------------------------------- */
/**
2020-10-23 13:32:23 -04:00
* Adjust an at-will damage formula to scale it for higher level characters and monsters
2020-06-24 14:23:26 -04:00
* @private
*/
2020-10-23 13:32:23 -04:00
_scaleAtWillDamage(parts, scale, level, rollData) {
2020-06-24 14:23:26 -04:00
const add = Math.floor((level + 1) / 6);
if ( add === 0 ) return;
this._scaleDamage(parts, scale || parts.join(" + "), add, rollData);
2020-06-24 14:23:26 -04:00
}
/* -------------------------------------------- */
/**
* Adjust the power damage formula to scale it for power level up-casting
* @param {Array} parts The original damage parts
* @param {number} baseLevel The default power level
* @param {number} powerLevel The casted power level
* @param {string} formula The scaling formula
2020-10-23 13:32:23 -04:00
* @param {object} rollData A data object that should be applied to the scaled damage roll
* @return {string[]} The scaled roll parts
2020-06-24 14:23:26 -04:00
* @private
*/
2020-10-23 13:32:23 -04:00
_scalePowerDamage(parts, baseLevel, powerLevel, formula, rollData) {
2020-06-24 14:23:26 -04:00
const upcastLevels = Math.max(powerLevel - baseLevel, 0);
if ( upcastLevels === 0 ) return parts;
this._scaleDamage(parts, formula, upcastLevels, rollData);
2020-10-23 13:32:23 -04:00
}
/* -------------------------------------------- */
/**
* Scale an array of damage parts according to a provided scaling formula and scaling multiplier
* @param {string[]} parts Initial roll parts
* @param {string} scaling A scaling formula
* @param {number} times A number of times to apply the scaling formula
* @param {object} rollData A data object that should be applied to the scaled damage roll
* @return {string[]} The scaled roll parts
* @private
*/
_scaleDamage(parts, scaling, times, rollData) {
if ( times <= 0 ) return parts;
const p0 = new Roll(parts[0], rollData);
const s = new Roll(scaling, rollData).alter(times);
// Attempt to simplify by combining like dice terms
let simplified = false;
if ( (s.terms[0] instanceof Die) && (s.terms.length === 1) ) {
const d0 = p0.terms[0];
const s0 = s.terms[0];
if ( (d0 instanceof Die) && (d0.faces === s0.faces) && d0.modifiers.equals(s0.modifiers) ) {
d0.number += s0.number;
parts[0] = p0.formula;
simplified = true;
}
}
// Otherwise add to the first part
if ( !simplified ) {
parts[0] = `${parts[0]} + ${s.formula}`;
}
2020-06-24 14:23:26 -04:00
return parts;
}
/* -------------------------------------------- */
/**
* Place an attack roll using an item (weapon, feat, power, or equipment)
2020-10-23 13:32:23 -04:00
* Rely upon the d20Roll logic for the core implementation
*
2020-10-23 13:32:23 -04:00
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
2020-06-24 14:23:26 -04:00
*/
async rollFormula(options={}) {
if ( !this.data.data.formula ) {
throw new Error("This Item does not have a formula to roll!");
}
// Define Roll Data
const rollData = this.getRollData();
2020-10-23 13:32:23 -04:00
if ( options.powerLevel ) rollData.item.level = options.powerLevel;
const title = `${this.name} - ${game.i18n.localize("SW5E.OtherFormula")}`;
2020-06-24 14:23:26 -04:00
// Invoke the roll and submit it to chat
const roll = new Roll(rollData.item.formula, rollData).roll();
roll.toMessage({
speaker: ChatMessage.getSpeaker({actor: this.actor}),
flavor: title,
2020-10-23 13:32:23 -04:00
rollMode: game.settings.get("core", "rollMode"),
messageData: {"flags.sw5e.roll": {type: "other", itemId: this.id }}
2020-06-24 14:23:26 -04:00
});
return roll;
}
/* -------------------------------------------- */
/**
* Use a consumable item, deducting from the quantity or charges of the item.
* @param {boolean} configureDialog Whether to show a configuration dialog
2020-10-23 13:32:23 -04:00
* @return {boolean} Whether further execution should be prevented
* @private
2020-06-24 14:23:26 -04:00
*/
2020-10-23 13:32:23 -04:00
async _rollConsumable(configureDialog) {
if ( this.data.type !== "consumable" ) throw new Error("Wrong Item type");
2020-06-24 14:23:26 -04:00
const itemData = this.data.data;
2020-10-23 13:32:23 -04:00
// Determine whether to deduct uses of the item
const uses = itemData.uses || {};
const autoDestroy = uses.autoDestroy;
let usesCharges = !!uses.per && (uses.max > 0);
const recharge = itemData.recharge || {};
const usesRecharge = !!recharge.value;
2020-06-24 14:23:26 -04:00
2020-10-23 13:32:23 -04:00
// Display a configuration dialog to confirm the usage
let placeTemplate = false;
let consume = uses.autoUse || true;
if ( configureDialog ) {
const usage = await AbilityUseDialog.create(this);
if ( usage === null ) return false;
consume = Boolean(usage.get("consumeUse"));
placeTemplate = Boolean(usage.get("placeTemplate"));
2020-06-24 14:23:26 -04:00
}
2020-10-23 13:32:23 -04:00
// Update Item data
if ( consume ) {
const current = uses.value || 0;
const remaining = usesCharges ? Math.max(current - 1, 0) : current;
if ( usesRecharge ) await this.update({"data.recharge.charged": false});
2020-06-24 14:23:26 -04:00
else {
2020-10-23 13:32:23 -04:00
const q = itemData.quantity;
// Case 1, reduce charges
if ( remaining ) {
await this.update({"data.uses.value": remaining});
}
// Case 2, reduce quantity
else if ( q > 1 ) {
await this.update({"data.quantity": q - 1, "data.uses.value": uses.max || 0});
}
// Case 3, destroy the item
else if ( (q <= 1) && autoDestroy ) {
await this.actor.deleteOwnedItem(this.id);
}
// Case 4, reduce item to 0 quantity and 0 charges
else if ( (q === 1) ) {
await this.update({"data.quantity": q - 1, "data.uses.value": 0});
}
// Case 5, item unusable, display warning and do nothing
else {
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
}
2020-06-24 14:23:26 -04:00
}
}
2020-10-23 13:32:23 -04:00
// Maybe initiate template placement workflow
if ( this.hasAreaTarget && placeTemplate ) {
const template = AbilityTemplate.fromItem(this);
if ( template ) template.drawPreview();
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
}
return true;
}
2020-06-24 14:23:26 -04:00
/* -------------------------------------------- */
/**
* Perform an ability recharge test for an item which uses the d6 recharge mechanic
2020-10-23 13:32:23 -04:00
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
2020-06-24 14:23:26 -04:00
*/
2020-10-23 13:32:23 -04:00
async rollRecharge() {
2020-06-24 14:23:26 -04:00
const data = this.data.data;
if ( !data.recharge.value ) return;
// Roll the check
const roll = new Roll("1d6").roll();
const success = roll.total >= parseInt(data.recharge.value);
// Display a Chat Message
const promises = [roll.toMessage({
2020-10-23 13:32:23 -04:00
flavor: `${game.i18n.format("SW5E.ItemRechargeCheck", {name: this.name})} - ${game.i18n.localize(success ? "SW5E.ItemRechargeSuccess" : "SW5E.ItemRechargeFailure")}`,
2020-06-24 14:23:26 -04:00
speaker: ChatMessage.getSpeaker({actor: this.actor, token: this.actor.token})
})];
// Update the Item data
if ( success ) promises.push(this.update({"data.recharge.charged": true}));
return Promise.all(promises).then(() => roll);
}
/* -------------------------------------------- */
/**
2020-10-23 13:32:23 -04:00
* Roll a Tool Check. Rely upon the d20Roll logic for the core implementation
* @prarm {Object} options Roll configuration options provided to the d20Roll function
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
2020-06-24 14:23:26 -04:00
*/
rollToolCheck(options={}) {
if ( this.type !== "tool" ) throw "Wrong item type!";
// Prepare roll data
let rollData = this.getRollData();
const parts = [`@mod`, "@prof"];
2020-10-23 13:32:23 -04:00
const title = `${this.name} - ${game.i18n.localize("SW5E.ToolCheck")}`;
2020-06-24 14:23:26 -04:00
2020-10-23 13:32:23 -04:00
// Compose the roll data
const rollConfig = mergeObject({
2020-06-24 14:23:26 -04:00
parts: parts,
data: rollData,
template: "systems/sw5e/templates/chat/tool-roll-dialog.html",
title: title,
speaker: ChatMessage.getSpeaker({actor: this.actor}),
flavor: title,
2020-06-24 14:23:26 -04:00
dialogOptions: {
width: 400,
top: options.event ? options.event.clientY - 80 : null,
left: window.innerWidth - 710,
},
2020-10-23 13:32:23 -04:00
halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false,
messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }}
}, options);
rollConfig.event = options.event;
// Call the roll helper utility
return d20Roll(rollConfig);
2020-06-24 14:23:26 -04:00
}
/* -------------------------------------------- */
/**
* Prepare a data object which is passed to any Roll formulas which are created related to this Item
* @private
*/
getRollData() {
if ( !this.actor ) return null;
const rollData = this.actor.getRollData();
rollData.item = duplicate(this.data.data);
// Include an ability score modifier if one exists
const abl = this.abilityMod;
if ( abl ) {
const ability = rollData.abilities[abl];
rollData["mod"] = ability.mod || 0;
}
// Include a proficiency score
const prof = ("proficient" in rollData.item) ? (rollData.item.proficient || 0) : 1;
rollData["prof"] = Math.floor(prof * (rollData.attributes.prof || 0));
2020-06-24 14:23:26 -04:00
return rollData;
}
/* -------------------------------------------- */
/* Chat Message Helpers */
/* -------------------------------------------- */
static chatListeners(html) {
html.on('click', '.card-buttons button', this._onChatCardAction.bind(this));
html.on('click', '.item-name', this._onChatCardToggleContent.bind(this));
}
/* -------------------------------------------- */
/**
* Handle execution of a chat card action via a click event on one of the card buttons
* @param {Event} event The originating click event
* @returns {Promise} A promise which resolves once the handler workflow is complete
* @private
*/
static async _onChatCardAction(event) {
event.preventDefault();
// Extract card data
const button = event.currentTarget;
button.disabled = true;
const card = button.closest(".chat-card");
const messageId = card.closest(".message").dataset.messageId;
const message = game.messages.get(messageId);
const action = button.dataset.action;
// Validate permission to proceed with the roll
const isTargetted = action === "save";
if ( !( isTargetted || game.user.isGM || message.isAuthor ) ) return;
2020-10-23 13:32:23 -04:00
// Recover the actor for the chat card
2020-06-24 14:23:26 -04:00
const actor = this._getChatCardActor(card);
if ( !actor ) return;
2020-10-23 13:32:23 -04:00
// Get the Item from stored flag data or by the item ID on the Actor
const storedData = message.getFlag("sw5e", "itemData");
const item = storedData ? this.createOwned(storedData, actor) : actor.getOwnedItem(card.dataset.itemId);
2020-06-24 14:23:26 -04:00
if ( !item ) {
2020-10-23 13:32:23 -04:00
return ui.notifications.error(game.i18n.format("SW5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name}))
2020-06-24 14:23:26 -04:00
}
const powerLevel = parseInt(card.dataset.powerLevel) || null;
2020-10-23 13:32:23 -04:00
// Handle different actions
switch ( action ) {
case "attack":
await item.rollAttack({event}); break;
case "damage":
await item.rollDamage({event, powerLevel}); break;
case "versatile":
await item.rollDamage({event, powerLevel, versatile: true}); break;
case "formula":
await item.rollFormula({event, powerLevel}); break;
case "save":
const targets = this._getChatCardTargets(card);
for ( let token of targets ) {
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token});
await token.actor.rollAbilitySave(button.dataset.ability, { event, speaker });
}
break;
case "toolCheck":
await item.rollToolCheck({event}); break;
case "placeTemplate":
const template = AbilityTemplate.fromItem(item);
if ( template ) template.drawPreview();
break;
2020-06-24 14:23:26 -04:00
}
// Re-enable the button
button.disabled = false;
}
/* -------------------------------------------- */
/**
* Handle toggling the visibility of chat card content when the name is clicked
* @param {Event} event The originating click event
* @private
*/
static _onChatCardToggleContent(event) {
event.preventDefault();
const header = event.currentTarget;
const card = header.closest(".chat-card");
const content = card.querySelector(".card-content");
content.style.display = content.style.display === "none" ? "block" : "none";
}
/* -------------------------------------------- */
/**
* Get the Actor which is the author of a chat card
* @param {HTMLElement} card The chat card being used
* @return {Actor|null} The Actor entity or null
* @private
*/
static _getChatCardActor(card) {
// Case 1 - a synthetic actor from a Token
const tokenKey = card.dataset.tokenId;
if (tokenKey) {
const [sceneId, tokenId] = tokenKey.split(".");
const scene = game.scenes.get(sceneId);
if (!scene) return null;
const tokenData = scene.getEmbeddedEntity("Token", tokenId);
if (!tokenData) return null;
const token = new Token(tokenData);
return token.actor;
}
// Case 2 - use Actor ID directory
const actorId = card.dataset.actorId;
return game.actors.get(actorId) || null;
}
/* -------------------------------------------- */
/**
* Get the Actor which is the author of a chat card
* @param {HTMLElement} card The chat card being used
* @return {Array.<Actor>} An Array of Actor entities, if any
* @private
*/
static _getChatCardTargets(card) {
2020-10-23 13:32:23 -04:00
let targets = canvas.tokens.controlled.filter(t => !!t.actor);
if ( !targets.length && game.user.character ) targets = targets.concat(game.user.character.getActiveTokens());
if ( !targets.length ) ui.notifications.warn(game.i18n.localize("SW5E.ActionWarningNoToken"));
2020-06-24 14:23:26 -04:00
return targets;
}
2020-10-23 13:32:23 -04:00
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* Create a consumable power scroll Item from a power Item.
* @param {Item5e} power The power to be made into a scroll
* @return {Item5e} The created scroll consumable item
* @private
*/
static async createScrollFromPower(power) {
// Get power data
const itemData = power instanceof Item5e ? power.data : power;
const {actionType, description, source, activation, duration, target, range, damage, save, level} = itemData.data;
// Get scroll data
const scrollUuid = CONFIG.SW5E.powerScrollIds[level];
const scrollItem = await fromUuid(scrollUuid);
const scrollData = scrollItem.data;
delete scrollData._id;
// Split the scroll description into an intro paragraph and the remaining details
const scrollDescription = scrollData.data.description.value;
const pdel = '</p>';
const scrollIntroEnd = scrollDescription.indexOf(pdel);
const scrollIntro = scrollDescription.slice(0, scrollIntroEnd + pdel.length);
const scrollDetails = scrollDescription.slice(scrollIntroEnd + pdel.length);
// Create a composite description from the scroll description and the power details
const desc = `${scrollIntro}<hr/><h3>${itemData.name} (Level ${level})</h3><hr/>${description.value}<hr/><h3>Scroll Details</h3><hr/>${scrollDetails}`;
// Create the power scroll data
const powerScrollData = mergeObject(scrollData, {
name: `${game.i18n.localize("SW5E.PowerScroll")}: ${itemData.name}`,
img: itemData.img,
data: {
"description.value": desc.trim(),
source,
actionType,
activation,
duration,
target,
range,
damage,
save,
level
}
});
return new this(powerScrollData);
}
2020-06-24 14:23:26 -04:00
}