forked from GitHub-Mirrors/foundry-sw5e

Finished adding monster values Added innate and dual powercasting modes Innate powercasters do not use force points or slots when casting powers
1417 lines
50 KiB
JavaScript
1417 lines
50 KiB
JavaScript
import {simplifyRollFormula, d20Roll, damageRoll} from "../dice.js";
|
|
import AbilityUseDialog from "../apps/ability-use-dialog.js";
|
|
|
|
/**
|
|
* Override and extend the basic :class:`Item` implementation
|
|
*/
|
|
export default class Item5e extends Item {
|
|
|
|
/* -------------------------------------------- */
|
|
/* 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
|
|
if (itemData.ability) return itemData.ability;
|
|
|
|
// Case 2 - inferred from a parent actor
|
|
else if (this.actor) {
|
|
const actorData = this.actor.data.data;
|
|
|
|
// Powers - Use Actor powercasting modifier based on power school
|
|
if (this.data.type === "power") {
|
|
switch (this.data.data.school) {
|
|
case "lgt": return "wis";
|
|
case "uni": return (actorData.abilities["wis"].mod >= actorData.abilities["cha"].mod) ? "wis" : "cha";
|
|
case "drk": return "cha";
|
|
case "tec": return "int";
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
|
|
// 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";
|
|
}
|
|
|
|
// Case 3 - unknown
|
|
return null
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Does the Item implement an attack roll as part of its usage
|
|
* @type {boolean}
|
|
*/
|
|
get hasAttack() {
|
|
return ["mwak", "rwak", "mpak", "rpak"].includes(this.data.data.actionType);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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() {
|
|
const save = this.data.data?.save || {};
|
|
return !!(save.ability && save.scaling);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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 = this.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";
|
|
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;
|
|
}, []);
|
|
labels.materials = data?.materials?.value ?? null;
|
|
}
|
|
|
|
// Feat Items
|
|
else if ( itemData.type === "feat" ) {
|
|
const act = data.activation;
|
|
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");
|
|
}
|
|
|
|
// Species Items
|
|
else if ( itemData.type === "species" ) {
|
|
// labels.species = C.species[data.species];
|
|
}
|
|
// Archetype Items
|
|
else if ( itemData.type === "archetype" ) {
|
|
// labels.archetype = C.archetype[data.archetype];
|
|
}
|
|
// Background Items
|
|
else if ( itemData.type === "background" ) {
|
|
// labels.background = C.background[data.background];
|
|
}
|
|
// Class Feature Items
|
|
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];
|
|
}
|
|
|
|
// Equipment Items
|
|
else if ( itemData.type === "equipment" ) {
|
|
labels.armor = data.armor.value ? `${data.armor.value} ${game.i18n.localize("SW5E.AC")}` : "";
|
|
}
|
|
|
|
// 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 || {};
|
|
labels.recharge = `${game.i18n.localize("SW5E.Recharge")} [${chg.value}${parseInt(chg.value) < 6 ? "+" : ""}]`;
|
|
}
|
|
|
|
// Item Actions
|
|
if ( data.hasOwnProperty("actionType") ) {
|
|
// if this item is owned, we populate the label and saving throw during actor init
|
|
if (!this.isOwned) {
|
|
// Saving throws
|
|
this.getSaveDC();
|
|
|
|
// To Hit
|
|
this.getAttackToHit();
|
|
}
|
|
|
|
// 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(", ");
|
|
}
|
|
|
|
// Limited Uses
|
|
if ( this.isOwned && !!data.uses?.max ) {
|
|
let max = data.uses.max;
|
|
if ( !Number.isNumeric(max) ) {
|
|
max = Roll.replaceFormulaData(max, this.actor.getRollData());
|
|
if ( Roll.MATH_PROXY.safeEval ) max = Roll.MATH_PROXY.safeEval(max);
|
|
}
|
|
data.uses.max = Number(max);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update the derived power DC for an item that requires a saving throw
|
|
* @returns {number|null}
|
|
*/
|
|
getSaveDC() {
|
|
if ( !this.hasSave ) return;
|
|
const save = this.data.data?.save;
|
|
|
|
// Actor power-DC based scaling
|
|
if ( save.scaling === "power" ) {
|
|
switch (this.data.data.school) {
|
|
case "lgt": {
|
|
save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.powerForceLightDC") : null;
|
|
break;
|
|
}
|
|
case "uni": {
|
|
save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.powerForceUnivDC") : null;
|
|
break;
|
|
}
|
|
case "drk": {
|
|
save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.powerForceDarkDC") : null;
|
|
break;
|
|
}
|
|
case "tec": {
|
|
save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.powerTechDC") : null;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ability-score based scaling
|
|
else if ( save.scaling !== "flat" ) {
|
|
save.dc = this.isOwned ? getProperty(this.actor.data, `data.abilities.${save.scaling}.dc`) : null;
|
|
}
|
|
|
|
// Update labels
|
|
const abl = CONFIG.SW5E.abilities[save.ability];
|
|
this.labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability: abl});
|
|
return save.dc;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update a label to the Item detailing its total to hit bonus.
|
|
* Sources:
|
|
* - item entity's innate attack bonus
|
|
* - item's actor's proficiency bonus if applicable
|
|
* - item's actor's global bonuses to the given item type
|
|
* - item's ammunition if applicable
|
|
*
|
|
* @returns {Object} returns `rollData` and `parts` to be used in the item's Attack roll
|
|
*/
|
|
getAttackToHit() {
|
|
const itemData = this.data.data;
|
|
if ( !this.hasAttack || !itemData ) return;
|
|
const rollData = this.getRollData();
|
|
|
|
// Define Roll bonuses
|
|
const parts = [];
|
|
|
|
// Include the item's innate attack bonus as the initial value and label
|
|
if ( itemData.attackBonus ) {
|
|
parts.push(itemData.attackBonus)
|
|
this.labels.toHit = itemData.attackBonus;
|
|
}
|
|
|
|
// Take no further action for un-owned items
|
|
if ( !this.isOwned ) return {rollData, parts};
|
|
|
|
// Ability score modifier
|
|
parts.push(`@mod`);
|
|
|
|
// Add proficiency bonus if an explicit proficiency flag is present or for non-item features
|
|
if ( !["weapon", "consumable"].includes(this.data.type) || itemData.proficient ) {
|
|
parts.push("@prof");
|
|
}
|
|
|
|
// Actor-level global bonus to attack rolls
|
|
const actorBonus = this.actor.data.data.bonuses?.[itemData.actionType] || {};
|
|
if ( actorBonus.attack ) parts.push(actorBonus.attack);
|
|
|
|
// One-time bonus provided by consumed ammunition
|
|
if ( (itemData.consume?.type === 'ammo') && !!this.actor.items ) {
|
|
const ammoItemData = this.actor.items.get(itemData.consume.target)?.data;
|
|
|
|
if (ammoItemData) {
|
|
const ammoItemQuantity = ammoItemData.data.quantity;
|
|
const ammoCanBeConsumed = ammoItemQuantity && (ammoItemQuantity - (itemData.consume.amount ?? 0) >= 0);
|
|
const ammoItemAttackBonus = ammoItemData.data.attackBonus;
|
|
const ammoIsTypeConsumable = (ammoItemData.type === "consumable") && (ammoItemData.data.consumableType === "ammo")
|
|
if ( ammoCanBeConsumed && ammoItemAttackBonus && ammoIsTypeConsumable ) {
|
|
parts.push("@ammo");
|
|
rollData["ammo"] = ammoItemAttackBonus;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Condense the resulting attack bonus formula into a simplified label
|
|
let toHitLabel = simplifyRollFormula(parts.join('+'), rollData).trim();
|
|
if (toHitLabel.charAt(0) !== '-') {
|
|
toHitLabel = '+ ' + toHitLabel
|
|
}
|
|
this.labels.toHit = toHitLabel;
|
|
|
|
// Update labels and return the prepared roll data
|
|
return {rollData, parts};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Roll the item to Chat, creating a chat card which contains follow up attack or damage roll options
|
|
* @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).
|
|
* @return {Promise<ChatMessage|object|void>}
|
|
*/
|
|
async roll({configureDialog=true, rollMode, createMessage=true}={}) {
|
|
let item = this;
|
|
const actor = this.actor;
|
|
|
|
// Reference aspects of the item data necessary for usage
|
|
const id = this.data.data; // Item data
|
|
const hasArea = this.hasAreaTarget; // Is the ability usage an AoE?
|
|
const resource = id.consume || {}; // Resource consumption
|
|
const recharge = id.recharge || {}; // Recharge mechanic
|
|
const uses = id?.uses ?? {}; // Limited uses
|
|
const isPower = this.type === "power"; // Does the item require a power slot?
|
|
// TODO: Possibly Mod this to not consume slots based on class?
|
|
const requirePowerSlot = isPower && (id.level > 0) && CONFIG.SW5E.powerUpcastModes.includes(id.preparation.mode);
|
|
|
|
// Define follow-up actions resulting from the item usage
|
|
let createMeasuredTemplate = hasArea; // Trigger a template creation
|
|
let consumeRecharge = !!recharge.value; // Consume recharge
|
|
let consumeResource = !!resource.target && (resource.type !== "ammo") // Consume a linked (non-ammo) resource
|
|
let consumePowerSlot = requirePowerSlot; // Consume a power slot
|
|
let consumeUsage = !!uses.per; // Consume limited uses
|
|
let consumeQuantity = uses.autoDestroy; // Consume quantity of the item in lieu of uses
|
|
|
|
// Display a configuration dialog to customize the usage
|
|
const needsConfiguration = createMeasuredTemplate || consumeRecharge || consumeResource || consumePowerSlot || consumeUsage;
|
|
if (configureDialog && needsConfiguration) {
|
|
const configuration = await AbilityUseDialog.create(this);
|
|
if (!configuration) return;
|
|
|
|
|
|
// Determine consumption preferences
|
|
createMeasuredTemplate = Boolean(configuration.placeTemplate);
|
|
consumeUsage = Boolean(configuration.consumeUse);
|
|
consumeRecharge = Boolean(configuration.consumeRecharge);
|
|
consumeResource = Boolean(configuration.consumeResource);
|
|
consumePowerSlot = Boolean(configuration.consumeSlot);
|
|
|
|
// Handle power upcasting
|
|
if ( requirePowerSlot ) {
|
|
const slotLevel = configuration.level;
|
|
const powerLevel = parseInt(slotLevel);
|
|
|
|
if (powerLevel !== id.level) {
|
|
const upcastData = mergeObject(this.data, {"data.level": powerLevel}, {inplace: false});
|
|
item = this.constructor.createOwned(upcastData, actor); // Replace the item with an upcast version
|
|
}
|
|
if ( consumePowerSlot ) consumePowerSlot = `power${powerLevel}`;
|
|
}
|
|
}
|
|
|
|
// Determine whether the item can be used by testing for resource consumption
|
|
const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerSlot, consumeUsage, consumeQuantity});
|
|
if ( !usage ) return;
|
|
|
|
const {actorUpdates, itemUpdates, resourceUpdates} = usage;
|
|
|
|
// Commit pending data updates
|
|
if ( !isObjectEmpty(itemUpdates) ) await item.update(itemUpdates);
|
|
if ( consumeQuantity && (item.data.data.quantity === 0) ) await item.delete();
|
|
if ( !isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates);
|
|
if ( !isObjectEmpty(resourceUpdates) ) {
|
|
const resource = actor.items.get(id.consume?.target);
|
|
if ( resource ) await resource.update(resourceUpdates);
|
|
}
|
|
|
|
// Initiate measured template creation
|
|
if ( createMeasuredTemplate ) {
|
|
const template = game.sw5e.canvas.AbilityTemplate.fromItem(item);
|
|
if ( template ) template.drawPreview();
|
|
}
|
|
|
|
// Create or return the Chat Message data
|
|
return item.displayCard({rollMode, createMessage});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Verify that the consumed resources used by an Item are available.
|
|
* Otherwise display an error and return false.
|
|
* @param {boolean} consumeQuantity Consume quantity of the item if other consumption modes are not available?
|
|
* @param {boolean} consumeRecharge Whether the item consumes the recharge mechanic
|
|
* @param {boolean} consumeResource Whether the item consumes a limited resource
|
|
* @param {string|boolean} consumePowerSlot A level of power slot consumed, or false
|
|
* @param {boolean} consumeUsage Whether the item consumes a limited usage
|
|
* @returns {object|boolean} A set of data changes to apply when the item is used, or false
|
|
* @private
|
|
*/
|
|
_getUsageUpdates({consumeQuantity=false, consumeRecharge=false, consumeResource=false, consumePowerSlot=false, consumeUsage=false}) {
|
|
|
|
// Reference item data
|
|
const id = this.data.data;
|
|
const actorUpdates = {};
|
|
const itemUpdates = {};
|
|
const resourceUpdates = {};
|
|
|
|
// Consume Recharge
|
|
if ( consumeRecharge ) {
|
|
const recharge = id.recharge || {};
|
|
if ( recharge.charged === false ) {
|
|
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
|
|
return false;
|
|
}
|
|
itemUpdates["data.recharge.charged"] = false;
|
|
}
|
|
|
|
// Consume Limited Resource
|
|
if ( consumeResource ) {
|
|
const canConsume = this._handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates);
|
|
if ( canConsume === false ) return false;
|
|
}
|
|
|
|
// Consume Power Slots and Force/Tech Points
|
|
if ( consumePowerSlot ) {
|
|
const level = this.actor?.data.data.powers[consumePowerSlot];
|
|
const fp = this.actor.data.data.attributes.force.points;
|
|
const tp = this.actor.data.data.attributes.tech.points;
|
|
const powerCost = id.level + 1;
|
|
const innatePower = this.actor.data.data.attributes.powercasting === 'innate';
|
|
if (!innatePower){
|
|
switch (id.school){
|
|
case "lgt":
|
|
case "uni":
|
|
case "drk": {
|
|
const powers = Number(level?.fvalue ?? 0);
|
|
if ( powers === 0 ) {
|
|
const label = game.i18n.localize(`SW5E.PowerLevel${id.level}`);
|
|
ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label}));
|
|
return false;
|
|
}
|
|
actorUpdates[`data.powers.${consumePowerSlot}.fvalue`] = Math.max(powers - 1, 0);
|
|
if (fp.temp >= powerCost) {
|
|
actorUpdates["data.attributes.force.points.temp"] = fp.temp - powerCost;
|
|
}else{
|
|
actorUpdates["data.attributes.force.points.value"] = fp.value + fp.temp - powerCost;
|
|
actorUpdates["data.attributes.force.points.temp"] = 0;
|
|
}
|
|
break;
|
|
}
|
|
case "tec": {
|
|
const powers = Number(level?.tvalue ?? 0);
|
|
if ( powers === 0 ) {
|
|
const label = game.i18n.localize(`SW5E.PowerLevel${id.level}`);
|
|
ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label}));
|
|
return false;
|
|
}
|
|
actorUpdates[`data.powers.${consumePowerSlot}.tvalue`] = Math.max(powers - 1, 0);
|
|
if (tp.temp >= powerCost) {
|
|
actorUpdates["data.attributes.tech.points.temp"] = tp.temp - powerCost;
|
|
}else{
|
|
actorUpdates["data.attributes.tech.points.value"] = tp.value + tp.temp - powerCost;
|
|
actorUpdates["data.attributes.tech.points.temp"] = 0;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Consume Limited Usage
|
|
if ( consumeUsage ) {
|
|
const uses = id.uses || {};
|
|
const available = Number(uses.value ?? 0);
|
|
let used = false;
|
|
|
|
// Reduce usages
|
|
const remaining = Math.max(available - 1, 0);
|
|
if ( available >= 1 ) {
|
|
used = true;
|
|
itemUpdates["data.uses.value"] = remaining;
|
|
}
|
|
|
|
// Reduce quantity if not reducing usages or if usages hit 0 and we are set to consumeQuantity
|
|
if ( consumeQuantity && (!used || (remaining === 0)) ) {
|
|
const q = Number(id.quantity ?? 1);
|
|
if ( q >= 1 ) {
|
|
used = true;
|
|
itemUpdates["data.quantity"] = Math.max(q - 1, 0);
|
|
itemUpdates["data.uses.value"] = uses.max ?? 1;
|
|
}
|
|
}
|
|
|
|
// If the item was not used, return a warning
|
|
if ( !used ) {
|
|
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Return the configured usage
|
|
return {itemUpdates, actorUpdates, resourceUpdates};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Handle update actions required when consuming an external resource
|
|
* @param {object} itemUpdates An object of data updates applied to this item
|
|
* @param {object} actorUpdates An object of data updates applied to the item owner (Actor)
|
|
* @param {object} resourceUpdates An object of data updates applied to a different resource item (Item)
|
|
* @return {boolean|void} Return false to block further progress, or return nothing to continue
|
|
* @private
|
|
*/
|
|
_handleConsumeResource(itemUpdates, actorUpdates, resourceUpdates) {
|
|
const actor = this.actor;
|
|
const itemData = this.data.data;
|
|
const consume = itemData.consume || {};
|
|
if ( !consume.type ) return;
|
|
|
|
// No consumed target
|
|
const typeLabel = CONFIG.SW5E.abilityConsumptionTypes[consume.type];
|
|
if ( !consume.target ) {
|
|
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel}));
|
|
return false;
|
|
}
|
|
|
|
// Identify the consumed resource and its current quantity
|
|
let resource = null;
|
|
let amount = Number(consume.amount ?? 1);
|
|
let quantity = 0;
|
|
switch ( consume.type ) {
|
|
case "attribute":
|
|
resource = getProperty(actor.data.data, consume.target);
|
|
quantity = resource || 0;
|
|
break;
|
|
case "ammo":
|
|
case "material":
|
|
resource = actor.items.get(consume.target);
|
|
quantity = resource ? resource.data.data.quantity : 0;
|
|
break;
|
|
case "charges":
|
|
resource = actor.items.get(consume.target);
|
|
if ( !resource ) break;
|
|
const uses = resource.data.data.uses;
|
|
if ( uses.per && uses.max ) quantity = uses.value;
|
|
else if ( resource.data.data.recharge?.value ) {
|
|
quantity = resource.data.data.recharge.charged ? 1 : 0;
|
|
amount = 1;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Verify that a consumed resource is available
|
|
if ( !resource ) {
|
|
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoSource", {name: this.name, type: typeLabel}));
|
|
return false;
|
|
}
|
|
|
|
// Verify that the required quantity is available
|
|
let remaining = quantity - amount;
|
|
if ( remaining < 0 ) {
|
|
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoQuantity", {name: this.name, type: typeLabel}));
|
|
return false;
|
|
}
|
|
|
|
// Define updates to provided data objects
|
|
switch ( consume.type ) {
|
|
case "attribute":
|
|
actorUpdates[`data.${consume.target}`] = remaining;
|
|
break;
|
|
case "ammo":
|
|
case "material":
|
|
resourceUpdates["data.quantity"] = remaining;
|
|
break;
|
|
case "charges":
|
|
const uses = resource.data.data.uses || {};
|
|
const recharge = resource.data.data.recharge || {};
|
|
if ( uses.per && uses.max ) resourceUpdates["data.uses.value"] = remaining;
|
|
else if ( recharge.value ) resourceUpdates["data.recharge.charged"] = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Display the chat card for an Item as a Chat Message
|
|
* @param {object} options Options which configure the display of the item chat card
|
|
* @param {string} rollMode The message visibility mode to apply to the created card
|
|
* @param {boolean} createMessage Whether to automatically create a ChatMessage entity (if true), or only return
|
|
* the prepared message data (if false)
|
|
*/
|
|
async displayCard({rollMode, createMessage=true}={}) {
|
|
|
|
// 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
|
|
};
|
|
|
|
// Render the chat card template
|
|
const templateType = ["tool"].includes(this.data.type) ? this.data.type : "item";
|
|
const template = `systems/sw5e/templates/chat/${templateType}-card.html`;
|
|
const html = await renderTemplate(template, templateData);
|
|
|
|
// Create the ChatMessage data object
|
|
const chatData = {
|
|
user: game.user._id,
|
|
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
|
|
content: html,
|
|
flavor: this.data.data.chatFlavor || this.name,
|
|
speaker: ChatMessage.getSpeaker({actor: this.actor, token}),
|
|
flags: {"core.canPopout": true}
|
|
};
|
|
|
|
// If the Item was destroyed in the process of displaying its card - embed the item data in the chat message
|
|
if ( (this.data.type === "consumable") && !this.actor.items.has(this.id) ) {
|
|
chatData.flags["sw5e.itemData"] = this.data;
|
|
}
|
|
|
|
// Apply the roll mode to adjust message visibility
|
|
ChatMessage.applyRollMode(chatData, rollMode || game.settings.get("core", "rollMode"));
|
|
|
|
// Create the Chat Message or return its data
|
|
return createMessage ? ChatMessage.create(chatData) : chatData;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* 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
|
|
*/
|
|
getChatData(htmlOptions={}) {
|
|
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);
|
|
|
|
// Equipment properties
|
|
if ( data.hasOwnProperty("equipped") && !["loot", "tool"].includes(this.data.type) ) {
|
|
if ( data.attunement === CONFIG.SW5E.attunementTypes.REQUIRED ) props.push(game.i18n.localize(CONFIG.SW5E.attunements[CONFIG.SW5E.attunementTypes.REQUIRED]));
|
|
props.push(
|
|
game.i18n.localize(data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"),
|
|
game.i18n.localize(data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"),
|
|
);
|
|
}
|
|
|
|
// Ability activation properties
|
|
if ( data.hasOwnProperty("activation") ) {
|
|
props.push(
|
|
labels.activation + (data.activation?.condition ? ` (${data.activation.condition})` : ""),
|
|
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,
|
|
data.stealth.value ? game.i18n.localize("SW5E.StealthDisadvantage") : null
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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],
|
|
data.uses.value + "/" + data.uses.max + " " + game.i18n.localize("SW5E.Charges")
|
|
);
|
|
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 loot type items
|
|
* @private
|
|
*/
|
|
_lootChatData(data, labels, props) {
|
|
props.push(
|
|
game.i18n.localize("SW5E.ItemTypeLoot"),
|
|
data.weight ? data.weight + " " + game.i18n.localize("SW5E.AbbreviationLbs") : null
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Render a chat card for Power type data
|
|
* @return {Object}
|
|
* @private
|
|
*/
|
|
_powerChatData(data, labels, props) {
|
|
props.push(
|
|
labels.level,
|
|
labels.components + (labels.materials ? ` (${labels.materials})` : "")
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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)
|
|
* Rely upon the d20Roll logic for the core implementation
|
|
*
|
|
* @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
|
|
*/
|
|
async rollAttack(options={}) {
|
|
const itemData = this.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.");
|
|
}
|
|
let title = `${this.name} - ${game.i18n.localize("SW5E.AttackRoll")}`;
|
|
|
|
// get the parts and rollData for this item's attack
|
|
const {parts, rollData} = this.getAttackToHit();
|
|
|
|
// Handle ammunition consumption
|
|
delete this._ammo;
|
|
let ammo = null;
|
|
let ammoUpdate = null;
|
|
const consume = itemData.consume;
|
|
if ( consume?.type === "ammo" ) {
|
|
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;
|
|
title += ` [${ammo.name}]`;
|
|
}
|
|
}
|
|
|
|
// Get pending ammunition update
|
|
const usage = this._getUsageUpdates({consumeResource: true});
|
|
if ( usage === false ) return null;
|
|
ammoUpdate = usage.resourceUpdates || {};
|
|
}
|
|
|
|
// Compose roll options
|
|
const rollConfig = mergeObject({
|
|
parts: parts,
|
|
actor: this.actor,
|
|
data: rollData,
|
|
title: title,
|
|
flavor: title,
|
|
speaker: ChatMessage.getSpeaker({actor: this.actor}),
|
|
dialogOptions: {
|
|
width: 400,
|
|
top: options.event ? options.event.clientY - 80 : null,
|
|
left: window.innerWidth - 710
|
|
},
|
|
messageData: {"flags.sw5e.roll": {type: "attack", itemId: this.id }}
|
|
}, options);
|
|
rollConfig.event = options.event;
|
|
|
|
// Expanded critical hit thresholds
|
|
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);
|
|
}
|
|
|
|
// 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
|
|
const roll = await d20Roll(rollConfig);
|
|
if ( roll === false ) return null;
|
|
|
|
// Commit ammunition consumption on attack rolls resource consumption if the attack roll was made
|
|
if ( ammo && !isObjectEmpty(ammoUpdate) ) await ammo.update(ammoUpdate);
|
|
return roll;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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 {boolean} [critical] Should damage be rolled as a critical hit?
|
|
* @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
|
|
*/
|
|
rollDamage({critical=false, event=null, powerLevel=null, versatile=false, options={}}={}) {
|
|
if ( !this.hasDamage ) throw new Error("You may not make a Damage Roll with this Item.");
|
|
const itemData = this.data.data;
|
|
const actorData = this.actor.data.data;
|
|
const messageData = {"flags.sw5e.roll": {type: "damage", itemId: this.id }};
|
|
|
|
// Get roll data
|
|
const parts = itemData.damage.parts.map(d => d[0]);
|
|
const rollData = this.getRollData();
|
|
if ( powerLevel ) rollData.item.level = powerLevel;
|
|
|
|
// Configure the damage roll
|
|
const actionFlavor = game.i18n.localize(itemData.actionType === "heal" ? "SW5E.Healing" : "SW5E.DamageRoll");
|
|
const title = `${this.name} - ${actionFlavor}`;
|
|
const rollConfig = {
|
|
actor: this.actor,
|
|
critical: critical ?? event?.altKey ?? false,
|
|
data: rollData,
|
|
event: event,
|
|
fastForward: event ? event.shiftKey || event.altKey || event.ctrlKey || event.metaKey : false,
|
|
parts: parts,
|
|
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
|
|
};
|
|
|
|
// 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
|
|
if ( (this.data.type === "power") ) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Add damage bonus formula
|
|
const actorBonus = getProperty(actorData, `bonuses.${itemData.actionType}`) || {};
|
|
if ( actorBonus.damage && (parseInt(actorBonus.damage) !== 0) ) {
|
|
parts.push(actorBonus.damage);
|
|
}
|
|
|
|
// Handle ammunition damage
|
|
const ammoData = this._ammo?.data;
|
|
|
|
// only add the ammunition damage if the ammution is a consumable with type 'ammo'
|
|
if ( this._ammo && (ammoData.type === "consumable") && (ammoData.data.consumableType === "ammo") ) {
|
|
parts.push("@ammo");
|
|
rollData["ammo"] = ammoData.data.damage.parts.map(p => p[0]).join("+");
|
|
rollConfig.flavor += ` [${this._ammo.name}]`;
|
|
delete this._ammo;
|
|
}
|
|
|
|
// Scale melee critical hit damage
|
|
if ( itemData.actionType === "mwak" ) {
|
|
rollConfig.criticalBonusDice = this.actor.getFlag("sw5e", "meleeCriticalDamageDice") ?? 0;
|
|
}
|
|
|
|
// Call the roll helper utility
|
|
return damageRoll(mergeObject(rollConfig, options));
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Adjust an at-will damage formula to scale it for higher level characters and monsters
|
|
* @private
|
|
*/
|
|
_scaleAtWillDamage(parts, scale, level, rollData) {
|
|
const add = Math.floor((level + 1) / 6);
|
|
if ( add === 0 ) return;
|
|
this._scaleDamage(parts, scale || parts.join(" + "), add, rollData);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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
|
|
* @param {object} rollData A data object that should be applied to the scaled damage roll
|
|
* @return {string[]} The scaled roll parts
|
|
* @private
|
|
*/
|
|
_scalePowerDamage(parts, baseLevel, powerLevel, formula, rollData) {
|
|
const upcastLevels = Math.max(powerLevel - baseLevel, 0);
|
|
if ( upcastLevels === 0 ) return parts;
|
|
this._scaleDamage(parts, formula, upcastLevels, rollData);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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}`;
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Place an attack roll using an item (weapon, feat, power, or equipment)
|
|
* Rely upon the d20Roll logic for the core implementation
|
|
*
|
|
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
|
|
*/
|
|
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();
|
|
if ( options.powerLevel ) rollData.item.level = options.powerLevel;
|
|
const title = `${this.name} - ${game.i18n.localize("SW5E.OtherFormula")}`;
|
|
|
|
// 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,
|
|
rollMode: game.settings.get("core", "rollMode"),
|
|
messageData: {"flags.sw5e.roll": {type: "other", itemId: this.id }}
|
|
});
|
|
return roll;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Perform an ability recharge test for an item which uses the d6 recharge mechanic
|
|
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
|
|
*/
|
|
async rollRecharge() {
|
|
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({
|
|
flavor: `${game.i18n.format("SW5E.ItemRechargeCheck", {name: this.name})} - ${game.i18n.localize(success ? "SW5E.ItemRechargeSuccess" : "SW5E.ItemRechargeFailure")}`,
|
|
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);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
rollToolCheck(options={}) {
|
|
if ( this.type !== "tool" ) throw "Wrong item type!";
|
|
|
|
// Prepare roll data
|
|
let rollData = this.getRollData();
|
|
const parts = [`@mod`, "@prof"];
|
|
const title = `${this.name} - ${game.i18n.localize("SW5E.ToolCheck")}`;
|
|
|
|
// Compose the roll data
|
|
const rollConfig = mergeObject({
|
|
parts: parts,
|
|
data: rollData,
|
|
template: "systems/sw5e/templates/chat/tool-roll-dialog.html",
|
|
title: title,
|
|
speaker: ChatMessage.getSpeaker({actor: this.actor}),
|
|
flavor: title,
|
|
dialogOptions: {
|
|
width: 400,
|
|
top: options.event ? options.event.clientY - 80 : null,
|
|
left: window.innerWidth - 710,
|
|
},
|
|
halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false,
|
|
reliableTalent: (this.data.data.proficient >= 1) && this.actor.getFlag("sw5e", "reliableTalent"),
|
|
messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }}
|
|
}, options);
|
|
rollConfig.event = options.event;
|
|
|
|
// Call the roll helper utility
|
|
return d20Roll(rollConfig);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* 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));
|
|
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;
|
|
|
|
// Recover the actor for the chat card
|
|
const actor = this._getChatCardActor(card);
|
|
if ( !actor ) return;
|
|
|
|
// 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);
|
|
if ( !item ) {
|
|
return ui.notifications.error(game.i18n.format("SW5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name}))
|
|
}
|
|
const powerLevel = parseInt(card.dataset.powerLevel) || null;
|
|
|
|
// Handle different actions
|
|
switch ( action ) {
|
|
case "attack":
|
|
await item.rollAttack({event}); break;
|
|
case "damage":
|
|
case "versatile":
|
|
await item.rollDamage({
|
|
critical: event.altKey,
|
|
event: event,
|
|
powerLevel: powerLevel,
|
|
versatile: action === "versatile"
|
|
});
|
|
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 = game.sw5e.canvas.AbilityTemplate.fromItem(item);
|
|
if ( template ) template.drawPreview();
|
|
break;
|
|
}
|
|
|
|
// 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) {
|
|
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"));
|
|
return targets;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* 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);
|
|
}
|
|
}
|