forked from GitHub-Mirrors/foundry-sw5e
Merge pull request #105 from Cyr-/hotfix-feat-rolling
Added new handling for resources/rolling
This commit is contained in:
commit
8f2b0488a4
4 changed files with 494 additions and 325 deletions
|
@ -92,8 +92,14 @@ export default class Actor5e extends Actor {
|
||||||
init.total = init.mod + init.prof + init.bonus;
|
init.total = init.mod + init.prof + init.bonus;
|
||||||
|
|
||||||
// Prepare power-casting data
|
// Prepare power-casting data
|
||||||
this._computePowercastingDC(this.data);
|
data.attributes.powerdc = data.attributes.powercasting ? data.abilities[data.attributes.powercasting].dc : 10;
|
||||||
this._computePowercastingProgression(this.data);
|
this._computePowercastingProgression(this.data);
|
||||||
|
|
||||||
|
// Compute owned item attributes which depend on prepared Actor data
|
||||||
|
this.items.forEach(item => {
|
||||||
|
item.getSaveDC();
|
||||||
|
item.getAttackToHit();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
@ -168,7 +174,10 @@ export default class Actor5e extends Actor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load item data for all identified features
|
// Load item data for all identified features
|
||||||
const features = await Promise.all(ids.map(id => fromUuid(id)));
|
const features = [];
|
||||||
|
for ( let id of ids ) {
|
||||||
|
features.push(await fromUuid(id));
|
||||||
|
}
|
||||||
|
|
||||||
// Class powers should always be prepared
|
// Class powers should always be prepared
|
||||||
for ( const feature of features ) {
|
for ( const feature of features ) {
|
||||||
|
@ -312,19 +321,22 @@ export default class Actor5e extends Actor {
|
||||||
const joat = flags.jackOfAllTrades;
|
const joat = flags.jackOfAllTrades;
|
||||||
const observant = flags.observantFeat;
|
const observant = flags.observantFeat;
|
||||||
const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0;
|
const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0;
|
||||||
let round = Math.floor;
|
|
||||||
for (let [id, skl] of Object.entries(data.skills)) {
|
for (let [id, skl] of Object.entries(data.skills)) {
|
||||||
skl.value = parseFloat(skl.value || 0);
|
skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0;
|
||||||
|
let round = Math.floor;
|
||||||
|
|
||||||
// Apply Remarkable Athlete or Jack of all Trades
|
// Remarkable
|
||||||
let multi = skl.value;
|
if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) {
|
||||||
if ( athlete && (skl.value === 0) && feats.remarkableAthlete.abilities.includes(skl.ability) ) {
|
skl.value = 0.5;
|
||||||
multi = 0.5;
|
|
||||||
round = Math.ceil;
|
round = Math.ceil;
|
||||||
}
|
}
|
||||||
if ( joat && (skl.value === 0 ) ) multi = 0.5;
|
|
||||||
|
|
||||||
// Retain the maximum skill proficiency when skill proficiencies are merged
|
// Jack of All Trades
|
||||||
|
if ( joat && (skl.value < 0.5) ) {
|
||||||
|
skl.value = 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polymorph Skill Proficiencies
|
||||||
if ( originalSkills ) {
|
if ( originalSkills ) {
|
||||||
skl.value = Math.max(skl.value, originalSkills[id].value);
|
skl.value = Math.max(skl.value, originalSkills[id].value);
|
||||||
}
|
}
|
||||||
|
@ -332,7 +344,7 @@ export default class Actor5e extends Actor {
|
||||||
// Compute modifier
|
// Compute modifier
|
||||||
skl.bonus = checkBonus + skillBonus;
|
skl.bonus = checkBonus + skillBonus;
|
||||||
skl.mod = data.abilities[skl.ability].mod;
|
skl.mod = data.abilities[skl.ability].mod;
|
||||||
skl.prof = round(multi * data.attributes.prof);
|
skl.prof = round(skl.value * data.attributes.prof);
|
||||||
skl.total = skl.mod + skl.prof + skl.bonus;
|
skl.total = skl.mod + skl.prof + skl.bonus;
|
||||||
|
|
||||||
// Compute passive bonus
|
// Compute passive bonus
|
||||||
|
@ -343,23 +355,6 @@ export default class Actor5e extends Actor {
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the powercasting DC for all item abilities which use power DC scaling
|
|
||||||
* @param {object} actorData The actor data being prepared
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_computePowercastingDC(actorData) {
|
|
||||||
|
|
||||||
// Compute the powercasting DC
|
|
||||||
const data = actorData.data;
|
|
||||||
data.attributes.powerdc = data.attributes.powercasting ? data.abilities[data.attributes.powercasting].dc : 10;
|
|
||||||
|
|
||||||
// Compute ability save DCs that depend on the calling actor
|
|
||||||
this.items.forEach(i => i.getSaveDC());
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare data related to the power-casting capabilities of the Actor
|
* Prepare data related to the power-casting capabilities of the Actor
|
||||||
* @private
|
* @private
|
||||||
|
@ -408,7 +403,7 @@ export default class Actor5e extends Actor {
|
||||||
progression.slot = Math.ceil(caster.data.levels / denom);
|
progression.slot = Math.ceil(caster.data.levels / denom);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EXCEPTION: NPC with an explicit powercaster level
|
// EXCEPTION: NPC with an explicit power-caster level
|
||||||
if (isNPC && actorData.data.details.powerLevel) {
|
if (isNPC && actorData.data.details.powerLevel) {
|
||||||
progression.slot = actorData.data.details.powerLevel;
|
progression.slot = actorData.data.details.powerLevel;
|
||||||
}
|
}
|
||||||
|
@ -419,9 +414,9 @@ export default class Actor5e extends Actor {
|
||||||
for ( let [n, lvl] of Object.entries(powers) ) {
|
for ( let [n, lvl] of Object.entries(powers) ) {
|
||||||
let i = parseInt(n.slice(-1));
|
let i = parseInt(n.slice(-1));
|
||||||
if ( Number.isNaN(i) ) continue;
|
if ( Number.isNaN(i) ) continue;
|
||||||
if ( Number.isNumeric(lvl.override) ) lvl.max = Math.max(parseInt(lvl.override), 1);
|
if ( Number.isNumeric(lvl.override) ) lvl.max = Math.max(parseInt(lvl.override), 0);
|
||||||
else lvl.max = slots[i-1] || 0;
|
else lvl.max = slots[i-1] || 0;
|
||||||
lvl.value = Math.min(parseInt(lvl.value), lvl.max);
|
lvl.value = parseInt(lvl.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the Actor's pact magic level (if any)
|
// Determine the Actor's pact magic level (if any)
|
||||||
|
@ -1108,8 +1103,7 @@ export default class Actor5e extends Actor {
|
||||||
|
|
||||||
// Recover power slots
|
// Recover power slots
|
||||||
for ( let [k, v] of Object.entries(data.powers) ) {
|
for ( let [k, v] of Object.entries(data.powers) ) {
|
||||||
if ( !v.max && !v.override ) continue;
|
updateData[`data.powers.${k}.value`] = !Number.isNaN(v.override) ? v.override : (v.max ?? 0);
|
||||||
updateData[`data.powers.${k}.value`] = v.override || v.max;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recover pact slots.
|
// Recover pact slots.
|
||||||
|
@ -1186,7 +1180,6 @@ export default class Actor5e extends Actor {
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform this Actor into another one.
|
* Transform this Actor into another one.
|
||||||
*
|
*
|
||||||
|
@ -1216,10 +1209,10 @@ export default class Actor5e extends Actor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the original Actor data and the new source data
|
// Get the original Actor data and the new source data
|
||||||
const o = duplicate(this.data);
|
const o = this.toJSON();
|
||||||
o.flags.sw5e = o.flags.sw5e || {};
|
o.flags.sw5e = o.flags.sw5e || {};
|
||||||
o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves};
|
o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves};
|
||||||
const source = duplicate(target.data);
|
const source = target.toJSON();
|
||||||
|
|
||||||
// Prepare new data to merge from the source
|
// Prepare new data to merge from the source
|
||||||
const d = {
|
const d = {
|
||||||
|
@ -1227,6 +1220,7 @@ export default class Actor5e extends Actor {
|
||||||
name: `${o.name} (${source.name})`, // Append the new shape to your old name
|
name: `${o.name} (${source.name})`, // Append the new shape to your old name
|
||||||
data: source.data, // Get the data model of your new form
|
data: source.data, // Get the data model of your new form
|
||||||
items: source.items, // Get the items of your new form
|
items: source.items, // Get the items of your new form
|
||||||
|
effects: o.effects.concat(source.effects), // Combine active effects from both forms
|
||||||
token: source.token, // New token configuration
|
token: source.token, // New token configuration
|
||||||
img: source.img, // New appearance
|
img: source.img, // New appearance
|
||||||
permission: o.permission, // Use the original actor permissions
|
permission: o.permission, // Use the original actor permissions
|
||||||
|
|
|
@ -56,6 +56,16 @@ SW5E.alignments = {
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An enumeration of item attunement types
|
||||||
|
* @enum {number}
|
||||||
|
*/
|
||||||
|
SW5E.attunementTypes = {
|
||||||
|
NONE: 0,
|
||||||
|
REQUIRED: 1,
|
||||||
|
ATTUNED: 2,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An enumeration of item attunement states
|
* An enumeration of item attunement states
|
||||||
* @type {{"0": string, "1": string, "2": string}}
|
* @type {{"0": string, "1": string, "2": string}}
|
||||||
|
@ -457,7 +467,6 @@ SW5E.senses = {
|
||||||
"truesight": "SW5E.SenseTruesight"
|
"truesight": "SW5E.SenseTruesight"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,3 +1,69 @@
|
||||||
|
/**
|
||||||
|
* A standardized helper function for simplifying the constant parts of a multipart roll formula
|
||||||
|
*
|
||||||
|
* @param {string} formula The original Roll formula
|
||||||
|
* @param {Object} data Actor or item data against which to parse the roll
|
||||||
|
* @param {Object} options Formatting options
|
||||||
|
* @param {boolean} options.constantFirst Puts the constants before the dice terms in the resulting formula
|
||||||
|
*
|
||||||
|
* @return {string} The resulting simplified formula
|
||||||
|
*/
|
||||||
|
export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) {
|
||||||
|
const roll = new Roll(formula, data); // Parses the formula and replaces any @properties
|
||||||
|
const terms = roll.terms;
|
||||||
|
|
||||||
|
// Some terms are "too complicated" for this algorithm to simplify
|
||||||
|
// In this case, the original formula is returned.
|
||||||
|
if (terms.some(_isUnsupportedTerm)) return roll.formula;
|
||||||
|
|
||||||
|
const rollableTerms = []; // Terms that are non-constant, and their associated operators
|
||||||
|
const constantTerms = []; // Terms that are constant, and their associated operators
|
||||||
|
let operators = []; // Temporary storage for operators before they are moved to one of the above
|
||||||
|
|
||||||
|
for (let term of terms) { // For each term
|
||||||
|
if (["+", "-"].includes(term)) operators.push(term); // If the term is an addition/subtraction operator, push the term into the operators array
|
||||||
|
else { // Otherwise the term is not an operator
|
||||||
|
if (term instanceof DiceTerm) { // If the term is something rollable
|
||||||
|
rollableTerms.push(...operators); // Place all the operators into the rollableTerms array
|
||||||
|
rollableTerms.push(term); // Then place this rollable term into it as well
|
||||||
|
} //
|
||||||
|
else { // Otherwise, this must be a constant
|
||||||
|
constantTerms.push(...operators); // Place the operators into the constantTerms array
|
||||||
|
constantTerms.push(term); // Then also add this constant term to that array.
|
||||||
|
} //
|
||||||
|
operators = []; // Finally, the operators have now all been assigend to one of the arrays, so empty this before the next iteration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const constantFormula = Roll.cleanFormula(constantTerms); // Cleans up the constant terms and produces a new formula string
|
||||||
|
const rollableFormula = Roll.cleanFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string
|
||||||
|
|
||||||
|
const constantPart = roll._safeEval(constantFormula); // Mathematically evaluate the constant formula to produce a single constant term
|
||||||
|
|
||||||
|
const parts = constantFirst ? // Order the rollable and constant terms, either constant first or second depending on the optional argumen
|
||||||
|
[constantPart, rollableFormula] : [rollableFormula, constantPart];
|
||||||
|
|
||||||
|
// Join the parts with a + sign, pass them to `Roll` once again to clean up the formula
|
||||||
|
return new Roll(parts.filterJoin(" + ")).formula;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only some terms are supported by simplifyRollFormula, this method returns true when the term is not supported.
|
||||||
|
* @param {*} term - A single Dice term to check support on
|
||||||
|
* @return {Boolean} True when unsupported, false if supported
|
||||||
|
*/
|
||||||
|
function _isUnsupportedTerm(term) {
|
||||||
|
const diceTerm = term instanceof DiceTerm;
|
||||||
|
const operator = ["+", "-"].includes(term);
|
||||||
|
const number = !isNaN(Number(term));
|
||||||
|
|
||||||
|
return !(diceTerm || operator || number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A standardized helper function for managing core 5e "d20 rolls"
|
* A standardized helper function for managing core 5e "d20 rolls"
|
||||||
*
|
*
|
||||||
|
@ -53,7 +119,7 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ
|
||||||
|
|
||||||
// Determine the d20 roll and modifiers
|
// Determine the d20 roll and modifiers
|
||||||
let nd = 1;
|
let nd = 1;
|
||||||
let mods = halflingLucky ? "r=1" : "";
|
let mods = halflingLucky ? "r1=1" : "";
|
||||||
|
|
||||||
// Handle advantage
|
// Handle advantage
|
||||||
if (adv === 1) {
|
if (adv === 1) {
|
||||||
|
@ -109,6 +175,8 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ
|
||||||
if (d.faces === 20) {
|
if (d.faces === 20) {
|
||||||
d.options.critical = critical;
|
d.options.critical = critical;
|
||||||
d.options.fumble = fumble;
|
d.options.fumble = fumble;
|
||||||
|
if ( adv === 1 ) d.options.advantage = true;
|
||||||
|
else if ( adv === -1 ) d.options.disadvantage = true;
|
||||||
if (targetValue) d.options.target = targetValue;
|
if (targetValue) d.options.target = targetValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,7 +199,6 @@ export async function d20Roll({parts=[], data={}, event={}, rollMode=null, templ
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Present a Dialog form which creates a d20 roll once submitted
|
* Present a Dialog form which creates a d20 roll once submitted
|
||||||
* @return {Promise<Roll>}
|
* @return {Promise<Roll>}
|
||||||
|
@ -175,7 +242,6 @@ async function _d20RollDialog({template, title, parts, data, rollMode, dialogOpt
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -235,14 +301,15 @@ export async function damageRoll({parts, actor, data, event={}, rollMode=null, t
|
||||||
roll.terms[0].alter(1, criticalBonusDice);
|
roll.terms[0].alter(1, criticalBonusDice);
|
||||||
roll._formula = roll.formula;
|
roll._formula = roll.formula;
|
||||||
}
|
}
|
||||||
roll.dice.forEach(d => d.options.critical = true);
|
|
||||||
messageData.flavor += ` (${game.i18n.localize("SW5E.Critical")})`;
|
messageData.flavor += ` (${game.i18n.localize("SW5E.Critical")})`;
|
||||||
if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].critical = true;
|
if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].critical = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the roll
|
// Execute the roll
|
||||||
try {
|
try {
|
||||||
return roll.roll();
|
roll.evaluate()
|
||||||
|
if ( crit ) roll.dice.forEach(d => d.options.critical = true); // TODO workaround core bug which wipes Roll#options on roll
|
||||||
|
return roll;
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
ui.notifications.error(`Dice roll evaluation failed: ${err.message}`);
|
ui.notifications.error(`Dice roll evaluation failed: ${err.message}`);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {d20Roll, damageRoll} from "../dice.js";
|
import {simplifyRollFormula, d20Roll, damageRoll} from "../dice.js";
|
||||||
import AbilityUseDialog from "../apps/ability-use-dialog.js";
|
import AbilityUseDialog from "../apps/ability-use-dialog.js";
|
||||||
import AbilityTemplate from "../pixi/ability-template.js";
|
import AbilityTemplate from "../pixi/ability-template.js";
|
||||||
|
|
||||||
|
@ -101,7 +101,8 @@ export default class Item5e extends Item {
|
||||||
* @type {boolean}
|
* @type {boolean}
|
||||||
*/
|
*/
|
||||||
get hasSave() {
|
get hasSave() {
|
||||||
return !!(this.data.data.save && this.data.data.save.ability);
|
const save = this.data.data?.save || {};
|
||||||
|
return !!(save.ability && save.scaling);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
@ -255,29 +256,41 @@ export default class Item5e extends Item {
|
||||||
// Saving throws
|
// Saving throws
|
||||||
this.getSaveDC();
|
this.getSaveDC();
|
||||||
|
|
||||||
|
// To Hit
|
||||||
|
this.getAttackToHit();
|
||||||
|
|
||||||
// Damage
|
// Damage
|
||||||
let dam = data.damage || {};
|
let dam = data.damage || {};
|
||||||
if ( dam.parts ) {
|
if ( dam.parts ) {
|
||||||
labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- ");
|
labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- ");
|
||||||
labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", ");
|
labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", ");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Assign labels
|
// Limited Uses
|
||||||
this.labels = labels;
|
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 spell DC for an item that requires a saving throw
|
* Update the derived power DC for an item that requires a saving throw
|
||||||
* @returns {number|null}
|
* @returns {number|null}
|
||||||
*/
|
*/
|
||||||
getSaveDC() {
|
getSaveDC() {
|
||||||
if ( !this.hasSave ) return;
|
if ( !this.hasSave ) return;
|
||||||
const save = this.data.data?.save;
|
const save = this.data.data?.save;
|
||||||
|
|
||||||
// Actor spell-DC based scaling
|
// Actor power-DC based scaling
|
||||||
if ( save.scaling === "spell" ) {
|
if ( save.scaling === "power" ) {
|
||||||
save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.spelldc") : null;
|
save.dc = this.isOwned ? getProperty(this.actor.data, "data.attributes.powerdc") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ability-score based scaling
|
// Ability-score based scaling
|
||||||
|
@ -286,22 +299,332 @@ export default class Item5e extends Item {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update labels
|
// Update labels
|
||||||
const abl = CONFIG.DND5E.abilities[save.ability];
|
const abl = CONFIG.SW5E.abilities[save.ability];
|
||||||
this.labels.save = game.i18n.format("DND5E.SaveDC", {dc: save.dc || "", ability: abl});
|
this.labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability: abl});
|
||||||
return save.dc;
|
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
|
* 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 {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 {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
|
* @param {boolean} [createMessage] Whether to automatically create a chat message (if true) or simply return
|
||||||
* the prepared chat message data (if false).
|
* the prepared chat message data (if false).
|
||||||
* @return {Promise}
|
* @return {Promise<ChatMessage|object|void>}
|
||||||
*/
|
*/
|
||||||
async roll({configureDialog=true, rollMode=null, createMessage=true}={}) {
|
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?
|
||||||
|
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 = slotLevel === "pact" ? actor.data.data.powerss.pact.level : 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 = slotLevel === "pact" ? "pact" : `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
|
||||||
|
if ( consumePowerSlot ) {
|
||||||
|
const level = this.actor?.data.data.powers[consumePowerSlot];
|
||||||
|
const powers = Number(level?.value ?? 0);
|
||||||
|
if ( powers === 0 ) {
|
||||||
|
const label = game.i18n.localize(consumePowerSlot === "pact" ? "SW5E.PowerProgPact" : `SW5E.PowerLevel${id.level}`);
|
||||||
|
ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
actorUpdates[`data.powers.${consumePowerSlot}.value`] = Math.max(powers - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Basic template rendering data
|
||||||
const token = this.actor.token;
|
const token = this.actor.token;
|
||||||
|
@ -320,190 +643,31 @@ export default class Item5e extends Item {
|
||||||
hasAreaTarget: this.hasAreaTarget
|
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;
|
|
||||||
} else if ( this.data.type === "consumable" ) {
|
|
||||||
let configured = await this._rollConsumable(configureDialog);
|
|
||||||
if ( configured === false ) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For items which consume a resource, handle that here
|
|
||||||
const allowed = await this._handleResourceConsumption({isCard: true, isAttack: false});
|
|
||||||
if ( allowed === false ) return;
|
|
||||||
|
|
||||||
// Render the chat card template
|
// Render the chat card template
|
||||||
const templateType = ["tool"].includes(this.data.type) ? this.data.type : "item";
|
const templateType = ["tool"].includes(this.data.type) ? this.data.type : "item";
|
||||||
const template = `systems/sw5e/templates/chat/${templateType}-card.html`;
|
const template = `systems/sw5e/templates/chat/${templateType}-card.html`;
|
||||||
const html = await renderTemplate(template, templateData);
|
const html = await renderTemplate(template, templateData);
|
||||||
|
|
||||||
// Basic chat message data
|
// Create the ChatMessage data object
|
||||||
const chatData = {
|
const chatData = {
|
||||||
user: game.user._id,
|
user: game.user._id,
|
||||||
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
|
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
|
||||||
content: html,
|
content: html,
|
||||||
flavor: this.data.data.chatFlavor || this.name,
|
flavor: this.data.data.chatFlavor || this.name,
|
||||||
speaker: {
|
speaker: ChatMessage.getSpeaker({actor: this.actor, token}),
|
||||||
actor: this.actor._id,
|
|
||||||
token: this.actor.token,
|
|
||||||
alias: this.actor.name
|
|
||||||
},
|
|
||||||
flags: {"core.canPopout": true}
|
flags: {"core.canPopout": true}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If the consumable was destroyed in the process - embed the item data in the surviving message
|
// 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) ) {
|
if ( (this.data.type === "consumable") && !this.actor.items.has(this.id) ) {
|
||||||
chatData.flags["sw5e.itemData"] = this.data;
|
chatData.flags["sw5e.itemData"] = this.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle default roll mode
|
// Apply the roll mode to adjust message visibility
|
||||||
rollMode = rollMode || game.settings.get("core", "rollMode");
|
ChatMessage.applyRollMode(chatData, rollMode || game.settings.get("core", "rollMode"));
|
||||||
if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperRecipients("GM");
|
|
||||||
if ( rollMode === "blindroll" ) chatData["blind"] = true;
|
|
||||||
|
|
||||||
// Create the chat message
|
// Create the Chat Message or return its data
|
||||||
if ( createMessage ) return ChatMessage.create(chatData);
|
return createMessage ? ChatMessage.create(chatData) : chatData;
|
||||||
else return chatData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
const charge = this.data.data.recharge;
|
|
||||||
const uses = this.data.data.uses;
|
|
||||||
let usesCharges = !!uses.per && !!uses.max;
|
|
||||||
let placeTemplate = false;
|
|
||||||
let consume = charge.value || usesCharges;
|
|
||||||
|
|
||||||
// Determine whether the feat uses charges
|
|
||||||
configureDialog = configureDialog && (consume || this.hasAreaTarget);
|
|
||||||
if ( configureDialog ) {
|
|
||||||
const usage = await AbilityUseDialog.create(this);
|
|
||||||
if ( usage === null ) return false;
|
|
||||||
consume = Boolean(usage.get("consumeUse"));
|
|
||||||
placeTemplate = Boolean(usage.get("placeTemplate"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Item data
|
|
||||||
const current = getProperty(this.data, "data.uses.value") || 0;
|
|
||||||
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});
|
|
||||||
}
|
|
||||||
else if ( consume && usesCharges ) {
|
|
||||||
if ( uses.value <= 0 ) {
|
|
||||||
ui.notifications.warn(game.i18n.format("SW5E.ItemNoUses", {name: this.name}));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
if ( template ) template.drawPreview();
|
|
||||||
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
@ -527,8 +691,9 @@ export default class Item5e extends Item {
|
||||||
const fn = this[`_${this.data.type}ChatData`];
|
const fn = this[`_${this.data.type}ChatData`];
|
||||||
if ( fn ) fn.bind(this)(data, labels, props);
|
if ( fn ) fn.bind(this)(data, labels, props);
|
||||||
|
|
||||||
// General equipment properties
|
// Equipment properties
|
||||||
if ( data.hasOwnProperty("equipped") && !["loot", "tool"].includes(this.data.type) ) {
|
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(
|
props.push(
|
||||||
game.i18n.localize(data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"),
|
game.i18n.localize(data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"),
|
||||||
game.i18n.localize(data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"),
|
game.i18n.localize(data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"),
|
||||||
|
@ -653,43 +818,35 @@ export default class Item5e extends Item {
|
||||||
*/
|
*/
|
||||||
async rollAttack(options={}) {
|
async rollAttack(options={}) {
|
||||||
const itemData = this.data.data;
|
const itemData = this.data.data;
|
||||||
const actorData = this.actor.data.data;
|
|
||||||
const flags = this.actor.data.flags.sw5e || {};
|
const flags = this.actor.data.flags.sw5e || {};
|
||||||
if ( !this.hasAttack ) {
|
if ( !this.hasAttack ) {
|
||||||
throw new Error("You may not place an Attack Roll with this Item.");
|
throw new Error("You may not place an Attack Roll with this Item.");
|
||||||
}
|
}
|
||||||
let title = `${this.name} - ${game.i18n.localize("SW5E.AttackRoll")}`;
|
let title = `${this.name} - ${game.i18n.localize("SW5E.AttackRoll")}`;
|
||||||
const rollData = this.getRollData();
|
|
||||||
|
|
||||||
// Define Roll bonuses
|
// get the parts and rollData for this item's attack
|
||||||
const parts = [`@mod`];
|
const {parts, rollData} = this.getAttackToHit();
|
||||||
if ( (this.data.type !== "weapon") || itemData.proficient ) {
|
|
||||||
parts.push("@prof");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attack Bonus
|
// Handle ammunition consumption
|
||||||
if ( itemData.attackBonus ) parts.push(itemData.attackBonus);
|
|
||||||
const actorBonus = actorData?.bonuses?.[itemData.actionType] || {};
|
|
||||||
if ( actorBonus.attack ) parts.push(actorBonus.attack);
|
|
||||||
|
|
||||||
// Ammunition Bonus
|
|
||||||
delete this._ammo;
|
delete this._ammo;
|
||||||
|
let ammo = null;
|
||||||
|
let ammoUpdate = null;
|
||||||
const consume = itemData.consume;
|
const consume = itemData.consume;
|
||||||
if ( consume?.type === "ammo" ) {
|
if ( consume?.type === "ammo" ) {
|
||||||
const ammo = this.actor.items.get(consume.target);
|
ammo = this.actor.items.get(consume.target);
|
||||||
if(ammo?.data){
|
if (ammo?.data) {
|
||||||
const q = ammo.data.data.quantity;
|
const q = ammo.data.data.quantity;
|
||||||
const consumeAmount = consume.amount ?? 0;
|
const consumeAmount = consume.amount ?? 0;
|
||||||
if ( q && (q - consumeAmount >= 0) ) {
|
if ( q && (q - consumeAmount >= 0) ) {
|
||||||
this._ammo = ammo;
|
this._ammo = ammo;
|
||||||
let ammoBonus = ammo.data.data.attackBonus;
|
title += ` [${ammo.name}]`;
|
||||||
if ( ammoBonus ) {
|
|
||||||
parts.push("@ammo");
|
|
||||||
rollData["ammo"] = ammoBonus;
|
|
||||||
title += ` [${ammo.name}]`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get pending ammunition update
|
||||||
|
const usage = this._getUsageUpdates({consumeResource: true});
|
||||||
|
if ( usage === false ) return null;
|
||||||
|
ammoUpdate = usage.resourceUpdates || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose roll options
|
// Compose roll options
|
||||||
|
@ -730,9 +887,8 @@ export default class Item5e extends Item {
|
||||||
const roll = await d20Roll(rollConfig);
|
const roll = await d20Roll(rollConfig);
|
||||||
if ( roll === false ) return null;
|
if ( roll === false ) return null;
|
||||||
|
|
||||||
// Handle resource consumption if the attack roll was made
|
// Commit ammunition consumption on attack rolls resource consumption if the attack roll was made
|
||||||
const allowed = await this._handleResourceConsumption({isCard: false, isAttack: true});
|
if ( ammo && !isObjectEmpty(ammoUpdate) ) await ammo.update(ammoUpdate);
|
||||||
if ( allowed === false ) return null;
|
|
||||||
return roll;
|
return roll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -747,7 +903,7 @@ export default class Item5e extends Item {
|
||||||
* @param {object} [options] Additional options passed to the damageRoll function
|
* @param {object} [options] Additional options passed to the damageRoll function
|
||||||
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
|
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
|
||||||
*/
|
*/
|
||||||
rollDamage({event, powerLevel=null, versatile=false, options={}}={}) {
|
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.");
|
if ( !this.hasDamage ) throw new Error("You may not make a Damage Roll with this Item.");
|
||||||
const itemData = this.data.data;
|
const itemData = this.data.data;
|
||||||
const actorData = this.actor.data.data;
|
const actorData = this.actor.data.data;
|
||||||
|
@ -761,10 +917,12 @@ export default class Item5e extends Item {
|
||||||
// Configure the damage roll
|
// Configure the damage roll
|
||||||
const title = `${this.name} - ${game.i18n.localize("SW5E.DamageRoll")}`;
|
const title = `${this.name} - ${game.i18n.localize("SW5E.DamageRoll")}`;
|
||||||
const rollConfig = {
|
const rollConfig = {
|
||||||
event: event,
|
|
||||||
parts: parts,
|
|
||||||
actor: this.actor,
|
actor: this.actor,
|
||||||
|
critical: critical ?? event?.altKey ?? false,
|
||||||
data: rollData,
|
data: rollData,
|
||||||
|
event: event,
|
||||||
|
fastForward: event ? event.shiftKey || event.altKey || event.ctrlKey || event.metaKey : false,
|
||||||
|
parts: parts,
|
||||||
title: title,
|
title: title,
|
||||||
flavor: this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title,
|
flavor: this.labels.damageTypes.length ? `${title} (${this.labels.damageTypes})` : title,
|
||||||
speaker: ChatMessage.getSpeaker({actor: this.actor}),
|
speaker: ChatMessage.getSpeaker({actor: this.actor}),
|
||||||
|
@ -784,9 +942,9 @@ export default class Item5e extends Item {
|
||||||
|
|
||||||
// Scale damage from up-casting powers
|
// Scale damage from up-casting powers
|
||||||
if ( (this.data.type === "power") ) {
|
if ( (this.data.type === "power") ) {
|
||||||
if ( (itemData.scaling.mode === "atwill") ) {
|
if ( (itemData.scaling.mode === "cantrip") ) {
|
||||||
const level = this.actor.data.type === "character" ? actorData.details.level : actorData.details.powerLevel;
|
const level = this.actor.data.type === "character" ? actorData.details.level : actorData.details.powerLevel;
|
||||||
this._scaleAtWillDamage(parts, itemData.scaling.formula, level, rollData);
|
this._scaleCantripDamage(parts, itemData.scaling.formula, level, rollData);
|
||||||
}
|
}
|
||||||
else if ( powerLevel && (itemData.scaling.mode === "level") && itemData.scaling.formula ) {
|
else if ( powerLevel && (itemData.scaling.mode === "level") && itemData.scaling.formula ) {
|
||||||
const scaling = itemData.scaling.formula;
|
const scaling = itemData.scaling.formula;
|
||||||
|
@ -800,10 +958,13 @@ export default class Item5e extends Item {
|
||||||
parts.push(actorBonus.damage);
|
parts.push(actorBonus.damage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ammunition damage
|
// Handle ammunition damage
|
||||||
if ( this._ammo ) {
|
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");
|
parts.push("@ammo");
|
||||||
rollData["ammo"] = this._ammo.data.data.damage.parts.map(p => p[0]).join("+");
|
rollData["ammo"] = ammoData.data.damage.parts.map(p => p[0]).join("+");
|
||||||
rollConfig.flavor += ` [${this._ammo.name}]`;
|
rollConfig.flavor += ` [${this._ammo.name}]`;
|
||||||
delete this._ammo;
|
delete this._ammo;
|
||||||
}
|
}
|
||||||
|
@ -913,74 +1074,6 @@ export default class Item5e extends Item {
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
|
|
||||||
/**
|
|
||||||
* Use a consumable item, deducting from the quantity or charges of the item.
|
|
||||||
* @param {boolean} configureDialog Whether to show a configuration dialog
|
|
||||||
* @return {boolean} Whether further execution should be prevented
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async _rollConsumable(configureDialog) {
|
|
||||||
if ( this.data.type !== "consumable" ) throw new Error("Wrong Item type");
|
|
||||||
const itemData = this.data.data;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// 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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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});
|
|
||||||
else {
|
|
||||||
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}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform an ability recharge test for an item which uses the d6 recharge mechanic
|
* 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
|
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
|
||||||
|
@ -1033,6 +1126,7 @@ export default class Item5e extends Item {
|
||||||
left: window.innerWidth - 710,
|
left: window.innerWidth - 710,
|
||||||
},
|
},
|
||||||
halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false,
|
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 }}
|
messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }}
|
||||||
}, options);
|
}, options);
|
||||||
rollConfig.event = options.event;
|
rollConfig.event = options.event;
|
||||||
|
@ -1114,9 +1208,14 @@ export default class Item5e extends Item {
|
||||||
case "attack":
|
case "attack":
|
||||||
await item.rollAttack({event}); break;
|
await item.rollAttack({event}); break;
|
||||||
case "damage":
|
case "damage":
|
||||||
await item.rollDamage({event, powerLevel}); break;
|
|
||||||
case "versatile":
|
case "versatile":
|
||||||
await item.rollDamage({event, powerLevel, versatile: true}); break;
|
await item.rollDamage({
|
||||||
|
critical: event.altKey,
|
||||||
|
event: event,
|
||||||
|
powerLevel: powerLevel,
|
||||||
|
versatile: action === "versatile"
|
||||||
|
});
|
||||||
|
break;
|
||||||
case "formula":
|
case "formula":
|
||||||
await item.rollFormula({event, powerLevel}); break;
|
await item.rollFormula({event, powerLevel}); break;
|
||||||
case "save":
|
case "save":
|
||||||
|
@ -1129,7 +1228,7 @@ export default class Item5e extends Item {
|
||||||
case "toolCheck":
|
case "toolCheck":
|
||||||
await item.rollToolCheck({event}); break;
|
await item.rollToolCheck({event}); break;
|
||||||
case "placeTemplate":
|
case "placeTemplate":
|
||||||
const template = AbilityTemplate.fromItem(item);
|
const template = game.sw5e.canvas.AbilityTemplate.fromItem(item);
|
||||||
if ( template ) template.drawPreview();
|
if ( template ) template.drawPreview();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue