From 584767b352afdaf71df59e12c7c4689ce4040e1a Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 6 Jul 2021 19:57:18 -0500 Subject: [PATCH] Formatted js files --- gulpfile.js | 10 +- module/actor/entity.js | 3455 ++++++++--------- module/actor/old_entity.js | 4018 ++++++++++---------- module/actor/sheets/newSheet/base.js | 1831 ++++----- module/actor/sheets/newSheet/character.js | 1269 ++++--- module/actor/sheets/newSheet/npc.js | 261 +- module/actor/sheets/newSheet/starship.js | 276 +- module/actor/sheets/newSheet/vehicle.js | 796 ++-- module/actor/sheets/oldSheets/base.js | 1731 ++++----- module/actor/sheets/oldSheets/character.js | 613 +-- module/actor/sheets/oldSheets/npc.js | 231 +- module/actor/sheets/oldSheets/vehicle.js | 796 ++-- module/apps/ability-use-dialog.js | 399 +- module/apps/actor-flags.js | 240 +- module/apps/actor-type.js | 35 +- module/apps/hit-dice-config.js | 37 +- module/apps/long-rest.js | 112 +- module/apps/movement-config.js | 63 +- module/apps/select-items-prompt.js | 20 +- module/apps/senses-config.js | 72 +- module/apps/short-rest.js | 231 +- module/apps/trait-selector.js | 153 +- module/canvas.js | 60 +- module/characterImporter.js | 590 +-- module/chat.js | 151 +- module/classFeatures.js | 5 +- module/combat.js | 40 +- module/config.js | 2233 ++++++----- module/dice.js | 301 +- module/dice/d20-roll.js | 109 +- module/dice/damage-roll.js | 81 +- module/effects.js | 85 +- module/item/entity.js | 3129 +++++++-------- module/item/sheet.js | 665 ++-- module/macros.js | 65 +- module/migration.js | 957 ++--- module/pixi/ability-template.js | 219 +- module/settings.js | 271 +- module/templates.js | 53 +- module/token.js | 39 +- sw5e.js | 452 ++- 41 files changed, 13450 insertions(+), 12704 deletions(-) diff --git a/gulpfile.js b/gulpfile.js index dabc0757..d18fc7d3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -8,19 +8,19 @@ const less = require("gulp-less"); const SW5E_LESS = ["less/**/*.less"]; function compileLESS() { - return gulp.src("less/original/sw5e.less").pipe(less()).pipe(gulp.dest("./")); + return gulp.src("less/original/sw5e.less").pipe(less()).pipe(gulp.dest("./")); } function compileGlobalLess() { - return gulp.src("less/update/sw5e-global.less").pipe(less()).pipe(gulp.dest("./")); + return gulp.src("less/update/sw5e-global.less").pipe(less()).pipe(gulp.dest("./")); } function compileLightLess() { - return gulp.src("less/update/sw5e-light.less").pipe(less()).pipe(gulp.dest("./")); + return gulp.src("less/update/sw5e-light.less").pipe(less()).pipe(gulp.dest("./")); } function compileDarkLess() { - return gulp.src("less/update/sw5e-dark.less").pipe(less()).pipe(gulp.dest("./")); + return gulp.src("less/update/sw5e-dark.less").pipe(less()).pipe(gulp.dest("./")); } const css = gulp.series(compileLESS, compileGlobalLess, compileLightLess, compileDarkLess); @@ -30,7 +30,7 @@ const css = gulp.series(compileLESS, compileGlobalLess, compileLightLess, compil /* ----------------------------------------- */ function watchUpdates() { - gulp.watch(SW5E_LESS, css); + gulp.watch(SW5E_LESS, css); } /* ----------------------------------------- */ diff --git a/module/actor/entity.js b/module/actor/entity.js index ad37ab56..0fe01203 100644 --- a/module/actor/entity.js +++ b/module/actor/entity.js @@ -1,8 +1,8 @@ -import { d20Roll, damageRoll } from "../dice.js"; +import {d20Roll, damageRoll} from "../dice.js"; import SelectItemsPrompt from "../apps/select-items-prompt.js"; import ShortRestDialog from "../apps/short-rest.js"; import LongRestDialog from "../apps/long-rest.js"; -import {SW5E} from '../config.js'; +import {SW5E} from "../config.js"; import Item5e from "../item/entity.js"; /** @@ -10,1806 +10,1875 @@ import Item5e from "../item/entity.js"; * @extends {Actor} */ export default class Actor5e extends Actor { + /** + * The data source for Actor5e.classes allowing it to be lazily computed. + * @type {Object} + * @private + */ + _classes = undefined; - /** - * The data source for Actor5e.classes allowing it to be lazily computed. - * @type {Object} - * @private - */ - _classes = undefined; + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ - /* -------------------------------------------- */ - /* Properties */ - /* -------------------------------------------- */ - - /** - * A mapping of classes belonging to this Actor. - * @type {Object} - */ - get classes() { - if ( this._classes !== undefined ) return this._classes; - if ( this.data.type !== "character" ) return this._classes = {}; - return this._classes = this.items.filter((item) => item.type === "class").reduce((obj, cls) => { - obj[cls.name.slugify({strict: true})] = cls; - return obj; - }, {}); - } - - /* -------------------------------------------- */ - - /** - * Is this Actor currently polymorphed into some other creature? - * @type {boolean} - */ - get isPolymorphed() { - return this.getFlag("sw5e", "isPolymorphed") || false; - } - - /* -------------------------------------------- */ - /* Methods */ - /* -------------------------------------------- */ - - /** @override */ - prepareData() { - super.prepareData(); - - // iterate over owned items and recompute attributes that depend on prepared actor data - this.items.forEach(item => item.prepareFinalAttributes()); - } - - /* -------------------------------------------- */ - - /** @override */ - prepareBaseData() { - switch ( this.data.type ) { - case "character": - return this._prepareCharacterData(this.data); - case "npc": - return this._prepareNPCData(this.data); - case "starship": - return this._prepareStarshipData(this.data); - case "vehicle": - return this._prepareVehicleData(this.data); + /** + * A mapping of classes belonging to this Actor. + * @type {Object} + */ + get classes() { + if (this._classes !== undefined) return this._classes; + if (this.data.type !== "character") return (this._classes = {}); + return (this._classes = this.items + .filter((item) => item.type === "class") + .reduce((obj, cls) => { + obj[cls.name.slugify({strict: true})] = cls; + return obj; + }, {})); } - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - prepareDerivedData() { - const actorData = this.data; - const data = actorData.data; - const flags = actorData.flags.sw5e || {}; - const bonuses = getProperty(data, "bonuses.abilities") || {}; + /** + * Is this Actor currently polymorphed into some other creature? + * @type {boolean} + */ + get isPolymorphed() { + return this.getFlag("sw5e", "isPolymorphed") || false; + } - // Retrieve data for polymorphed actors - let originalSaves = null; - let originalSkills = null; - if (this.isPolymorphed) { - const transformOptions = this.getFlag('sw5e', 'transformOptions'); - const original = game.actors?.get(this.getFlag('sw5e', 'originalActor')); - if (original) { - if (transformOptions.mergeSaves) { - originalSaves = original.data.data.abilities; + /* -------------------------------------------- */ + /* Methods */ + /* -------------------------------------------- */ + + /** @override */ + prepareData() { + super.prepareData(); + + // iterate over owned items and recompute attributes that depend on prepared actor data + this.items.forEach((item) => item.prepareFinalAttributes()); + } + + /* -------------------------------------------- */ + + /** @override */ + prepareBaseData() { + switch (this.data.type) { + case "character": + return this._prepareCharacterData(this.data); + case "npc": + return this._prepareNPCData(this.data); + case "starship": + return this._prepareStarshipData(this.data); + case "vehicle": + return this._prepareVehicleData(this.data); } - if (transformOptions.mergeSkills) { - originalSkills = original.data.data.skills; + } + + /* -------------------------------------------- */ + + /** @override */ + prepareDerivedData() { + const actorData = this.data; + const data = actorData.data; + const flags = actorData.flags.sw5e || {}; + const bonuses = getProperty(data, "bonuses.abilities") || {}; + + // Retrieve data for polymorphed actors + let originalSaves = null; + let originalSkills = null; + if (this.isPolymorphed) { + const transformOptions = this.getFlag("sw5e", "transformOptions"); + const original = game.actors?.get(this.getFlag("sw5e", "originalActor")); + if (original) { + if (transformOptions.mergeSaves) { + originalSaves = original.data.data.abilities; + } + if (transformOptions.mergeSkills) { + originalSkills = original.data.data.skills; + } + } } - } - } - // Ability modifiers and saves - const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0; - const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0; - const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0; - for (let [id, abl] of Object.entries(data.abilities)) { - abl.mod = Math.floor((abl.value - 10) / 2); - abl.prof = (abl.proficient || 0) * data.attributes.prof; - abl.saveBonus = saveBonus; - abl.checkBonus = checkBonus; - abl.save = abl.mod + abl.prof + abl.saveBonus; - abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus; - - // If we merged saves when transforming, take the highest bonus here. - if (originalSaves && abl.proficient) { - abl.save = Math.max(abl.save, originalSaves[id].save); - } - } - - // Inventory encumbrance - data.attributes.encumbrance = this._computeEncumbrance(actorData); - - // Prepare skills - this._prepareSkills(actorData, bonuses, checkBonus, originalSkills); - - // Reset class store to ensure it is updated with any changes - this._classes = undefined; - - // Determine Initiative Modifier - const init = data.attributes.init; - const athlete = flags.remarkableAthlete; - const joat = flags.jackOfAllTrades; - init.mod = data.abilities.dex.mod; - if ( joat ) init.prof = Math.floor(0.5 * data.attributes.prof); - else if ( athlete ) init.prof = Math.ceil(0.5 * data.attributes.prof); - else init.prof = 0; - init.value = init.value ?? 0; - init.bonus = init.value + (flags.initiativeAlert ? 5 : 0); - init.total = init.mod + init.prof + init.bonus; - - // Cache labels - this.labels = {}; - if ( this.type === "npc" ) { - this.labels["creatureType"] = this.constructor.formatCreatureType(data.details.type); - } - - // Prepare power-casting data - this._computeDerivedPowercasting(this.data); - } - - /* -------------------------------------------- */ - - /** - * Return the amount of experience required to gain a certain character level. - * @param level {Number} The desired level - * @return {Number} The XP required - */ - getLevelExp(level) { - const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS; - return levels[Math.min(level, levels.length - 1)]; - } - - /* -------------------------------------------- */ - - /** - * Return the amount of experience granted by killing a creature of a certain CR. - * @param cr {Number} The creature's challenge rating - * @return {Number} The amount of experience granted per kill - */ - getCRExp(cr) { - if (cr < 1.0) return Math.max(200 * cr, 10); - return CONFIG.SW5E.CR_EXP_LEVELS[cr]; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getRollData() { - const data = super.getRollData(); - data.prof = this.data.data.attributes.prof || 0; - data.classes = Object.entries(this.classes).reduce((obj, e) => { - const [slug, cls] = e; - obj[slug] = cls.data.data; - return obj; - }, {}); - return data; - } - - /* -------------------------------------------- */ - - /** - * Given a list of items to add to the Actor, optionally prompt the - * user for which they would like to add. - * @param {Array.} items - The items being added to the Actor. - * @param {boolean} [prompt=true] - Whether or not to prompt the user. - * @returns {Promise} - */ - async addEmbeddedItems(items, prompt=true) { - let itemsToAdd = items; - if ( !items.length ) return []; - - // Obtain the array of item creation data - let toCreate = []; - if (prompt) { - const itemIdsToAdd = await SelectItemsPrompt.create(items, { - hint: game.i18n.localize('SW5E.AddEmbeddedItemPromptHint') - }); - for (let item of items) { - if (itemIdsToAdd.includes(item.id)) toCreate.push(item.toObject()); - } - } else { - toCreate = items.map(item => item.toObject()); - } - - // Create the requested items - if (itemsToAdd.length === 0) return []; - return Item5e.createDocuments(toCreate, {parent: this}); - } - - /* -------------------------------------------- */ - - /** - * Get a list of features to add to the Actor when a class item is updated. - * Optionally prompt the user for which they would like to add. - */ - async getClassFeatures({className, archetypeName, level}={}) { - const existing = new Set(this.items.map(i => i.name)); - const features = await Actor5e.loadClassFeatures({className, archetypeName, level}); - return features.filter(f => !existing.has(f.name)) || []; - } - - /* -------------------------------------------- */ - - /** - * Return the features which a character is awarded for each class level - * @param {string} className The class name being added - * @param {string} archetypeName The archetype of the class being added, if any - * @param {number} level The number of levels in the added class - * @param {number} priorLevel The previous level of the added class - * @return {Promise} Array of Item5e entities - */ - static async loadClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) { - className = className.toLowerCase(); - archetypeName = archetypeName.slugify(); - - // Get the configuration of features which may be added - const clsConfig = CONFIG.SW5E.classFeatures[className]; - if (!clsConfig) return []; - - // Acquire class features - let ids = []; - for ( let [l, f] of Object.entries(clsConfig.features || {}) ) { - l = parseInt(l); - if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f); - } - - // Acquire archetype features - const archConfig = clsConfig.archetypes[archetypeName] || {}; - for ( let [l, f] of Object.entries(archConfig.features || {}) ) { - l = parseInt(l); - if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f); - } - - // Load item data for all identified features - const features = []; - for ( let id of ids ) { - features.push(await fromUuid(id)); - } - - // Class powers should always be prepared - for ( const feature of features ) { - if ( feature.type === "power" ) { - const preparation = feature.data.data.preparation; - preparation.mode = "always"; - preparation.prepared = true; - } - } - return features; - } - - /* -------------------------------------------- */ - /* Data Preparation Helpers */ - /* -------------------------------------------- */ - - /** - * Prepare Character type specific data - */ - _prepareCharacterData(actorData) { - const data = actorData.data; - - // Determine character level and available hit dice based on owned Class items - const [level, hd] = this.items.reduce((arr, item) => { - if ( item.type === "class" ) { - const classLevels = parseInt(item.data.data.levels) || 1; - arr[0] += classLevels; - arr[1] += classLevels - (parseInt(item.data.data.hitDiceUsed) || 0); - } - return arr; - }, [0, 0]); - data.details.level = level; - data.attributes.hd = hd; - - // Character proficiency bonus - data.attributes.prof = Math.floor((level + 7) / 4); - - // Experience required for next level - const xp = data.details.xp; - xp.max = this.getLevelExp(level || 1); - const prior = this.getLevelExp(level - 1 || 0); - const required = xp.max - prior; - const pct = Math.round((xp.value - prior) * 100 / required); - xp.pct = Math.clamped(pct, 0, 100); - - // Add base Powercasting attributes - this._computeBasePowercasting(actorData); - } - - /* -------------------------------------------- */ - - /** - * Prepare NPC type specific data - */ - _prepareNPCData(actorData) { - const data = actorData.data; - - // Kill Experience - data.details.xp.value = this.getCRExp(data.details.cr); - - // Proficiency - data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4); - - this._computeBasePowercasting(actorData); - - // Powercaster Level - if ( data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel) ) { - data.details.powerLevel = Math.max(data.details.cr, 1); - } - } - - /* -------------------------------------------- */ - - /** - * Prepare vehicle type-specific data - * @param actorData - * @private - */ - _prepareVehicleData(actorData) {} - - /* -------------------------------------------- */ - - /** - * Prepare starship type-specific data - * @param actorData - * @private - */ - _prepareStarshipData(actorData) { - const data = actorData.data; - - // Proficiency - data.attributes.prof = Math.floor((Math.max(data.details.tier, 1) + 7) / 4); - - // Link hull to hp and shields to temp hp - data.attributes.hull.value = data.attributes.hp.value; - data.attributes.hull.max = data.attributes.hp.max; - data.attributes.shld.value = data.attributes.hp.temp; - data.attributes.shld.max = data.attributes.hp.tempmax; - } - - /* -------------------------------------------- */ - - /** - * Prepare skill checks. - * @param actorData - * @param bonuses Global bonus data. - * @param checkBonus Ability check specific bonus. - * @param originalSkills A transformed actor's original actor's skills. - * @private - */ - _prepareSkills(actorData, bonuses, checkBonus, originalSkills) { - if (actorData.type === 'vehicle') return; - - const data = actorData.data; - const flags = actorData.flags.sw5e || {}; - - // Skill modifiers - const feats = SW5E.characterFlags; - const athlete = flags.remarkableAthlete; - const joat = flags.jackOfAllTrades; - const observant = flags.observantFeat; - const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0; - for (let [id, skl] of Object.entries(data.skills)) { - skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0; - let round = Math.floor; - - // Remarkable - if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) { - skl.value = 0.5; - round = Math.ceil; - } - - // Jack of All Trades - if ( joat && (skl.value < 0.5) ) { - skl.value = 0.5; - } - - // Polymorph Skill Proficiencies - if ( originalSkills ) { - skl.value = Math.max(skl.value, originalSkills[id].value); - } - - // Compute modifier - skl.bonus = checkBonus + skillBonus; - skl.mod = data.abilities[skl.ability].mod; - skl.prof = round(skl.value * data.attributes.prof); - skl.total = skl.mod + skl.prof + skl.bonus; - - // Compute passive bonus - const passive = observant && (feats.observantFeat.skills.includes(id)) ? 5 : 0; - skl.passive = 10 + skl.total + passive; - } - } - - /* -------------------------------------------- */ - - /** - * Prepare data related to the power-casting capabilities of the Actor - * @private - */ - _computeBasePowercasting (actorData) { - if (actorData.type === 'vehicle' || actorData.type === 'starship') return; - const ad = actorData.data; - const powers = ad.powers; - const isNPC = actorData.type === 'npc'; - - // Translate the list of classes into force and tech power-casting progression - const forceProgression = { - classes: 0, - levels: 0, - multi: 0, - maxClass: "none", - maxClassPriority: 0, - maxClassLevels: 0, - maxClassPowerLevel: 0, - powersKnown: 0, - points: 0 - }; - const techProgression = { - classes: 0, - levels: 0, - multi: 0, - maxClass: "none", - maxClassPriority: 0, - maxClassLevels: 0, - maxClassPowerLevel: 0, - powersKnown: 0, - points: 0 - }; - - // Tabulate the total power-casting progression - const classes = this.data.items.filter(i => i.type === "class"); - let priority = 0; - for ( let cls of classes ) { - const d = cls.data.data; - if ( d.powercasting.progression === "none" ) continue; - const levels = d.levels; - const prog = d.powercasting.progression; - // TODO: Consider a more dynamic system - switch (prog) { - case 'consular': - priority = 3; - forceProgression.levels += levels; - forceProgression.multi += (SW5E.powerMaxLevel['consular'][19]/9)*levels; - forceProgression.classes++; - // see if class controls high level forcecasting - if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){ - forceProgression.maxClass = 'consular'; - forceProgression.maxClassLevels = levels; - forceProgression.maxClassPriority = priority; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['consular'][Math.clamped((levels - 1), 0, 20)]; - } - // calculate points and powers known - forceProgression.powersKnown += SW5E.powersKnown['consular'][Math.clamped((levels - 1), 0, 20)]; - forceProgression.points += SW5E.powerPoints['consular'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'engineer': - priority = 2 - techProgression.levels += levels; - techProgression.multi += (SW5E.powerMaxLevel['engineer'][19]/9)*levels; - techProgression.classes++; - // see if class controls high level techcasting - if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){ - techProgression.maxClass = 'engineer'; - techProgression.maxClassLevels = levels; - techProgression.maxClassPriority = priority; - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['engineer'][Math.clamped((levels - 1), 0, 20)]; - } - techProgression.powersKnown += SW5E.powersKnown['engineer'][Math.clamped((levels - 1), 0, 20)]; - techProgression.points += SW5E.powerPoints['engineer'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'guardian': - priority = 1; - forceProgression.levels += levels; - forceProgression.multi += (SW5E.powerMaxLevel['guardian'][19]/9)*levels; - forceProgression.classes++; - // see if class controls high level forcecasting - if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){ - forceProgression.maxClass = 'guardian'; - forceProgression.maxClassLevels = levels; - forceProgression.maxClassPriority = priority; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['guardian'][Math.clamped((levels - 1), 0, 20)]; - } - forceProgression.powersKnown += SW5E.powersKnown['guardian'][Math.clamped((levels - 1), 0, 20)]; - forceProgression.points += SW5E.powerPoints['guardian'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'scout': - priority = 1; - techProgression.levels += levels; - techProgression.multi += (SW5E.powerMaxLevel['scout'][19]/9)*levels; - techProgression.classes++; - // see if class controls high level techcasting - if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){ - techProgression.maxClass = 'scout'; - techProgression.maxClassLevels = levels; - techProgression.maxClassPriority = priority; - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['scout'][Math.clamped((levels - 1), 0, 20)]; - } - techProgression.powersKnown += SW5E.powersKnown['scout'][Math.clamped((levels - 1), 0, 20)]; - techProgression.points += SW5E.powerPoints['scout'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'sentinel': - priority = 2; - forceProgression.levels += levels; - forceProgression.multi += (SW5E.powerMaxLevel['sentinel'][19]/9)*levels; - forceProgression.classes++; - // see if class controls high level forcecasting - if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){ - forceProgression.maxClass = 'sentinel'; - forceProgression.maxClassLevels = levels; - forceProgression.maxClassPriority = priority; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['sentinel'][Math.clamped((levels - 1), 0, 20)]; - } - forceProgression.powersKnown += SW5E.powersKnown['sentinel'][Math.clamped((levels - 1), 0, 20)]; - forceProgression.points += SW5E.powerPoints['sentinel'][Math.clamped((levels - 1), 0, 20)]; - break; } - } - - if (isNPC) { - // EXCEPTION: NPC with an explicit power-caster level - if (ad.details.powerForceLevel) { - forceProgression.levels = ad.details.powerForceLevel; - ad.attributes.force.level = forceProgression.levels; - forceProgression.maxClass = ad.attributes.powercasting; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped((forceProgression.levels - 1), 0, 20)]; - } - if (ad.details.powerTechLevel) { - techProgression.levels = ad.details.powerTechLevel; - ad.attributes.tech.level = techProgression.levels; - techProgression.maxClass = ad.attributes.powercasting; - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped((techProgression.levels - 1), 0, 20)]; - } - } else { - // EXCEPTION: multi-classed progression uses multi rounded down rather than levels - if (forceProgression.classes > 1) { - forceProgression.levels = Math.floor(forceProgression.multi); - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][forceProgression.levels - 1]; - } - if (techProgression.classes > 1) { - techProgression.levels = Math.floor(techProgression.multi); - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][techProgression.levels - 1]; - } - } - - - // Look up the number of slots per level from the powerLimit table - let forcePowerLimit = Array.from(SW5E.powerLimit['none']); - for (let i = 0; i < (forceProgression.maxClassPowerLevel); i++) { - forcePowerLimit[i] = SW5E.powerLimit[forceProgression.maxClass][i]; - } - - for ( let [n, lvl] of Object.entries(powers) ) { - let i = parseInt(n.slice(-1)); - if ( Number.isNaN(i) ) continue; - if ( Number.isNumeric(lvl.foverride) ) lvl.fmax = Math.max(parseInt(lvl.foverride), 0); - else lvl.fmax = forcePowerLimit[i-1] || 0; - if (isNPC){ - lvl.fvalue = lvl.fmax; - }else{ - lvl.fvalue = Math.min(parseInt(lvl.fvalue || lvl.value || lvl.fmax),lvl.fmax); - } - } - - let techPowerLimit = Array.from(SW5E.powerLimit['none']); - for (let i = 0; i < (techProgression.maxClassPowerLevel); i++) { - techPowerLimit[i] = SW5E.powerLimit[techProgression.maxClass][i]; - } - - for ( let [n, lvl] of Object.entries(powers) ) { - let i = parseInt(n.slice(-1)); - if ( Number.isNaN(i) ) continue; - if ( Number.isNumeric(lvl.toverride) ) lvl.tmax = Math.max(parseInt(lvl.toverride), 0); - else lvl.tmax = techPowerLimit[i-1] || 0; - if (isNPC){ - lvl.tvalue = lvl.tmax; - }else{ - lvl.tvalue = Math.min(parseInt(lvl.tvalue || lvl.value || lvl.tmax),lvl.tmax); - } - } - - // Set Force and tech power for PC Actors - if (!isNPC) { - if (forceProgression.levels) { - ad.attributes.force.known.max = forceProgression.powersKnown; - ad.attributes.force.points.max = forceProgression.points; - ad.attributes.force.level = forceProgression.levels; - } - if (techProgression.levels){ - ad.attributes.tech.known.max = techProgression.powersKnown; - ad.attributes.tech.points.max = techProgression.points; - ad.attributes.tech.level = techProgression.levels; - } - } - - - // Tally Powers Known and check for migration first to avoid errors - let hasKnownPowers = actorData?.data?.attributes?.force?.known?.value !== undefined; - if ( hasKnownPowers ) { - const knownPowers = this.data.items.filter(i => i.type === "power"); - let knownForcePowers = 0; - let knownTechPowers = 0; - for ( let knownPower of knownPowers ) { - const d = knownPower.data; - switch (d.data.school){ - case "lgt": - case "uni": - case "drk":{ - knownForcePowers++; - break; - } - case "tec":{ - knownTechPowers++; - break; - } + // Ability modifiers and saves + const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0; + const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0; + const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0; + for (let [id, abl] of Object.entries(data.abilities)) { + abl.mod = Math.floor((abl.value - 10) / 2); + abl.prof = (abl.proficient || 0) * data.attributes.prof; + abl.saveBonus = saveBonus; + abl.checkBonus = checkBonus; + abl.save = abl.mod + abl.prof + abl.saveBonus; + abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus; + + // If we merged saves when transforming, take the highest bonus here. + if (originalSaves && abl.proficient) { + abl.save = Math.max(abl.save, originalSaves[id].save); + } } - } - ad.attributes.force.known.value = knownForcePowers; - ad.attributes.tech.known.value = knownTechPowers; - } - } - /* -------------------------------------------- */ + // Inventory encumbrance + data.attributes.encumbrance = this._computeEncumbrance(actorData); - /** - * Prepare data related to the power-casting capabilities of the Actor - * @private - */ - _computeDerivedPowercasting (actorData) { + // Prepare skills + this._prepareSkills(actorData, bonuses, checkBonus, originalSkills); - if (!(actorData.type === 'character' || actorData.type === 'npc')) return; + // Reset class store to ensure it is updated with any changes + this._classes = undefined; - const ad = actorData.data; + // Determine Initiative Modifier + const init = data.attributes.init; + const athlete = flags.remarkableAthlete; + const joat = flags.jackOfAllTrades; + init.mod = data.abilities.dex.mod; + if (joat) init.prof = Math.floor(0.5 * data.attributes.prof); + else if (athlete) init.prof = Math.ceil(0.5 * data.attributes.prof); + else init.prof = 0; + init.value = init.value ?? 0; + init.bonus = init.value + (flags.initiativeAlert ? 5 : 0); + init.total = init.mod + init.prof + init.bonus; - // Powercasting DC for Actors and NPCs - // TODO: Consider an option for using the variant rule of all powers use the same value - ad.attributes.powerForceLightDC = 8 + ad.abilities.wis.mod + ad.attributes.prof ?? 10; - ad.attributes.powerForceDarkDC = 8 + ad.abilities.cha.mod + ad.attributes.prof ?? 10; - ad.attributes.powerForceUnivDC = Math.max(ad.attributes.powerForceLightDC,ad.attributes.powerForceDarkDC) ?? 10; - ad.attributes.powerTechDC = 8 + ad.abilities.int.mod + ad.attributes.prof ?? 10; - - if (actorData.type !== 'character') return; - - // Set Force and tech bonus points for PC Actors - if (!!ad.attributes.force.level){ - ad.attributes.force.points.max += Math.max(ad.abilities.wis.mod,ad.abilities.cha.mod); - } - if (!!ad.attributes.tech.level){ - ad.attributes.tech.points.max += ad.abilities.int.mod; - } - - } - - /* -------------------------------------------- */ - - /** - * Compute the level and percentage of encumbrance for an Actor. - * - * Optionally include the weight of carried currency across all denominations by applying the standard rule - * from the PHB pg. 143 - * @param {Object} actorData The data object for the Actor being rendered - * @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level - * @private - */ - _computeEncumbrance(actorData) { - // TODO: Maybe add an option for variant encumbrance - // Get the total weight from items - const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"]; - let weight = actorData.items.reduce((weight, i) => { - if ( !physicalItems.includes(i.type) ) return weight; - const q = i.data.data.quantity || 0; - const w = i.data.data.weight || 0; - return weight + (q * w); - }, 0); - - // [Optional] add Currency Weight (for non-transformed actors) - if ( game.settings.get("sw5e", "currencyWeight") && actorData.data.currency ) { - const currency = actorData.data.currency; - const numCoins = Object.values(currency).reduce((val, denom) => val += Math.max(denom, 0), 0); - weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; - } - - // Determine the encumbrance size class - let mod = { - tiny: 0.5, - sm: 1, - med: 1, - lg: 2, - huge: 4, - grg: 8 - }[actorData.data.traits.size] || 1; - if ( this.getFlag("sw5e", "powerfulBuild") ) mod = Math.min(mod * 2, 8); - - // Compute Encumbrance percentage - weight = weight.toNearest(0.1); - const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod; - const pct = Math.clamped((weight * 100) / max, 0, 100); - return { value: weight.toNearest(0.1), max, pct, encumbered: pct > (2/3) }; - } - - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preCreate(data, options, user) { - await super._preCreate(data, options, user); - - // Token size category - const s = CONFIG.SW5E.tokenSizes[this.data.data.traits.size || "med"]; - this.data.token.update({width: s, height: s}); - - // Player character configuration - if ( this.type === "character" ) { - this.data.token.update({vision: true, actorLink: true, disposition: 1}); - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - async _preUpdate(changed, options, user) { - await super._preUpdate(changed, options, user); - - // Apply changes in Actor size to Token width/height - const newSize = foundry.utils.getProperty(changed, "data.traits.size"); - if ( newSize && (newSize !== foundry.utils.getProperty(this.data, "data.traits.size")) ) { - let size = CONFIG.SW5E.tokenSizes[newSize]; - if ( !foundry.utils.hasProperty(changed, "token.width") ) { - changed.token = changed.token || {}; - changed.token.height = size; - changed.token.width = size; - } - } - - // Reset death save counters - const isDead = this.data.data.attributes.hp.value <= 0; - if ( isDead && (foundry.utils.getProperty(changed, "data.attributes.hp.value") > 0) ) { - foundry.utils.setProperty(changed, "data.attributes.death.success", 0); - foundry.utils.setProperty(changed, "data.attributes.death.failure", 0); - } - } - - /* -------------------------------------------- */ - - /** - * Assign a class item as the original class for the Actor based on which class has the most levels - * @protected - */ - _assignPrimaryClass() { - const classes = this.itemTypes.class.sort((a, b) => b.data.data.levels - a.data.data.levels); - const newPC = classes[0]?.id || ""; - return this.update({"data.details.originalClass": newPC}); - } - - /* -------------------------------------------- */ - /* Gameplay Mechanics */ - /* -------------------------------------------- */ - - /** @override */ - async modifyTokenAttribute(attribute, value, isDelta, isBar) { - if ( attribute === "attributes.hp" ) { - const hp = getProperty(this.data.data, attribute); - const delta = isDelta ? (-1 * value) : (hp.value + hp.temp) - value; - return this.applyDamage(delta); - } - return super.modifyTokenAttribute(attribute, value, isDelta, isBar); - } - - /* -------------------------------------------- */ - - /** - * Apply a certain amount of damage or healing to the health pool for Actor - * @param {number} amount An amount of damage (positive) or healing (negative) to sustain - * @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing - * @return {Promise} A Promise which resolves once the damage has been applied - */ - async applyDamage(amount=0, multiplier=1) { - amount = Math.floor(parseInt(amount) * multiplier); - const hp = this.data.data.attributes.hp; - - // Deduct damage from temp HP first - const tmp = parseInt(hp.temp) || 0; - const dt = amount > 0 ? Math.min(tmp, amount) : 0; - - // Remaining goes to health - const tmpMax = parseInt(hp.tempmax) || 0; - const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax); - - // Update the Actor - const updates = { - "data.attributes.hp.temp": tmp - dt, - "data.attributes.hp.value": dh - }; - - // Delegate damage application to a hook - // TODO replace this in the future with a better modifyTokenAttribute function in the core - const allowed = Hooks.call("modifyTokenAttribute", { - attribute: "attributes.hp", - value: amount, - isDelta: false, - isBar: true - }, updates); - return allowed !== false ? this.update(updates) : this; - } - - /* -------------------------------------------- */ - - /** - * Roll a Skill Check - * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus - * @param {string} skillId The skill id (e.g. "ins") - * @param {Object} options Options which configure how the skill check is rolled - * @return {Promise} A Promise which resolves to the created Roll instance - */ - rollSkill(skillId, options={}) { - const skl = this.data.data.skills[skillId]; - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; - - // Compose roll parts and data - const parts = ["@mod"]; - const data = {mod: skl.mod + skl.prof}; - - // Ability test bonus - if ( bonuses.check ) { - data["checkBonus"] = bonuses.check; - parts.push("@checkBonus"); - } - - // Skill check bonus - if ( bonuses.skill ) { - data["skillBonus"] = bonuses.skill; - parts.push("@skillBonus"); - } - - // Add provided extra roll parts now because they will get clobbered by mergeObject below - if (options.parts?.length > 0) { - parts.push(...options.parts); - } - - // Reliable Talent applies to any skill check we have full or better proficiency in - const reliableTalent = (skl.value >= 1 && this.getFlag("sw5e", "reliableTalent")); - - // Roll and return - const rollData = foundry.utils.mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.format("SW5E.SkillPromptTitle", {skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId]}), - halflingLucky: this.getFlag("sw5e", "halflingLucky"), - reliableTalent: reliableTalent, - messageData: { - speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), - "flags.sw5e.roll": {type: "skill", skillId } - } - }); - return d20Roll(rollData); - } - - /* -------------------------------------------- */ - - /** - * Roll a generic ability test or saving throw. - * Prompt the user for input on which variety of roll they want to do. - * @param {String}abilityId The ability id (e.g. "str") - * @param {Object} options Options which configure how ability tests or saving throws are rolled - */ - rollAbility(abilityId, options={}) { - const label = CONFIG.SW5E.abilities[abilityId]; - new Dialog({ - title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), - content: `

${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}

`, - buttons: { - test: { - label: game.i18n.localize("SW5E.ActionAbil"), - callback: () => this.rollAbilityTest(abilityId, options) - }, - save: { - label: game.i18n.localize("SW5E.ActionSave"), - callback: () => this.rollAbilitySave(abilityId, options) + // Cache labels + this.labels = {}; + if (this.type === "npc") { + this.labels["creatureType"] = this.constructor.formatCreatureType(data.details.type); } - } - }).render(true); - } - /* -------------------------------------------- */ - - /** - * Roll an Ability Test - * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus - * @param {String} abilityId The ability ID (e.g. "str") - * @param {Object} options Options which configure how ability tests are rolled - * @return {Promise} A Promise which resolves to the created Roll instance - */ - rollAbilityTest(abilityId, options={}) { - const label = CONFIG.SW5E.abilities[abilityId]; - const abl = this.data.data.abilities[abilityId]; - - // Construct parts - const parts = ["@mod"]; - const data = {mod: abl.mod}; - - // Add feat-related proficiency bonuses - const feats = this.data.flags.sw5e || {}; - if ( feats.remarkableAthlete && SW5E.characterFlags.remarkableAthlete.abilities.includes(abilityId) ) { - parts.push("@proficiency"); - data.proficiency = Math.ceil(0.5 * this.data.data.attributes.prof); - } - else if ( feats.jackOfAllTrades ) { - parts.push("@proficiency"); - data.proficiency = Math.floor(0.5 * this.data.data.attributes.prof); + // Prepare power-casting data + this._computeDerivedPowercasting(this.data); } - // Add global actor bonus - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; - if ( bonuses.check ) { - parts.push("@checkBonus"); - data.checkBonus = bonuses.check; + /* -------------------------------------------- */ + + /** + * Return the amount of experience required to gain a certain character level. + * @param level {Number} The desired level + * @return {Number} The XP required + */ + getLevelExp(level) { + const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS; + return levels[Math.min(level, levels.length - 1)]; } - // Add provided extra roll parts now because they will get clobbered by mergeObject below - if (options.parts?.length > 0) { - parts.push(...options.parts); + /* -------------------------------------------- */ + + /** + * Return the amount of experience granted by killing a creature of a certain CR. + * @param cr {Number} The creature's challenge rating + * @return {Number} The amount of experience granted per kill + */ + getCRExp(cr) { + if (cr < 1.0) return Math.max(200 * cr, 10); + return CONFIG.SW5E.CR_EXP_LEVELS[cr]; } - // Roll and return - const rollData = foundry.utils.mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), - halflingLucky: feats.halflingLucky, - messageData: { - speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), - "flags.sw5e.roll": {type: "ability", abilityId } - } - }); - return d20Roll(rollData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - - /** - * Roll an Ability Saving Throw - * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus - * @param {String} abilityId The ability ID (e.g. "str") - * @param {Object} options Options which configure how ability tests are rolled - * @return {Promise} A Promise which resolves to the created Roll instance - */ - rollAbilitySave(abilityId, options={}) { - const label = CONFIG.SW5E.abilities[abilityId]; - const abl = this.data.data.abilities[abilityId]; - - // Construct parts - const parts = ["@mod"]; - const data = {mod: abl.mod}; - - // Include proficiency bonus - if ( abl.prof > 0 ) { - parts.push("@prof"); - data.prof = abl.prof; + /** @inheritdoc */ + getRollData() { + const data = super.getRollData(); + data.prof = this.data.data.attributes.prof || 0; + data.classes = Object.entries(this.classes).reduce((obj, e) => { + const [slug, cls] = e; + obj[slug] = cls.data.data; + return obj; + }, {}); + return data; } - // Include a global actor ability save bonus - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; - if ( bonuses.save ) { - parts.push("@saveBonus"); - data.saveBonus = bonuses.save; + /* -------------------------------------------- */ + + /** + * Given a list of items to add to the Actor, optionally prompt the + * user for which they would like to add. + * @param {Array.} items - The items being added to the Actor. + * @param {boolean} [prompt=true] - Whether or not to prompt the user. + * @returns {Promise} + */ + async addEmbeddedItems(items, prompt = true) { + let itemsToAdd = items; + if (!items.length) return []; + + // Obtain the array of item creation data + let toCreate = []; + if (prompt) { + const itemIdsToAdd = await SelectItemsPrompt.create(items, { + hint: game.i18n.localize("SW5E.AddEmbeddedItemPromptHint") + }); + for (let item of items) { + if (itemIdsToAdd.includes(item.id)) toCreate.push(item.toObject()); + } + } else { + toCreate = items.map((item) => item.toObject()); + } + + // Create the requested items + if (itemsToAdd.length === 0) return []; + return Item5e.createDocuments(toCreate, {parent: this}); } - // Add provided extra roll parts now because they will get clobbered by mergeObject below - if (options.parts?.length > 0) { - parts.push(...options.parts); + /* -------------------------------------------- */ + + /** + * Get a list of features to add to the Actor when a class item is updated. + * Optionally prompt the user for which they would like to add. + */ + async getClassFeatures({className, archetypeName, level} = {}) { + const existing = new Set(this.items.map((i) => i.name)); + const features = await Actor5e.loadClassFeatures({className, archetypeName, level}); + return features.filter((f) => !existing.has(f.name)) || []; } - // Roll and return - const rollData = foundry.utils.mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}), - halflingLucky: this.getFlag("sw5e", "halflingLucky"), - messageData: { - speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), - "flags.sw5e.roll": {type: "save", abilityId } - } - }); - return d20Roll(rollData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Return the features which a character is awarded for each class level + * @param {string} className The class name being added + * @param {string} archetypeName The archetype of the class being added, if any + * @param {number} level The number of levels in the added class + * @param {number} priorLevel The previous level of the added class + * @return {Promise} Array of Item5e entities + */ + static async loadClassFeatures({className = "", archetypeName = "", level = 1, priorLevel = 0} = {}) { + className = className.toLowerCase(); + archetypeName = archetypeName.slugify(); - /** - * Perform a death saving throw, rolling a d20 plus any global save bonuses - * @param {Object} options Additional options which modify the roll - * @return {Promise} A Promise which resolves to the Roll instance - */ - async rollDeathSave(options={}) { + // Get the configuration of features which may be added + const clsConfig = CONFIG.SW5E.classFeatures[className]; + if (!clsConfig) return []; - // Display a warning if we are not at zero HP or if we already have reached 3 - const death = this.data.data.attributes.death; - if ( (this.data.data.attributes.hp.value > 0) || (death.failure >= 3) || (death.success >= 3)) { - ui.notifications.warn(game.i18n.localize("SW5E.DeathSaveUnnecessary")); - return null; + // Acquire class features + let ids = []; + for (let [l, f] of Object.entries(clsConfig.features || {})) { + l = parseInt(l); + if (l <= level && l > priorLevel) ids = ids.concat(f); + } + + // Acquire archetype features + const archConfig = clsConfig.archetypes[archetypeName] || {}; + for (let [l, f] of Object.entries(archConfig.features || {})) { + l = parseInt(l); + if (l <= level && l > priorLevel) ids = ids.concat(f); + } + + // Load item data for all identified features + const features = []; + for (let id of ids) { + features.push(await fromUuid(id)); + } + + // Class powers should always be prepared + for (const feature of features) { + if (feature.type === "power") { + const preparation = feature.data.data.preparation; + preparation.mode = "always"; + preparation.prepared = true; + } + } + return features; } - // Evaluate a global saving throw bonus - const parts = []; - const data = {}; + /* -------------------------------------------- */ + /* Data Preparation Helpers */ + /* -------------------------------------------- */ - // Include a global actor ability save bonus - const bonuses = foundry.utils.getProperty(this.data.data, "bonuses.abilities") || {}; - if ( bonuses.save ) { - parts.push("@saveBonus"); - data.saveBonus = bonuses.save; + /** + * Prepare Character type specific data + */ + _prepareCharacterData(actorData) { + const data = actorData.data; + + // Determine character level and available hit dice based on owned Class items + const [level, hd] = this.items.reduce( + (arr, item) => { + if (item.type === "class") { + const classLevels = parseInt(item.data.data.levels) || 1; + arr[0] += classLevels; + arr[1] += classLevels - (parseInt(item.data.data.hitDiceUsed) || 0); + } + return arr; + }, + [0, 0] + ); + data.details.level = level; + data.attributes.hd = hd; + + // Character proficiency bonus + data.attributes.prof = Math.floor((level + 7) / 4); + + // Experience required for next level + const xp = data.details.xp; + xp.max = this.getLevelExp(level || 1); + const prior = this.getLevelExp(level - 1 || 0); + const required = xp.max - prior; + const pct = Math.round(((xp.value - prior) * 100) / required); + xp.pct = Math.clamped(pct, 0, 100); + + // Add base Powercasting attributes + this._computeBasePowercasting(actorData); } - // Evaluate the roll - const rollData = foundry.utils.mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.localize("SW5E.DeathSavingThrow"), - halflingLucky: this.getFlag("sw5e", "halflingLucky"), - targetValue: 10, - messageData: { - speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), - "flags.sw5e.roll": {type: "death"} - } - }); - const roll = await d20Roll(rollData); - if ( !roll ) return null; + /* -------------------------------------------- */ - // Take action depending on the result - const success = roll.total >= 10; - const d20 = roll.dice[0].total; + /** + * Prepare NPC type specific data + */ + _prepareNPCData(actorData) { + const data = actorData.data; - let chatString; + // Kill Experience + data.details.xp.value = this.getCRExp(data.details.cr); - // Save success - if ( success ) { - let successes = (death.success || 0) + 1; + // Proficiency + data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4); - // Critical Success = revive with 1hp - if ( d20 === 20 ) { - await this.update({ - "data.attributes.death.success": 0, - "data.attributes.death.failure": 0, - "data.attributes.hp.value": 1 + this._computeBasePowercasting(actorData); + + // Powercaster Level + if (data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel)) { + data.details.powerLevel = Math.max(data.details.cr, 1); + } + } + + /* -------------------------------------------- */ + + /** + * Prepare vehicle type-specific data + * @param actorData + * @private + */ + _prepareVehicleData(actorData) {} + + /* -------------------------------------------- */ + + /** + * Prepare starship type-specific data + * @param actorData + * @private + */ + _prepareStarshipData(actorData) { + const data = actorData.data; + + // Proficiency + data.attributes.prof = Math.floor((Math.max(data.details.tier, 1) + 7) / 4); + + // Link hull to hp and shields to temp hp + data.attributes.hull.value = data.attributes.hp.value; + data.attributes.hull.max = data.attributes.hp.max; + data.attributes.shld.value = data.attributes.hp.temp; + data.attributes.shld.max = data.attributes.hp.tempmax; + } + + /* -------------------------------------------- */ + + /** + * Prepare skill checks. + * @param actorData + * @param bonuses Global bonus data. + * @param checkBonus Ability check specific bonus. + * @param originalSkills A transformed actor's original actor's skills. + * @private + */ + _prepareSkills(actorData, bonuses, checkBonus, originalSkills) { + if (actorData.type === "vehicle") return; + + const data = actorData.data; + const flags = actorData.flags.sw5e || {}; + + // Skill modifiers + const feats = SW5E.characterFlags; + const athlete = flags.remarkableAthlete; + const joat = flags.jackOfAllTrades; + const observant = flags.observantFeat; + const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0; + for (let [id, skl] of Object.entries(data.skills)) { + skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0; + let round = Math.floor; + + // Remarkable + if (athlete && skl.value < 0.5 && feats.remarkableAthlete.abilities.includes(skl.ability)) { + skl.value = 0.5; + round = Math.ceil; + } + + // Jack of All Trades + if (joat && skl.value < 0.5) { + skl.value = 0.5; + } + + // Polymorph Skill Proficiencies + if (originalSkills) { + skl.value = Math.max(skl.value, originalSkills[id].value); + } + + // Compute modifier + skl.bonus = checkBonus + skillBonus; + skl.mod = data.abilities[skl.ability].mod; + skl.prof = round(skl.value * data.attributes.prof); + skl.total = skl.mod + skl.prof + skl.bonus; + + // Compute passive bonus + const passive = observant && feats.observantFeat.skills.includes(id) ? 5 : 0; + skl.passive = 10 + skl.total + passive; + } + } + + /* -------------------------------------------- */ + + /** + * Prepare data related to the power-casting capabilities of the Actor + * @private + */ + _computeBasePowercasting(actorData) { + if (actorData.type === "vehicle" || actorData.type === "starship") return; + const ad = actorData.data; + const powers = ad.powers; + const isNPC = actorData.type === "npc"; + + // Translate the list of classes into force and tech power-casting progression + const forceProgression = { + classes: 0, + levels: 0, + multi: 0, + maxClass: "none", + maxClassPriority: 0, + maxClassLevels: 0, + maxClassPowerLevel: 0, + powersKnown: 0, + points: 0 + }; + const techProgression = { + classes: 0, + levels: 0, + multi: 0, + maxClass: "none", + maxClassPriority: 0, + maxClassLevels: 0, + maxClassPowerLevel: 0, + powersKnown: 0, + points: 0 + }; + + // Tabulate the total power-casting progression + const classes = this.data.items.filter((i) => i.type === "class"); + let priority = 0; + for (let cls of classes) { + const d = cls.data.data; + if (d.powercasting.progression === "none") continue; + const levels = d.levels; + const prog = d.powercasting.progression; + // TODO: Consider a more dynamic system + switch (prog) { + case "consular": + priority = 3; + forceProgression.levels += levels; + forceProgression.multi += (SW5E.powerMaxLevel["consular"][19] / 9) * levels; + forceProgression.classes++; + // see if class controls high level forcecasting + if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) { + forceProgression.maxClass = "consular"; + forceProgression.maxClassLevels = levels; + forceProgression.maxClassPriority = priority; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["consular"][Math.clamped(levels - 1, 0, 20)]; + } + // calculate points and powers known + forceProgression.powersKnown += SW5E.powersKnown["consular"][Math.clamped(levels - 1, 0, 20)]; + forceProgression.points += SW5E.powerPoints["consular"][Math.clamped(levels - 1, 0, 20)]; + break; + case "engineer": + priority = 2; + techProgression.levels += levels; + techProgression.multi += (SW5E.powerMaxLevel["engineer"][19] / 9) * levels; + techProgression.classes++; + // see if class controls high level techcasting + if (levels >= techProgression.maxClassLevels && priority > techProgression.maxClassPriority) { + techProgression.maxClass = "engineer"; + techProgression.maxClassLevels = levels; + techProgression.maxClassPriority = priority; + techProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["engineer"][Math.clamped(levels - 1, 0, 20)]; + } + techProgression.powersKnown += SW5E.powersKnown["engineer"][Math.clamped(levels - 1, 0, 20)]; + techProgression.points += SW5E.powerPoints["engineer"][Math.clamped(levels - 1, 0, 20)]; + break; + case "guardian": + priority = 1; + forceProgression.levels += levels; + forceProgression.multi += (SW5E.powerMaxLevel["guardian"][19] / 9) * levels; + forceProgression.classes++; + // see if class controls high level forcecasting + if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) { + forceProgression.maxClass = "guardian"; + forceProgression.maxClassLevels = levels; + forceProgression.maxClassPriority = priority; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["guardian"][Math.clamped(levels - 1, 0, 20)]; + } + forceProgression.powersKnown += SW5E.powersKnown["guardian"][Math.clamped(levels - 1, 0, 20)]; + forceProgression.points += SW5E.powerPoints["guardian"][Math.clamped(levels - 1, 0, 20)]; + break; + case "scout": + priority = 1; + techProgression.levels += levels; + techProgression.multi += (SW5E.powerMaxLevel["scout"][19] / 9) * levels; + techProgression.classes++; + // see if class controls high level techcasting + if (levels >= techProgression.maxClassLevels && priority > techProgression.maxClassPriority) { + techProgression.maxClass = "scout"; + techProgression.maxClassLevels = levels; + techProgression.maxClassPriority = priority; + techProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["scout"][Math.clamped(levels - 1, 0, 20)]; + } + techProgression.powersKnown += SW5E.powersKnown["scout"][Math.clamped(levels - 1, 0, 20)]; + techProgression.points += SW5E.powerPoints["scout"][Math.clamped(levels - 1, 0, 20)]; + break; + case "sentinel": + priority = 2; + forceProgression.levels += levels; + forceProgression.multi += (SW5E.powerMaxLevel["sentinel"][19] / 9) * levels; + forceProgression.classes++; + // see if class controls high level forcecasting + if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) { + forceProgression.maxClass = "sentinel"; + forceProgression.maxClassLevels = levels; + forceProgression.maxClassPriority = priority; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["sentinel"][Math.clamped(levels - 1, 0, 20)]; + } + forceProgression.powersKnown += SW5E.powersKnown["sentinel"][Math.clamped(levels - 1, 0, 20)]; + forceProgression.points += SW5E.powerPoints["sentinel"][Math.clamped(levels - 1, 0, 20)]; + break; + } + } + + if (isNPC) { + // EXCEPTION: NPC with an explicit power-caster level + if (ad.details.powerForceLevel) { + forceProgression.levels = ad.details.powerForceLevel; + ad.attributes.force.level = forceProgression.levels; + forceProgression.maxClass = ad.attributes.powercasting; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped(forceProgression.levels - 1, 0, 20)]; + } + if (ad.details.powerTechLevel) { + techProgression.levels = ad.details.powerTechLevel; + ad.attributes.tech.level = techProgression.levels; + techProgression.maxClass = ad.attributes.powercasting; + techProgression.maxClassPowerLevel = + SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped(techProgression.levels - 1, 0, 20)]; + } + } else { + // EXCEPTION: multi-classed progression uses multi rounded down rather than levels + if (forceProgression.classes > 1) { + forceProgression.levels = Math.floor(forceProgression.multi); + forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel["multi"][forceProgression.levels - 1]; + } + if (techProgression.classes > 1) { + techProgression.levels = Math.floor(techProgression.multi); + techProgression.maxClassPowerLevel = SW5E.powerMaxLevel["multi"][techProgression.levels - 1]; + } + } + + // Look up the number of slots per level from the powerLimit table + let forcePowerLimit = Array.from(SW5E.powerLimit["none"]); + for (let i = 0; i < forceProgression.maxClassPowerLevel; i++) { + forcePowerLimit[i] = SW5E.powerLimit[forceProgression.maxClass][i]; + } + + for (let [n, lvl] of Object.entries(powers)) { + let i = parseInt(n.slice(-1)); + if (Number.isNaN(i)) continue; + if (Number.isNumeric(lvl.foverride)) lvl.fmax = Math.max(parseInt(lvl.foverride), 0); + else lvl.fmax = forcePowerLimit[i - 1] || 0; + if (isNPC) { + lvl.fvalue = lvl.fmax; + } else { + lvl.fvalue = Math.min(parseInt(lvl.fvalue || lvl.value || lvl.fmax), lvl.fmax); + } + } + + let techPowerLimit = Array.from(SW5E.powerLimit["none"]); + for (let i = 0; i < techProgression.maxClassPowerLevel; i++) { + techPowerLimit[i] = SW5E.powerLimit[techProgression.maxClass][i]; + } + + for (let [n, lvl] of Object.entries(powers)) { + let i = parseInt(n.slice(-1)); + if (Number.isNaN(i)) continue; + if (Number.isNumeric(lvl.toverride)) lvl.tmax = Math.max(parseInt(lvl.toverride), 0); + else lvl.tmax = techPowerLimit[i - 1] || 0; + if (isNPC) { + lvl.tvalue = lvl.tmax; + } else { + lvl.tvalue = Math.min(parseInt(lvl.tvalue || lvl.value || lvl.tmax), lvl.tmax); + } + } + + // Set Force and tech power for PC Actors + if (!isNPC) { + if (forceProgression.levels) { + ad.attributes.force.known.max = forceProgression.powersKnown; + ad.attributes.force.points.max = forceProgression.points; + ad.attributes.force.level = forceProgression.levels; + } + if (techProgression.levels) { + ad.attributes.tech.known.max = techProgression.powersKnown; + ad.attributes.tech.points.max = techProgression.points; + ad.attributes.tech.level = techProgression.levels; + } + } + + // Tally Powers Known and check for migration first to avoid errors + let hasKnownPowers = actorData?.data?.attributes?.force?.known?.value !== undefined; + if (hasKnownPowers) { + const knownPowers = this.data.items.filter((i) => i.type === "power"); + let knownForcePowers = 0; + let knownTechPowers = 0; + for (let knownPower of knownPowers) { + const d = knownPower.data; + switch (d.data.school) { + case "lgt": + case "uni": + case "drk": { + knownForcePowers++; + break; + } + case "tec": { + knownTechPowers++; + break; + } + } + } + ad.attributes.force.known.value = knownForcePowers; + ad.attributes.tech.known.value = knownTechPowers; + } + } + + /* -------------------------------------------- */ + + /** + * Prepare data related to the power-casting capabilities of the Actor + * @private + */ + _computeDerivedPowercasting(actorData) { + if (!(actorData.type === "character" || actorData.type === "npc")) return; + + const ad = actorData.data; + + // Powercasting DC for Actors and NPCs + // TODO: Consider an option for using the variant rule of all powers use the same value + ad.attributes.powerForceLightDC = 8 + ad.abilities.wis.mod + ad.attributes.prof ?? 10; + ad.attributes.powerForceDarkDC = 8 + ad.abilities.cha.mod + ad.attributes.prof ?? 10; + ad.attributes.powerForceUnivDC = + Math.max(ad.attributes.powerForceLightDC, ad.attributes.powerForceDarkDC) ?? 10; + ad.attributes.powerTechDC = 8 + ad.abilities.int.mod + ad.attributes.prof ?? 10; + + if (actorData.type !== "character") return; + + // Set Force and tech bonus points for PC Actors + if (!!ad.attributes.force.level) { + ad.attributes.force.points.max += Math.max(ad.abilities.wis.mod, ad.abilities.cha.mod); + } + if (!!ad.attributes.tech.level) { + ad.attributes.tech.points.max += ad.abilities.int.mod; + } + } + + /* -------------------------------------------- */ + + /** + * Compute the level and percentage of encumbrance for an Actor. + * + * Optionally include the weight of carried currency across all denominations by applying the standard rule + * from the PHB pg. 143 + * @param {Object} actorData The data object for the Actor being rendered + * @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level + * @private + */ + _computeEncumbrance(actorData) { + // TODO: Maybe add an option for variant encumbrance + // Get the total weight from items + const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"]; + let weight = actorData.items.reduce((weight, i) => { + if (!physicalItems.includes(i.type)) return weight; + const q = i.data.data.quantity || 0; + const w = i.data.data.weight || 0; + return weight + q * w; + }, 0); + + // [Optional] add Currency Weight (for non-transformed actors) + if (game.settings.get("sw5e", "currencyWeight") && actorData.data.currency) { + const currency = actorData.data.currency; + const numCoins = Object.values(currency).reduce((val, denom) => (val += Math.max(denom, 0)), 0); + weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; + } + + // Determine the encumbrance size class + let mod = + { + tiny: 0.5, + sm: 1, + med: 1, + lg: 2, + huge: 4, + grg: 8 + }[actorData.data.traits.size] || 1; + if (this.getFlag("sw5e", "powerfulBuild")) mod = Math.min(mod * 2, 8); + + // Compute Encumbrance percentage + weight = weight.toNearest(0.1); + const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod; + const pct = Math.clamped((weight * 100) / max, 0, 100); + return {value: weight.toNearest(0.1), max, pct, encumbered: pct > 2 / 3}; + } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + /** @inheritdoc */ + async _preCreate(data, options, user) { + await super._preCreate(data, options, user); + + // Token size category + const s = CONFIG.SW5E.tokenSizes[this.data.data.traits.size || "med"]; + this.data.token.update({width: s, height: s}); + + // Player character configuration + if (this.type === "character") { + this.data.token.update({vision: true, actorLink: true, disposition: 1}); + } + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + async _preUpdate(changed, options, user) { + await super._preUpdate(changed, options, user); + + // Apply changes in Actor size to Token width/height + const newSize = foundry.utils.getProperty(changed, "data.traits.size"); + if (newSize && newSize !== foundry.utils.getProperty(this.data, "data.traits.size")) { + let size = CONFIG.SW5E.tokenSizes[newSize]; + if (!foundry.utils.hasProperty(changed, "token.width")) { + changed.token = changed.token || {}; + changed.token.height = size; + changed.token.width = size; + } + } + + // Reset death save counters + const isDead = this.data.data.attributes.hp.value <= 0; + if (isDead && foundry.utils.getProperty(changed, "data.attributes.hp.value") > 0) { + foundry.utils.setProperty(changed, "data.attributes.death.success", 0); + foundry.utils.setProperty(changed, "data.attributes.death.failure", 0); + } + } + + /* -------------------------------------------- */ + + /** + * Assign a class item as the original class for the Actor based on which class has the most levels + * @protected + */ + _assignPrimaryClass() { + const classes = this.itemTypes.class.sort((a, b) => b.data.data.levels - a.data.data.levels); + const newPC = classes[0]?.id || ""; + return this.update({"data.details.originalClass": newPC}); + } + + /* -------------------------------------------- */ + /* Gameplay Mechanics */ + /* -------------------------------------------- */ + + /** @override */ + async modifyTokenAttribute(attribute, value, isDelta, isBar) { + if (attribute === "attributes.hp") { + const hp = getProperty(this.data.data, attribute); + const delta = isDelta ? -1 * value : hp.value + hp.temp - value; + return this.applyDamage(delta); + } + return super.modifyTokenAttribute(attribute, value, isDelta, isBar); + } + + /* -------------------------------------------- */ + + /** + * Apply a certain amount of damage or healing to the health pool for Actor + * @param {number} amount An amount of damage (positive) or healing (negative) to sustain + * @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing + * @return {Promise} A Promise which resolves once the damage has been applied + */ + async applyDamage(amount = 0, multiplier = 1) { + amount = Math.floor(parseInt(amount) * multiplier); + const hp = this.data.data.attributes.hp; + + // Deduct damage from temp HP first + const tmp = parseInt(hp.temp) || 0; + const dt = amount > 0 ? Math.min(tmp, amount) : 0; + + // Remaining goes to health + const tmpMax = parseInt(hp.tempmax) || 0; + const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax); + + // Update the Actor + const updates = { + "data.attributes.hp.temp": tmp - dt, + "data.attributes.hp.value": dh + }; + + // Delegate damage application to a hook + // TODO replace this in the future with a better modifyTokenAttribute function in the core + const allowed = Hooks.call( + "modifyTokenAttribute", + { + attribute: "attributes.hp", + value: amount, + isDelta: false, + isBar: true + }, + updates + ); + return allowed !== false ? this.update(updates) : this; + } + + /* -------------------------------------------- */ + + /** + * Roll a Skill Check + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {string} skillId The skill id (e.g. "ins") + * @param {Object} options Options which configure how the skill check is rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollSkill(skillId, options = {}) { + const skl = this.data.data.skills[skillId]; + const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + + // Compose roll parts and data + const parts = ["@mod"]; + const data = {mod: skl.mod + skl.prof}; + + // Ability test bonus + if (bonuses.check) { + data["checkBonus"] = bonuses.check; + parts.push("@checkBonus"); + } + + // Skill check bonus + if (bonuses.skill) { + data["skillBonus"] = bonuses.skill; + parts.push("@skillBonus"); + } + + // Add provided extra roll parts now because they will get clobbered by mergeObject below + if (options.parts?.length > 0) { + parts.push(...options.parts); + } + + // Reliable Talent applies to any skill check we have full or better proficiency in + const reliableTalent = skl.value >= 1 && this.getFlag("sw5e", "reliableTalent"); + + // Roll and return + const rollData = foundry.utils.mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.format("SW5E.SkillPromptTitle", { + skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId] + }), + halflingLucky: this.getFlag("sw5e", "halflingLucky"), + reliableTalent: reliableTalent, + messageData: { + "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "skill", skillId} + } }); - chatString = "SW5E.DeathSaveCriticalSuccess"; - } + return d20Roll(rollData); + } - // 3 Successes = survive and reset checks - else if ( successes === 3 ) { - await this.update({ - "data.attributes.death.success": 0, - "data.attributes.death.failure": 0 + /* -------------------------------------------- */ + + /** + * Roll a generic ability test or saving throw. + * Prompt the user for input on which variety of roll they want to do. + * @param {String}abilityId The ability id (e.g. "str") + * @param {Object} options Options which configure how ability tests or saving throws are rolled + */ + rollAbility(abilityId, options = {}) { + const label = CONFIG.SW5E.abilities[abilityId]; + new Dialog({ + title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), + content: `

${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}

`, + buttons: { + test: { + label: game.i18n.localize("SW5E.ActionAbil"), + callback: () => this.rollAbilityTest(abilityId, options) + }, + save: { + label: game.i18n.localize("SW5E.ActionSave"), + callback: () => this.rollAbilitySave(abilityId, options) + } + } + }).render(true); + } + + /* -------------------------------------------- */ + + /** + * Roll an Ability Test + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {String} abilityId The ability ID (e.g. "str") + * @param {Object} options Options which configure how ability tests are rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollAbilityTest(abilityId, options = {}) { + const label = CONFIG.SW5E.abilities[abilityId]; + const abl = this.data.data.abilities[abilityId]; + + // Construct parts + const parts = ["@mod"]; + const data = {mod: abl.mod}; + + // Add feat-related proficiency bonuses + const feats = this.data.flags.sw5e || {}; + if (feats.remarkableAthlete && SW5E.characterFlags.remarkableAthlete.abilities.includes(abilityId)) { + parts.push("@proficiency"); + data.proficiency = Math.ceil(0.5 * this.data.data.attributes.prof); + } else if (feats.jackOfAllTrades) { + parts.push("@proficiency"); + data.proficiency = Math.floor(0.5 * this.data.data.attributes.prof); + } + + // Add global actor bonus + const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + if (bonuses.check) { + parts.push("@checkBonus"); + data.checkBonus = bonuses.check; + } + + // Add provided extra roll parts now because they will get clobbered by mergeObject below + if (options.parts?.length > 0) { + parts.push(...options.parts); + } + + // Roll and return + const rollData = foundry.utils.mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), + halflingLucky: feats.halflingLucky, + messageData: { + "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "ability", abilityId} + } }); - chatString = "SW5E.DeathSaveSuccess"; - } - - // Increment successes - else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)}); + return d20Roll(rollData); } - // Save failure - else { - let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1); - await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)}); - if ( failures >= 3 ) { // 3 Failures = death - chatString = "SW5E.DeathSaveFailure"; - } - } + /* -------------------------------------------- */ - // Display success/failure chat message - if ( chatString ) { - let chatData = { content: game.i18n.format(chatString, {name: this.name}), speaker }; - ChatMessage.applyRollMode(chatData, roll.options.rollMode); - await ChatMessage.create(chatData); - } + /** + * Roll an Ability Saving Throw + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {String} abilityId The ability ID (e.g. "str") + * @param {Object} options Options which configure how ability tests are rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollAbilitySave(abilityId, options = {}) { + const label = CONFIG.SW5E.abilities[abilityId]; + const abl = this.data.data.abilities[abilityId]; - // Return the rolled result - return roll; - } + // Construct parts + const parts = ["@mod"]; + const data = {mod: abl.mod}; - /* -------------------------------------------- */ - - /** - * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier - * @param {string} [denomination] The hit denomination of hit die to roll. Example "d8". - * If no denomination is provided, the first available HD will be used - * @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll? - * @return {Promise} The created Roll instance, or null if no hit die was rolled - */ - async rollHitDie(denomination, {dialog=true}={}) { - - // If no denomination was provided, choose the first available - let cls = null; - if ( !denomination ) { - cls = this.itemTypes.class.find(c => c.data.data.hitDiceUsed < c.data.data.levels); - if ( !cls ) return null; - denomination = cls.data.data.hitDice; - } - - // Otherwise locate a class (if any) which has an available hit die of the requested denomination - else { - cls = this.items.find(i => { - const d = i.data.data; - return (d.hitDice === denomination) && ((d.hitDiceUsed || 0) < (d.levels || 1)); - }); - } - - // If no class is available, display an error notification - if ( !cls ) { - ui.notifications.error(game.i18n.format("SW5E.HitDiceWarn", {name: this.name, formula: denomination})); - return null; - } - - // Prepare roll data - const parts = [`1${denomination}`, "@abilities.con.mod"]; - const title = game.i18n.localize("SW5E.HitDiceRoll"); - const rollData = foundry.utils.deepClone(this.data.data); - - // Call the roll helper utility - const roll = await damageRoll({ - event: new Event("hitDie"), - parts: parts, - data: rollData, - title: title, - allowCritical: false, - fastForward: !dialog, - dialogOptions: {width: 350}, - messageData: { - speaker: ChatMessage.getSpeaker({actor: this}), - "flags.sw5e.roll": {type: "hitDie"} - } - }); - if ( !roll ) return null; - - // Adjust actor data - await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1}); - const hp = this.data.data.attributes.hp; - const dhp = Math.min(hp.max + (hp.tempmax ?? 0) - hp.value, roll.total); - await this.update({"data.attributes.hp.value": hp.value + dhp}); - return roll; - } - - /* -------------------------------------------- */ - - /** - * Results from a rest operation. - * - * @typedef {object} RestResult - * @property {number} dhp Hit points recovered during the rest. - * @property {number} dhd Hit dice recovered or spent during the rest. - * @property {object} updateData Updates applied to the actor. - * @property {Array.} updateItems Updates applied to actor's items. - * @property {boolean} newDay Whether a new day occurred during the rest. - */ - - /* -------------------------------------------- */ - - /** - * Take a short rest, possibly spending hit dice and recovering resources, item uses, and tech slots & points. - * - * @param {object} [options] - * @param {boolean} [options.dialog=true] Present a dialog window which allows for rolling hit dice as part - * of the Short Rest and selecting whether a new day has occurred. - * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. - * @param {boolean} [options.autoHD=false] Automatically spend Hit Dice if you are missing 3 or more hit points. - * @param {boolean} [options.autoHDThreshold=3] A number of missing hit points which would trigger an automatic HD roll. - * @return {Promise.} A Promise which resolves once the short rest workflow has completed. - */ - async shortRest({dialog=true, chat=true, autoHD=false, autoHDThreshold=3}={}) { - - // Take note of the initial hit points and number of hit dice the Actor has - const hd0 = this.data.data.attributes.hd; - const hp0 = this.data.data.attributes.hp.value; - let newDay = false; - - // Display a Dialog for rolling hit dice - if ( dialog ) { - try { - newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0}); - } catch(err) { - return; - } - } - - // Automatically spend hit dice - else if ( autoHD ) { - await this.autoSpendHitDice({ threshold: autoHDThreshold }); - } - - return this._rest(chat, newDay, false, this.data.data.attributes.hd - hd0, this.data.data.attributes.hp.value - hp0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value); - } - - /* -------------------------------------------- */ - - /** - * Take a long rest, recovering hit points, hit dice, resources, item uses, and tech & force power points & slots. - * - * @param {object} [options] - * @param {boolean} [options.dialog=true] Present a confirmation dialog window whether or not to take a long rest. - * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. - * @param {boolean} [options.newDay=true] Whether the long rest carries over to a new day. - * @return {Promise.} A Promise which resolves once the long rest workflow has completed. - */ - async longRest({dialog=true, chat=true, newDay=true}={}) { - // Maybe present a confirmation dialog - if ( dialog ) { - try { - newDay = await LongRestDialog.longRestDialog({actor: this}); - } catch(err) { - return; - } - } - - return this._rest(chat, newDay, true, 0, 0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value, this.data.data.attributes.force.points.max - this.data.data.attributes.force.points.value); - } - - /* -------------------------------------------- */ - - /** - * Perform all of the changes needed for a short or long rest. - * - * @param {boolean} chat Summarize the results of the rest workflow as a chat message. - * @param {boolean} newDay Has a new day occurred during this rest? - * @param {boolean} longRest Is this a long rest? - * @param {number} [dhd=0] Number of hit dice spent during so far during the rest. - * @param {number} [dhp=0] Number of hit points recovered so far during the rest. - * @param {number} [dtp=0] Number of tech points recovered so far during the rest. - * @param {number} [dfp=0] Number of force points recovered so far during the rest. - * @return {Promise.} Consolidated results of the rest workflow. - * @private - */ - async _rest(chat, newDay, longRest, dhd=0, dhp=0, dtp=0, dfp=0) { - // TODO: Turn gritty realism into the SW5e longer rests variant rule https://sw5e.com/rules/variantRules/Longer%20Rests - let hitPointsRecovered = 0; - let hitPointUpdates = {}; - let hitDiceRecovered = 0; - let hitDiceUpdates = []; - - // Recover hit points & hit dice on long rest - if ( longRest ) { - ({ updates: hitPointUpdates, hitPointsRecovered } = this._getRestHitPointRecovery()); - ({ updates: hitDiceUpdates, hitDiceRecovered } = this._getRestHitDiceRecovery()); - } - - // Figure out the rest of the changes - const result = { - dhd: dhd + hitDiceRecovered, - dhp: dhp + hitPointsRecovered, - dtp: dtp, - dfp: dfp, - updateData: { - ...hitPointUpdates, - ...this._getRestResourceRecovery({ recoverShortRestResources: !longRest, recoverLongRestResources: longRest }), - ...this._getRestPowerRecovery({ recoverForcePowers: longRest }) - }, - updateItems: [ - ...hitDiceUpdates, - ...this._getRestItemUsesRecovery({ recoverLongRestUses: longRest, recoverDailyUses: newDay }) - ], - newDay: newDay - } - - // Perform updates - await this.update(result.updateData); - await this.updateEmbeddedDocuments("Item", result.updateItems); - - // Display a Chat Message summarizing the rest effects - if ( chat ) await this._displayRestResultMessage(result, longRest); - - // Return data summarizing the rest effects - return result; - } - - /* -------------------------------------------- */ - - /** - * Display a chat message with the result of a rest. - * - * @param {RestResult} result Result of the rest operation. - * @param {boolean} [longRest=false] Is this a long rest? - * @return {Promise.} Chat message that was created. - * @protected - */ - async _displayRestResultMessage(result, longRest=false) { - const { dhd, dhp, dtp, dfp, newDay } = result; - const diceRestored = dhd !== 0; - const healthRestored = dhp !== 0; - const length = longRest ? "Long" : "Short"; - - let restFlavor, message; - - // Summarize the rest duration - switch (game.settings.get("sw5e", "restVariant")) { - case 'normal': restFlavor = (longRest && newDay) ? "SW5E.LongRestOvernight" : `SW5E.${length}RestNormal`; break; - case 'gritty': restFlavor = (!longRest && newDay) ? "SW5E.ShortRestOvernight" : `SW5E.${length}RestGritty`; break; - case 'epic': restFlavor = `SW5E.${length}RestEpic`; break; - } - - // Determine the chat message to display - if (longRest) { - message = "SW5E.LongRestResult"; - if (dhp !== 0) message += "HP"; - if (dfp !== 0) message += "FP"; - if (dtp !== 0) message += "TP"; - if (dhd !== 0) message += "HD"; - } else { - message = "SW5E.ShortRestResultShort"; - if ((dhd !== 0) && (dhp !== 0)){ - if (dtp !== 0){ - message = "SW5E.ShortRestResultWithTech"; - }else{ - message = "SW5E.ShortRestResult"; + // Include proficiency bonus + if (abl.prof > 0) { + parts.push("@prof"); + data.prof = abl.prof; } - }else{ - if (dtp !== 0){ - message = "SW5E.ShortRestResultOnlyTech"; + + // Include a global actor ability save bonus + const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + if (bonuses.save) { + parts.push("@saveBonus"); + data.saveBonus = bonuses.save; } - } + + // Add provided extra roll parts now because they will get clobbered by mergeObject below + if (options.parts?.length > 0) { + parts.push(...options.parts); + } + + // Roll and return + const rollData = foundry.utils.mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}), + halflingLucky: this.getFlag("sw5e", "halflingLucky"), + messageData: { + "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "save", abilityId} + } + }); + return d20Roll(rollData); } - // Create a chat message - let chatData = { - user: game.user.id, - speaker: {actor: this, alias: this.name}, - flavor: game.i18n.localize(restFlavor), - content: game.i18n.format(message, { - name: this.name, - dice: longRest ? dhd : -dhd, - health: dhp, - tech: dtp, - force: dfp - }) - }; - ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode")); - return ChatMessage.create(chatData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Perform a death saving throw, rolling a d20 plus any global save bonuses + * @param {Object} options Additional options which modify the roll + * @return {Promise} A Promise which resolves to the Roll instance + */ + async rollDeathSave(options = {}) { + // Display a warning if we are not at zero HP or if we already have reached 3 + const death = this.data.data.attributes.death; + if (this.data.data.attributes.hp.value > 0 || death.failure >= 3 || death.success >= 3) { + ui.notifications.warn(game.i18n.localize("SW5E.DeathSaveUnnecessary")); + return null; + } - /** - * Automatically spend hit dice to recover hit points up to a certain threshold. - * - * @param {object} [options] - * @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll. - * @return {Promise.} Number of hit dice spent. - */ - async autoSpendHitDice({ threshold=3 }={}) { - const max = this.data.data.attributes.hp.max + this.data.data.attributes.hp.tempmax; + // Evaluate a global saving throw bonus + const parts = []; + const data = {}; - let diceRolled = 0; - while ( (this.data.data.attributes.hp.value + threshold) <= max ) { - const r = await this.rollHitDie(undefined, {dialog: false}); - if ( r === null ) break; - diceRolled += 1; + // Include a global actor ability save bonus + const bonuses = foundry.utils.getProperty(this.data.data, "bonuses.abilities") || {}; + if (bonuses.save) { + parts.push("@saveBonus"); + data.saveBonus = bonuses.save; + } + + // Evaluate the roll + const rollData = foundry.utils.mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.localize("SW5E.DeathSavingThrow"), + halflingLucky: this.getFlag("sw5e", "halflingLucky"), + targetValue: 10, + messageData: { + "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "death"} + } + }); + const roll = await d20Roll(rollData); + if (!roll) return null; + + // Take action depending on the result + const success = roll.total >= 10; + const d20 = roll.dice[0].total; + + let chatString; + + // Save success + if (success) { + let successes = (death.success || 0) + 1; + + // Critical Success = revive with 1hp + if (d20 === 20) { + await this.update({ + "data.attributes.death.success": 0, + "data.attributes.death.failure": 0, + "data.attributes.hp.value": 1 + }); + chatString = "SW5E.DeathSaveCriticalSuccess"; + } + + // 3 Successes = survive and reset checks + else if (successes === 3) { + await this.update({ + "data.attributes.death.success": 0, + "data.attributes.death.failure": 0 + }); + chatString = "SW5E.DeathSaveSuccess"; + } + + // Increment successes + else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)}); + } + + // Save failure + else { + let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1); + await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)}); + if (failures >= 3) { + // 3 Failures = death + chatString = "SW5E.DeathSaveFailure"; + } + } + + // Display success/failure chat message + if (chatString) { + let chatData = {content: game.i18n.format(chatString, {name: this.name}), speaker}; + ChatMessage.applyRollMode(chatData, roll.options.rollMode); + await ChatMessage.create(chatData); + } + + // Return the rolled result + return roll; } - return diceRolled; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier + * @param {string} [denomination] The hit denomination of hit die to roll. Example "d8". + * If no denomination is provided, the first available HD will be used + * @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll? + * @return {Promise} The created Roll instance, or null if no hit die was rolled + */ + async rollHitDie(denomination, {dialog = true} = {}) { + // If no denomination was provided, choose the first available + let cls = null; + if (!denomination) { + cls = this.itemTypes.class.find((c) => c.data.data.hitDiceUsed < c.data.data.levels); + if (!cls) return null; + denomination = cls.data.data.hitDice; + } - /** - * Recovers actor hit points and eliminates any temp HP. - * - * @param {object} [options] - * @param {boolean} [options.recoverTemp=true] Reset temp HP to zero. - * @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero. - * @return {object} Updates to the actor and change in hit points. - * @protected - */ - _getRestHitPointRecovery({ recoverTemp=true, recoverTempMax=true }={}) { - const data = this.data.data; - let updates = {}; - let max = data.attributes.hp.max; + // Otherwise locate a class (if any) which has an available hit die of the requested denomination + else { + cls = this.items.find((i) => { + const d = i.data.data; + return d.hitDice === denomination && (d.hitDiceUsed || 0) < (d.levels || 1); + }); + } - if ( recoverTempMax ) { - updates["data.attributes.hp.tempmax"] = 0; - } else { - max += data.attributes.hp.tempmax; - } - updates["data.attributes.hp.value"] = max; - if ( recoverTemp ) { - updates["data.attributes.hp.temp"] = 0; + // If no class is available, display an error notification + if (!cls) { + ui.notifications.error(game.i18n.format("SW5E.HitDiceWarn", {name: this.name, formula: denomination})); + return null; + } + + // Prepare roll data + const parts = [`1${denomination}`, "@abilities.con.mod"]; + const title = game.i18n.localize("SW5E.HitDiceRoll"); + const rollData = foundry.utils.deepClone(this.data.data); + + // Call the roll helper utility + const roll = await damageRoll({ + event: new Event("hitDie"), + parts: parts, + data: rollData, + title: title, + allowCritical: false, + fastForward: !dialog, + dialogOptions: {width: 350}, + messageData: { + "speaker": ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "hitDie"} + } + }); + if (!roll) return null; + + // Adjust actor data + await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1}); + const hp = this.data.data.attributes.hp; + const dhp = Math.min(hp.max + (hp.tempmax ?? 0) - hp.value, roll.total); + await this.update({"data.attributes.hp.value": hp.value + dhp}); + return roll; } - return { updates, hitPointsRecovered: max - data.attributes.hp.value }; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Results from a rest operation. + * + * @typedef {object} RestResult + * @property {number} dhp Hit points recovered during the rest. + * @property {number} dhd Hit dice recovered or spent during the rest. + * @property {object} updateData Updates applied to the actor. + * @property {Array.} updateItems Updates applied to actor's items. + * @property {boolean} newDay Whether a new day occurred during the rest. + */ - /** - * Recovers actor resources. - * @param {object} [options] - * @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest. - * @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest. - * @return {object} Updates to the actor. - * @protected - */ - _getRestResourceRecovery({recoverShortRestResources=true, recoverLongRestResources=true}={}) { - let updates = {}; - for ( let [k, r] of Object.entries(this.data.data.resources) ) { - if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) { - updates[`data.resources.${k}.value`] = Number(r.max); - } - } - return updates; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Take a short rest, possibly spending hit dice and recovering resources, item uses, and tech slots & points. + * + * @param {object} [options] + * @param {boolean} [options.dialog=true] Present a dialog window which allows for rolling hit dice as part + * of the Short Rest and selecting whether a new day has occurred. + * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. + * @param {boolean} [options.autoHD=false] Automatically spend Hit Dice if you are missing 3 or more hit points. + * @param {boolean} [options.autoHDThreshold=3] A number of missing hit points which would trigger an automatic HD roll. + * @return {Promise.} A Promise which resolves once the short rest workflow has completed. + */ + async shortRest({dialog = true, chat = true, autoHD = false, autoHDThreshold = 3} = {}) { + // Take note of the initial hit points and number of hit dice the Actor has + const hd0 = this.data.data.attributes.hd; + const hp0 = this.data.data.attributes.hp.value; + let newDay = false; - /** - * Recovers power slots. - * - * @param longRest = true It's a long rest - * @return {object} Updates to the actor. - * @protected - */ - _getRestPowerRecovery({ recoverTechPowers=true, recoverForcePowers=true }={}) { - let updates = {}; + // Display a Dialog for rolling hit dice + if (dialog) { + try { + newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0}); + } catch (err) { + return; + } + } - if (recoverTechPowers) { - updates["data.attributes.tech.points.value"] = this.data.data.attributes.tech.points.max; - updates["data.attributes.tech.points.temp"] = 0; - updates["data.attributes.tech.points.tempmax"] = 0; + // Automatically spend hit dice + else if (autoHD) { + await this.autoSpendHitDice({threshold: autoHDThreshold}); + } - for (let [k, v] of Object.entries(this.data.data.powers)) { - updates[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0); - } + return this._rest( + chat, + newDay, + false, + this.data.data.attributes.hd - hd0, + this.data.data.attributes.hp.value - hp0, + this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value + ); } - if (recoverForcePowers) { - updates["data.attributes.force.points.value"] = this.data.data.attributes.force.points.max; - updates["data.attributes.force.points.temp"] = 0; - updates["data.attributes.force.points.tempmax"] = 0; + /* -------------------------------------------- */ - for ( let [k, v] of Object.entries(this.data.data.powers) ) { - updates[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0); - } + /** + * Take a long rest, recovering hit points, hit dice, resources, item uses, and tech & force power points & slots. + * + * @param {object} [options] + * @param {boolean} [options.dialog=true] Present a confirmation dialog window whether or not to take a long rest. + * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. + * @param {boolean} [options.newDay=true] Whether the long rest carries over to a new day. + * @return {Promise.} A Promise which resolves once the long rest workflow has completed. + */ + async longRest({dialog = true, chat = true, newDay = true} = {}) { + // Maybe present a confirmation dialog + if (dialog) { + try { + newDay = await LongRestDialog.longRestDialog({actor: this}); + } catch (err) { + return; + } + } + + return this._rest( + chat, + newDay, + true, + 0, + 0, + this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value, + this.data.data.attributes.force.points.max - this.data.data.attributes.force.points.value + ); } - return updates; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Perform all of the changes needed for a short or long rest. + * + * @param {boolean} chat Summarize the results of the rest workflow as a chat message. + * @param {boolean} newDay Has a new day occurred during this rest? + * @param {boolean} longRest Is this a long rest? + * @param {number} [dhd=0] Number of hit dice spent during so far during the rest. + * @param {number} [dhp=0] Number of hit points recovered so far during the rest. + * @param {number} [dtp=0] Number of tech points recovered so far during the rest. + * @param {number} [dfp=0] Number of force points recovered so far during the rest. + * @return {Promise.} Consolidated results of the rest workflow. + * @private + */ + async _rest(chat, newDay, longRest, dhd = 0, dhp = 0, dtp = 0, dfp = 0) { + // TODO: Turn gritty realism into the SW5e longer rests variant rule https://sw5e.com/rules/variantRules/Longer%20Rests + let hitPointsRecovered = 0; + let hitPointUpdates = {}; + let hitDiceRecovered = 0; + let hitDiceUpdates = []; - /** - * Recovers class hit dice during a long rest. - * - * @param {object} [options] - * @param {number} [options.maxHitDice] Maximum number of hit dice to recover. - * @return {object} Array of item updates and number of hit dice recovered. - * @protected - */ - _getRestHitDiceRecovery({ maxHitDice=undefined }={}) { - // Determine the number of hit dice which may be recovered - if ( maxHitDice === undefined ) { - maxHitDice = Math.max(Math.floor(this.data.data.details.level / 2), 1); + // Recover hit points & hit dice on long rest + if (longRest) { + ({updates: hitPointUpdates, hitPointsRecovered} = this._getRestHitPointRecovery()); + ({updates: hitDiceUpdates, hitDiceRecovered} = this._getRestHitDiceRecovery()); + } + + // Figure out the rest of the changes + const result = { + dhd: dhd + hitDiceRecovered, + dhp: dhp + hitPointsRecovered, + dtp: dtp, + dfp: dfp, + updateData: { + ...hitPointUpdates, + ...this._getRestResourceRecovery({ + recoverShortRestResources: !longRest, + recoverLongRestResources: longRest + }), + ...this._getRestPowerRecovery({recoverForcePowers: longRest}) + }, + updateItems: [ + ...hitDiceUpdates, + ...this._getRestItemUsesRecovery({recoverLongRestUses: longRest, recoverDailyUses: newDay}) + ], + newDay: newDay + }; + + // Perform updates + await this.update(result.updateData); + await this.updateEmbeddedDocuments("Item", result.updateItems); + + // Display a Chat Message summarizing the rest effects + if (chat) await this._displayRestResultMessage(result, longRest); + + // Return data summarizing the rest effects + return result; } - // Sort classes which can recover HD, assuming players prefer recovering larger HD first. - const sortedClasses = Object.values(this.classes).sort((a, b) => { - return (parseInt(b.data.data.hitDice.slice(1)) || 0) - (parseInt(a.data.data.hitDice.slice(1)) || 0); - }); + /* -------------------------------------------- */ - let updates = []; - let hitDiceRecovered = 0; - for ( let item of sortedClasses ) { - const d = item.data.data; - if ( (hitDiceRecovered < maxHitDice) && (d.hitDiceUsed > 0) ) { - let delta = Math.min(d.hitDiceUsed || 0, maxHitDice - hitDiceRecovered); - hitDiceRecovered += delta; - updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); - } + /** + * Display a chat message with the result of a rest. + * + * @param {RestResult} result Result of the rest operation. + * @param {boolean} [longRest=false] Is this a long rest? + * @return {Promise.} Chat message that was created. + * @protected + */ + async _displayRestResultMessage(result, longRest = false) { + const {dhd, dhp, dtp, dfp, newDay} = result; + const diceRestored = dhd !== 0; + const healthRestored = dhp !== 0; + const length = longRest ? "Long" : "Short"; + + let restFlavor, message; + + // Summarize the rest duration + switch (game.settings.get("sw5e", "restVariant")) { + case "normal": + restFlavor = longRest && newDay ? "SW5E.LongRestOvernight" : `SW5E.${length}RestNormal`; + break; + case "gritty": + restFlavor = !longRest && newDay ? "SW5E.ShortRestOvernight" : `SW5E.${length}RestGritty`; + break; + case "epic": + restFlavor = `SW5E.${length}RestEpic`; + break; + } + + // Determine the chat message to display + if (longRest) { + message = "SW5E.LongRestResult"; + if (dhp !== 0) message += "HP"; + if (dfp !== 0) message += "FP"; + if (dtp !== 0) message += "TP"; + if (dhd !== 0) message += "HD"; + } else { + message = "SW5E.ShortRestResultShort"; + if (dhd !== 0 && dhp !== 0) { + if (dtp !== 0) { + message = "SW5E.ShortRestResultWithTech"; + } else { + message = "SW5E.ShortRestResult"; + } + } else { + if (dtp !== 0) { + message = "SW5E.ShortRestResultOnlyTech"; + } + } + } + + // Create a chat message + let chatData = { + user: game.user.id, + speaker: {actor: this, alias: this.name}, + flavor: game.i18n.localize(restFlavor), + content: game.i18n.format(message, { + name: this.name, + dice: longRest ? dhd : -dhd, + health: dhp, + tech: dtp, + force: dfp + }) + }; + ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode")); + return ChatMessage.create(chatData); } - return { updates, hitDiceRecovered }; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Automatically spend hit dice to recover hit points up to a certain threshold. + * + * @param {object} [options] + * @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll. + * @return {Promise.} Number of hit dice spent. + */ + async autoSpendHitDice({threshold = 3} = {}) { + const max = this.data.data.attributes.hp.max + this.data.data.attributes.hp.tempmax; - /** - * Recovers item uses during short or long rests. - * - * @param {object} [options] - * @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest. - * @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest. - * @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day. - * @return {Array.} Array of item updates. - * @protected - */ - _getRestItemUsesRecovery({ recoverShortRestUses=true, recoverLongRestUses=true, recoverDailyUses=true }={}) { - let recovery = []; - if ( recoverShortRestUses ) recovery.push("sr"); - if ( recoverLongRestUses ) recovery.push("lr"); - if ( recoverDailyUses ) recovery.push("day"); + let diceRolled = 0; + while (this.data.data.attributes.hp.value + threshold <= max) { + const r = await this.rollHitDie(undefined, {dialog: false}); + if (r === null) break; + diceRolled += 1; + } - let updates = []; - for ( let item of this.items ) { - const d = item.data.data; - if ( d.uses && recovery.includes(d.uses.per) ) { - updates.push({_id: item.id, "data.uses.value": d.uses.max}); - } - if ( recoverLongRestUses && d.recharge && d.recharge.value ) { - updates.push({_id: item.id, "data.recharge.charged": true}); - } + return diceRolled; } - return updates; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Recovers actor hit points and eliminates any temp HP. + * + * @param {object} [options] + * @param {boolean} [options.recoverTemp=true] Reset temp HP to zero. + * @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero. + * @return {object} Updates to the actor and change in hit points. + * @protected + */ + _getRestHitPointRecovery({recoverTemp = true, recoverTempMax = true} = {}) { + const data = this.data.data; + let updates = {}; + let max = data.attributes.hp.max; - /** - * Transform this Actor into another one. - * - * @param {Actor} target The target Actor. - * @param {boolean} [keepPhysical] Keep physical abilities (str, dex, con) - * @param {boolean} [keepMental] Keep mental abilities (int, wis, cha) - * @param {boolean} [keepSaves] Keep saving throw proficiencies - * @param {boolean} [keepSkills] Keep skill proficiencies - * @param {boolean} [mergeSaves] Take the maximum of the save proficiencies - * @param {boolean} [mergeSkills] Take the maximum of the skill proficiencies - * @param {boolean} [keepClass] Keep proficiency bonus - * @param {boolean} [keepFeats] Keep features - * @param {boolean} [keepPowers] Keep powers - * @param {boolean} [keepItems] Keep items - * @param {boolean} [keepBio] Keep biography - * @param {boolean} [keepVision] Keep vision - * @param {boolean} [transformTokens] Transform linked tokens too - */ - async transformInto(target, { keepPhysical=false, keepMental=false, keepSaves=false, keepSkills=false, - mergeSaves=false, mergeSkills=false, keepClass=false, keepFeats=false, keepPowers=false, - keepItems=false, keepBio=false, keepVision=false, transformTokens=true}={}) { + if (recoverTempMax) { + updates["data.attributes.hp.tempmax"] = 0; + } else { + max += data.attributes.hp.tempmax; + } + updates["data.attributes.hp.value"] = max; + if (recoverTemp) { + updates["data.attributes.hp.temp"] = 0; + } - // Ensure the player is allowed to polymorph - const allowed = game.settings.get("sw5e", "allowPolymorphing"); - if ( !allowed && !game.user.isGM ) { - return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphWarn")); + return {updates, hitPointsRecovered: max - data.attributes.hp.value}; } - // Get the original Actor data and the new source data - const o = this.toJSON(); - o.flags.sw5e = o.flags.sw5e || {}; - o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves}; - const source = target.toJSON(); + /* -------------------------------------------- */ - // Prepare new data to merge from the source - const d = { - type: o.type, // Remain the same actor type - name: `${o.name} (${source.name})`, // Append the new shape to your old name - data: source.data, // Get the data model of your new form - items: source.items, // Get the items of your new form - effects: o.effects.concat(source.effects), // Combine active effects from both forms - img: source.img, // New appearance - permission: o.permission, // Use the original actor permissions - folder: o.folder, // Be displayed in the same sidebar folder - flags: o.flags // Use the original actor flags - }; - - // Specifically delete some data attributes - delete d.data.resources; // Don't change your resource pools - delete d.data.currency; // Don't lose currency - delete d.data.bonuses; // Don't lose global bonuses - - // Specific additional adjustments - d.data.details.alignment = o.data.details.alignment; // Don't change alignment - d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level - d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration - d.data.powers = o.data.powers; // Keep power slots - - // Token appearance updates - d.token = {name: d.name}; - for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) { - d.token[k] = source.token[k]; - } - if ( !keepVision ) { - for ( let k of ['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle'] ) { - d.token[k] = source.token[k]; - } - } - if ( source.token.randomImg ) { - const images = await target.getTokenImages(); - d.token.img = images[Math.floor(Math.random() * images.length)]; + /** + * Recovers actor resources. + * @param {object} [options] + * @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest. + * @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest. + * @return {object} Updates to the actor. + * @protected + */ + _getRestResourceRecovery({recoverShortRestResources = true, recoverLongRestResources = true} = {}) { + let updates = {}; + for (let [k, r] of Object.entries(this.data.data.resources)) { + if ( + Number.isNumeric(r.max) && + ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) + ) { + updates[`data.resources.${k}.value`] = Number(r.max); + } + } + return updates; } - // Transfer ability scores - const abilities = d.data.abilities; - for ( let k of Object.keys(abilities) ) { - const oa = o.data.abilities[k]; - const prof = abilities[k].proficient; - if ( keepPhysical && ["str", "dex", "con"].includes(k) ) abilities[k] = oa; - else if ( keepMental && ["int", "wis", "cha"].includes(k) ) abilities[k] = oa; - if ( keepSaves ) abilities[k].proficient = oa.proficient; - else if ( mergeSaves ) abilities[k].proficient = Math.max(prof, oa.proficient); + /* -------------------------------------------- */ + + /** + * Recovers power slots. + * + * @param longRest = true It's a long rest + * @return {object} Updates to the actor. + * @protected + */ + _getRestPowerRecovery({recoverTechPowers = true, recoverForcePowers = true} = {}) { + let updates = {}; + + if (recoverTechPowers) { + updates["data.attributes.tech.points.value"] = this.data.data.attributes.tech.points.max; + updates["data.attributes.tech.points.temp"] = 0; + updates["data.attributes.tech.points.tempmax"] = 0; + + for (let [k, v] of Object.entries(this.data.data.powers)) { + updates[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : v.tmax ?? 0; + } + } + + if (recoverForcePowers) { + updates["data.attributes.force.points.value"] = this.data.data.attributes.force.points.max; + updates["data.attributes.force.points.temp"] = 0; + updates["data.attributes.force.points.tempmax"] = 0; + + for (let [k, v] of Object.entries(this.data.data.powers)) { + updates[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : v.fmax ?? 0; + } + } + + return updates; } - // Transfer skills - if ( keepSkills ) d.data.skills = o.data.skills; - else if ( mergeSkills ) { - for ( let [k, s] of Object.entries(d.data.skills) ) { - s.value = Math.max(s.value, o.data.skills[k].value); - } + /* -------------------------------------------- */ + + /** + * Recovers class hit dice during a long rest. + * + * @param {object} [options] + * @param {number} [options.maxHitDice] Maximum number of hit dice to recover. + * @return {object} Array of item updates and number of hit dice recovered. + * @protected + */ + _getRestHitDiceRecovery({maxHitDice = undefined} = {}) { + // Determine the number of hit dice which may be recovered + if (maxHitDice === undefined) { + maxHitDice = Math.max(Math.floor(this.data.data.details.level / 2), 1); + } + + // Sort classes which can recover HD, assuming players prefer recovering larger HD first. + const sortedClasses = Object.values(this.classes).sort((a, b) => { + return (parseInt(b.data.data.hitDice.slice(1)) || 0) - (parseInt(a.data.data.hitDice.slice(1)) || 0); + }); + + let updates = []; + let hitDiceRecovered = 0; + for (let item of sortedClasses) { + const d = item.data.data; + if (hitDiceRecovered < maxHitDice && d.hitDiceUsed > 0) { + let delta = Math.min(d.hitDiceUsed || 0, maxHitDice - hitDiceRecovered); + hitDiceRecovered += delta; + updates.push({"_id": item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); + } + } + + return {updates, hitDiceRecovered}; } - // Keep specific items from the original data - d.items = d.items.concat(o.items.filter(i => { - if ( i.type === "class" ) return keepClass; - else if ( i.type === "feat" ) return keepFeats; - else if ( i.type === "power" ) return keepPowers; - else return keepItems; - })); + /* -------------------------------------------- */ - // Transfer classes for NPCs - if (!keepClass && d.data.details.cr) { - d.items.push({ - type: 'class', - name: game.i18n.localize('SW5E.PolymorphTmpClass'), - data: { levels: d.data.details.cr } - }); + /** + * Recovers item uses during short or long rests. + * + * @param {object} [options] + * @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest. + * @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest. + * @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day. + * @return {Array.} Array of item updates. + * @protected + */ + _getRestItemUsesRecovery({recoverShortRestUses = true, recoverLongRestUses = true, recoverDailyUses = true} = {}) { + let recovery = []; + if (recoverShortRestUses) recovery.push("sr"); + if (recoverLongRestUses) recovery.push("lr"); + if (recoverDailyUses) recovery.push("day"); + + let updates = []; + for (let item of this.items) { + const d = item.data.data; + if (d.uses && recovery.includes(d.uses.per)) { + updates.push({"_id": item.id, "data.uses.value": d.uses.max}); + } + if (recoverLongRestUses && d.recharge && d.recharge.value) { + updates.push({"_id": item.id, "data.recharge.charged": true}); + } + } + + return updates; } - // Keep biography - if (keepBio) d.data.details.biography = o.data.details.biography; + /* -------------------------------------------- */ - // Keep senses - if (keepVision) d.data.traits.senses = o.data.traits.senses; - - // Set new data flags - if ( !this.isPolymorphed || !d.flags.sw5e.originalActor ) d.flags.sw5e.originalActor = this.id; - d.flags.sw5e.isPolymorphed = true; - - // Update unlinked Tokens in place since they can simply be re-dropped from the base actor - if (this.isToken) { - const tokenData = d.token; - tokenData.actorData = d; - delete tokenData.actorData.token; - return this.token.update(tokenData); - } - - // Update regular Actors by creating a new Actor with the Polymorphed data - await this.sheet.close(); - Hooks.callAll('sw5e.transformActor', this, target, d, { - keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills, - keepClass, keepFeats, keepPowers, keepItems, keepBio, keepVision, transformTokens - }); - const newActor = await this.constructor.create(d, {renderSheet: true}); - - // Update placed Token instances - if ( !transformTokens ) return; - const tokens = this.getActiveTokens(true); - const updates = tokens.map(t => { - const newTokenData = foundry.utils.deepClone(d.token); - if ( !t.data.actorLink ) newTokenData.actorData = newActor.data; - newTokenData._id = t.data._id; - newTokenData.actorId = newActor.id; - return newTokenData; - }); - return canvas.scene?.updateEmbeddedDocuments("Token", updates); - } - - /* -------------------------------------------- */ - - /** - * If this actor was transformed with transformTokens enabled, then its - * active tokens need to be returned to their original state. If not, then - * we can safely just delete this actor. - */ - async revertOriginalForm() { - if ( !this.isPolymorphed ) return; - if ( !this.isOwner ) { - return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn")); - } - - // If we are reverting an unlinked token, simply replace it with the base actor prototype - if ( this.isToken ) { - const baseActor = game.actors.get(this.token.data.actorId); - const prototypeTokenData = await baseActor.getTokenData(); - const tokenUpdate = {actorData: {}}; - for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) { - tokenUpdate[k] = prototypeTokenData[k]; - } - return this.token.update(tokenUpdate, {recursive: false}); - } - - // Obtain a reference to the original actor - const original = game.actors.get(this.getFlag('sw5e', 'originalActor')); - if ( !original ) return; - - // Get the Tokens which represent this actor - if ( canvas.ready ) { - const tokens = this.getActiveTokens(true); - const tokenData = await original.getTokenData(); - const tokenUpdates = tokens.map(t => { - const update = duplicate(tokenData); - update._id = t.id; - delete update.x; - delete update.y; - return update; - }); - canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates); - } - - // Delete the polymorphed version of the actor, if possible - const isRendered = this.sheet.rendered; - if ( game.user.isGM ) await this.delete(); - else if ( isRendered ) this.sheet.close(); - if ( isRendered ) original.sheet.render(isRendered); - return original; - } - - /* -------------------------------------------- */ - - /** - * Add additional system-specific sidebar directory context menu options for SW5e Actor entities - * @param {jQuery} html The sidebar HTML - * @param {Array} entryOptions The default array of context menu options - */ - static addDirectoryContextOptions(html, entryOptions) { - entryOptions.push({ - name: 'SW5E.PolymorphRestoreTransformation', - icon: '', - callback: li => { - const actor = game.actors.get(li.data('entityId')); - return actor.revertOriginalForm(); - }, - condition: li => { + /** + * Transform this Actor into another one. + * + * @param {Actor} target The target Actor. + * @param {boolean} [keepPhysical] Keep physical abilities (str, dex, con) + * @param {boolean} [keepMental] Keep mental abilities (int, wis, cha) + * @param {boolean} [keepSaves] Keep saving throw proficiencies + * @param {boolean} [keepSkills] Keep skill proficiencies + * @param {boolean} [mergeSaves] Take the maximum of the save proficiencies + * @param {boolean} [mergeSkills] Take the maximum of the skill proficiencies + * @param {boolean} [keepClass] Keep proficiency bonus + * @param {boolean} [keepFeats] Keep features + * @param {boolean} [keepPowers] Keep powers + * @param {boolean} [keepItems] Keep items + * @param {boolean} [keepBio] Keep biography + * @param {boolean} [keepVision] Keep vision + * @param {boolean} [transformTokens] Transform linked tokens too + */ + async transformInto( + target, + { + keepPhysical = false, + keepMental = false, + keepSaves = false, + keepSkills = false, + mergeSaves = false, + mergeSkills = false, + keepClass = false, + keepFeats = false, + keepPowers = false, + keepItems = false, + keepBio = false, + keepVision = false, + transformTokens = true + } = {} + ) { + // Ensure the player is allowed to polymorph const allowed = game.settings.get("sw5e", "allowPolymorphing"); - if ( !allowed && !game.user.isGM ) return false; - const actor = game.actors.get(li.data('entityId')); - return actor && actor.isPolymorphed; - } - }); - } + if (!allowed && !game.user.isGM) { + return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphWarn")); + } - /* -------------------------------------------- */ + // Get the original Actor data and the new source data + const o = this.toJSON(); + o.flags.sw5e = o.flags.sw5e || {}; + o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves}; + const source = target.toJSON(); - /** - * Format a type object into a string. - * @param {object} typeData The type data to convert to a string. - * @returns {string} - */ - static formatCreatureType(typeData) { - if ( typeof typeData === "string" ) return typeData; // backwards compatibility - let localizedType; - if ( typeData.value === "custom" ) { - localizedType = typeData.custom; - } else { - let code = CONFIG.SW5E.creatureTypes[typeData.value]; - localizedType = game.i18n.localize(!!typeData.swarm ? `${code}Pl` : code); + // Prepare new data to merge from the source + const d = { + type: o.type, // Remain the same actor type + name: `${o.name} (${source.name})`, // Append the new shape to your old name + data: source.data, // Get the data model of your new form + items: source.items, // Get the items of your new form + effects: o.effects.concat(source.effects), // Combine active effects from both forms + img: source.img, // New appearance + permission: o.permission, // Use the original actor permissions + folder: o.folder, // Be displayed in the same sidebar folder + flags: o.flags // Use the original actor flags + }; + + // Specifically delete some data attributes + delete d.data.resources; // Don't change your resource pools + delete d.data.currency; // Don't lose currency + delete d.data.bonuses; // Don't lose global bonuses + + // Specific additional adjustments + d.data.details.alignment = o.data.details.alignment; // Don't change alignment + d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level + d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration + d.data.powers = o.data.powers; // Keep power slots + + // Token appearance updates + d.token = {name: d.name}; + for (let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"]) { + d.token[k] = source.token[k]; + } + if (!keepVision) { + for (let k of ["dimSight", "brightSight", "dimLight", "brightLight", "vision", "sightAngle"]) { + d.token[k] = source.token[k]; + } + } + if (source.token.randomImg) { + const images = await target.getTokenImages(); + d.token.img = images[Math.floor(Math.random() * images.length)]; + } + + // Transfer ability scores + const abilities = d.data.abilities; + for (let k of Object.keys(abilities)) { + const oa = o.data.abilities[k]; + const prof = abilities[k].proficient; + if (keepPhysical && ["str", "dex", "con"].includes(k)) abilities[k] = oa; + else if (keepMental && ["int", "wis", "cha"].includes(k)) abilities[k] = oa; + if (keepSaves) abilities[k].proficient = oa.proficient; + else if (mergeSaves) abilities[k].proficient = Math.max(prof, oa.proficient); + } + + // Transfer skills + if (keepSkills) d.data.skills = o.data.skills; + else if (mergeSkills) { + for (let [k, s] of Object.entries(d.data.skills)) { + s.value = Math.max(s.value, o.data.skills[k].value); + } + } + + // Keep specific items from the original data + d.items = d.items.concat( + o.items.filter((i) => { + if (i.type === "class") return keepClass; + else if (i.type === "feat") return keepFeats; + else if (i.type === "power") return keepPowers; + else return keepItems; + }) + ); + + // Transfer classes for NPCs + if (!keepClass && d.data.details.cr) { + d.items.push({ + type: "class", + name: game.i18n.localize("SW5E.PolymorphTmpClass"), + data: {levels: d.data.details.cr} + }); + } + + // Keep biography + if (keepBio) d.data.details.biography = o.data.details.biography; + + // Keep senses + if (keepVision) d.data.traits.senses = o.data.traits.senses; + + // Set new data flags + if (!this.isPolymorphed || !d.flags.sw5e.originalActor) d.flags.sw5e.originalActor = this.id; + d.flags.sw5e.isPolymorphed = true; + + // Update unlinked Tokens in place since they can simply be re-dropped from the base actor + if (this.isToken) { + const tokenData = d.token; + tokenData.actorData = d; + delete tokenData.actorData.token; + return this.token.update(tokenData); + } + + // Update regular Actors by creating a new Actor with the Polymorphed data + await this.sheet.close(); + Hooks.callAll("sw5e.transformActor", this, target, d, { + keepPhysical, + keepMental, + keepSaves, + keepSkills, + mergeSaves, + mergeSkills, + keepClass, + keepFeats, + keepPowers, + keepItems, + keepBio, + keepVision, + transformTokens + }); + const newActor = await this.constructor.create(d, {renderSheet: true}); + + // Update placed Token instances + if (!transformTokens) return; + const tokens = this.getActiveTokens(true); + const updates = tokens.map((t) => { + const newTokenData = foundry.utils.deepClone(d.token); + if (!t.data.actorLink) newTokenData.actorData = newActor.data; + newTokenData._id = t.data._id; + newTokenData.actorId = newActor.id; + return newTokenData; + }); + return canvas.scene?.updateEmbeddedDocuments("Token", updates); } - let type = localizedType; - if ( !!typeData.swarm ) { - type = game.i18n.format('SW5E.CreatureSwarmPhrase', { - size: game.i18n.localize(CONFIG.SW5E.actorSizes[typeData.swarm]), - type: localizedType - }); + + /* -------------------------------------------- */ + + /** + * If this actor was transformed with transformTokens enabled, then its + * active tokens need to be returned to their original state. If not, then + * we can safely just delete this actor. + */ + async revertOriginalForm() { + if (!this.isPolymorphed) return; + if (!this.isOwner) { + return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn")); + } + + // If we are reverting an unlinked token, simply replace it with the base actor prototype + if (this.isToken) { + const baseActor = game.actors.get(this.token.data.actorId); + const prototypeTokenData = await baseActor.getTokenData(); + const tokenUpdate = {actorData: {}}; + for (let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"]) { + tokenUpdate[k] = prototypeTokenData[k]; + } + return this.token.update(tokenUpdate, {recursive: false}); + } + + // Obtain a reference to the original actor + const original = game.actors.get(this.getFlag("sw5e", "originalActor")); + if (!original) return; + + // Get the Tokens which represent this actor + if (canvas.ready) { + const tokens = this.getActiveTokens(true); + const tokenData = await original.getTokenData(); + const tokenUpdates = tokens.map((t) => { + const update = duplicate(tokenData); + update._id = t.id; + delete update.x; + delete update.y; + return update; + }); + canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates); + } + + // Delete the polymorphed version of the actor, if possible + const isRendered = this.sheet.rendered; + if (game.user.isGM) await this.delete(); + else if (isRendered) this.sheet.close(); + if (isRendered) original.sheet.render(isRendered); + return original; } - if (typeData.subtype) type = `${type} (${typeData.subtype})`; - return type; - } - /* -------------------------------------------- */ - /* DEPRECATED METHODS */ - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * @deprecated since sw5e 0.97 - */ - getPowerDC(ability) { - console.warn(`The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc`); - return this.data.data.abilities[ability]?.dc; - } + /** + * Add additional system-specific sidebar directory context menu options for SW5e Actor entities + * @param {jQuery} html The sidebar HTML + * @param {Array} entryOptions The default array of context menu options + */ + static addDirectoryContextOptions(html, entryOptions) { + entryOptions.push({ + name: "SW5E.PolymorphRestoreTransformation", + icon: '', + callback: (li) => { + const actor = game.actors.get(li.data("entityId")); + return actor.revertOriginalForm(); + }, + condition: (li) => { + const allowed = game.settings.get("sw5e", "allowPolymorphing"); + if (!allowed && !game.user.isGM) return false; + const actor = game.actors.get(li.data("entityId")); + return actor && actor.isPolymorphed; + } + }); + } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * Cast a Power, consuming a power slot of a certain level - * @param {Item5e} item The power being cast by the actor - * @param {Event} event The originating user interaction which triggered the cast - * @deprecated since sw5e 1.2.0 - */ - async usePower(item, {configureDialog=true}={}) { - console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`); - if ( item.data.type !== "power" ) throw new Error("Wrong Item type"); - return item.roll(); - } -} \ No newline at end of file + /** + * Format a type object into a string. + * @param {object} typeData The type data to convert to a string. + * @returns {string} + */ + static formatCreatureType(typeData) { + if (typeof typeData === "string") return typeData; // backwards compatibility + let localizedType; + if (typeData.value === "custom") { + localizedType = typeData.custom; + } else { + let code = CONFIG.SW5E.creatureTypes[typeData.value]; + localizedType = game.i18n.localize(!!typeData.swarm ? `${code}Pl` : code); + } + let type = localizedType; + if (!!typeData.swarm) { + type = game.i18n.format("SW5E.CreatureSwarmPhrase", { + size: game.i18n.localize(CONFIG.SW5E.actorSizes[typeData.swarm]), + type: localizedType + }); + } + if (typeData.subtype) type = `${type} (${typeData.subtype})`; + return type; + } + + /* -------------------------------------------- */ + /* DEPRECATED METHODS */ + /* -------------------------------------------- */ + + /** + * @deprecated since sw5e 0.97 + */ + getPowerDC(ability) { + console.warn( + `The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc` + ); + return this.data.data.abilities[ability]?.dc; + } + + /* -------------------------------------------- */ + + /** + * Cast a Power, consuming a power slot of a certain level + * @param {Item5e} item The power being cast by the actor + * @param {Event} event The originating user interaction which triggered the cast + * @deprecated since sw5e 1.2.0 + */ + async usePower(item, {configureDialog = true} = {}) { + console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`); + if (item.data.type !== "power") throw new Error("Wrong Item type"); + return item.roll(); + } +} diff --git a/module/actor/old_entity.js b/module/actor/old_entity.js index 23080d27..50b9b895 100644 --- a/module/actor/old_entity.js +++ b/module/actor/old_entity.js @@ -1,2040 +1,2126 @@ -import { d20Roll, damageRoll } from "../dice.js"; +import {d20Roll, damageRoll} from "../dice.js"; import ShortRestDialog from "../apps/short-rest.js"; import LongRestDialog from "../apps/long-rest.js"; -import {SW5E} from '../config.js'; +import {SW5E} from "../config.js"; /** * Extend the base Actor class to implement additional system-specific logic for SW5e. */ export default class Actor5e extends Actor { - - /** - * Is this Actor currently polymorphed into some other creature? - * @return {boolean} - */ - get isPolymorphed() { - return this.getFlag("sw5e", "isPolymorphed") || false; - } - - /* -------------------------------------------- */ - - /** @override */ - prepareBaseData() { - switch ( this.data.type ) { - case "character": - return this._prepareCharacterData(this.data); - case "npc": - return this._prepareNPCData(this.data); - case "starship": - return this._prepareStarshipData(this.data); - case "vehicle": - return this._prepareVehicleData(this.data); - } - } - - /* -------------------------------------------- */ - - /** @override */ - prepareDerivedData() { - const actorData = this.data; - const data = actorData.data; - const flags = actorData.flags.sw5e || {}; - const bonuses = getProperty(data, "bonuses.abilities") || {}; - - // Retrieve data for polymorphed actors - let originalSaves = null; - let originalSkills = null; - if (this.isPolymorphed) { - const transformOptions = this.getFlag('sw5e', 'transformOptions'); - const original = game.actors?.get(this.getFlag('sw5e', 'originalActor')); - if (original) { - if (transformOptions.mergeSaves) { - originalSaves = original.data.data.abilities; - } - if (transformOptions.mergeSkills) { - originalSkills = original.data.data.skills; - } - } + /** + * Is this Actor currently polymorphed into some other creature? + * @return {boolean} + */ + get isPolymorphed() { + return this.getFlag("sw5e", "isPolymorphed") || false; } - // Ability modifiers and saves - const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0; - const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0; - const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0; - for (let [id, abl] of Object.entries(data.abilities)) { - abl.mod = Math.floor((abl.value - 10) / 2); - abl.prof = (abl.proficient || 0) * data.attributes.prof; - abl.saveBonus = saveBonus; - abl.checkBonus = checkBonus; - abl.save = abl.mod + abl.prof + abl.saveBonus; - abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus; - - // If we merged saves when transforming, take the highest bonus here. - if (originalSaves && abl.proficient) { - abl.save = Math.max(abl.save, originalSaves[id].save); - } - } - - // Inventory encumbrance - data.attributes.encumbrance = this._computeEncumbrance(actorData); - - if (actorData.type === "starship") { - - // Calculate AC - data.attributes.ac.value += Math.min(data.abilities.dex.mod, data.attributes.equip.armor.maxDex); - - // Set Power Die Storage - data.attributes.power.central.max += data.attributes.equip.powerCoupling.centralCap; - data.attributes.power.comms.max += data.attributes.equip.powerCoupling.systemCap; - data.attributes.power.engines.max += data.attributes.equip.powerCoupling.systemCap; - data.attributes.power.shields.max += data.attributes.equip.powerCoupling.systemCap; - data.attributes.power.sensors.max += data.attributes.equip.powerCoupling.systemCap; - data.attributes.power.weapons.max += data.attributes.equip.powerCoupling.systemCap; - - // Find Size info of Starship - const size = actorData.items.filter(i => i.type === "starship"); - if (size.length === 0) return; - const sizeData = size[0].data; - - // Prepare Hull Points - data.attributes.hp.max = sizeData.hullDiceRolled.reduce((a, b) => a + b, 0) + data.abilities.con.mod * data.attributes.hull.dicemax; - if (data.attributes.hp.value === null) data.attributes.hp.value = data.attributes.hp.max; - - // Prepare Shield Points - data.attributes.hp.tempmax = (sizeData.shldDiceRolled.reduce((a, b) => a + b, 0) + data.abilities.str.mod * data.attributes.shld.dicemax) * data.attributes.equip.shields.capMult; - if (data.attributes.hp.temp === null) data.attributes.hp.temp = data.attributes.hp.tempmax; - - // Prepare Speeds - data.attributes.movement.space = sizeData.baseSpaceSpeed + (50 * (data.abilities.str.mod - data.abilities.con.mod)); - data.attributes.movement.turn = Math.min(data.attributes.movement.space, Math.max(50,(sizeData.baseTurnSpeed - (50 * (data.abilities.dex.mod - data.abilities.con.mod))))); - - // Prepare Max Suites - data.attributes.mods.suites.max = sizeData.modMaxSuitesBase + (sizeData.modMaxSuitesMult * data.abilities.con.mod); - - // Prepare Hardpoints - data.attributes.mods.hardpoints.max = sizeData.hardpointMult * Math.max(1,data.abilities.str.mod); - - //Prepare Fuel - data.attributes.fuel = this._computeFuel(actorData); - } - - // Prepare skills - this._prepareSkills(actorData, bonuses, checkBonus, originalSkills); - - // Determine Initiative Modifier - const init = data.attributes.init; - const athlete = flags.remarkableAthlete; - const joat = flags.jackOfAllTrades; - init.mod = data.abilities.dex.mod; - if ( joat ) init.prof = Math.floor(0.5 * data.attributes.prof); - else if ( athlete ) init.prof = Math.ceil(0.5 * data.attributes.prof); - else init.prof = 0; - init.value = init.value ?? 0; - init.bonus = init.value + (flags.initiativeAlert ? 5 : 0); - init.total = init.mod + init.prof + init.bonus; - - // Prepare power-casting data - data.attributes.powerForceLightDC = 8 + data.abilities.wis.mod + data.attributes.prof ?? 10; - data.attributes.powerForceDarkDC = 8 + data.abilities.cha.mod + data.attributes.prof ?? 10; - data.attributes.powerForceUnivDC = Math.max(data.attributes.powerForceLightDC,data.attributes.powerForceDarkDC) ?? 10; - data.attributes.powerTechDC = 8 + data.abilities.int.mod + data.attributes.prof ?? 10; - this._computeDerivedPowercasting(this.data); - - // Compute owned item attributes which depend on prepared Actor data - this.items.forEach(item => { - item.getSaveDC(); - item.getAttackToHit(); - }); - } - - /* -------------------------------------------- */ - - /** - * Return the amount of experience required to gain a certain character level. - * @param level {Number} The desired level - * @return {Number} The XP required - */ - getLevelExp(level) { - const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS; - return levels[Math.min(level, levels.length - 1)]; - } - - /* -------------------------------------------- */ - - /** - * Return the amount of experience granted by killing a creature of a certain CR. - * @param cr {Number} The creature's challenge rating - * @return {Number} The amount of experience granted per kill - */ - getCRExp(cr) { - if (cr < 1.0) return Math.max(200 * cr, 10); - return CONFIG.SW5E.CR_EXP_LEVELS[cr]; - } - - /* -------------------------------------------- */ - - /** @override */ - getRollData() { - const data = super.getRollData(); - data.classes = this.data.items.reduce((obj, i) => { - if ( i.type === "class" ) { - obj[i.name.slugify({strict: true})] = i.data; - } - return obj; - }, {}); - data.prof = this.data.data.attributes.prof || 0; - return data; - } - - /* -------------------------------------------- */ - - /** - * Return the features which a character is awarded for each class level - * @param {string} className The class name being added - * @param {string} archetypeName The archetype of the class being added, if any - * @param {number} level The number of levels in the added class - * @param {number} priorLevel The previous level of the added class - * @return {Promise} Array of Item5e entities - */ - static async getClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) { - className = className.toLowerCase(); - archetypeName = archetypeName.slugify(); - - // Get the configuration of features which may be added - const clsConfig = CONFIG.SW5E.classFeatures[className]; - if (!clsConfig) return []; - - // Acquire class features - let ids = []; - for ( let [l, f] of Object.entries(clsConfig.features || {}) ) { - l = parseInt(l); - if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f); - } - - // Acquire archetype features - const archConfig = clsConfig.archetypes[archetypeName] || {}; - for ( let [l, f] of Object.entries(archConfig.features || {}) ) { - l = parseInt(l); - if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f); - } - - // Load item data for all identified features - const features = []; - for ( let id of ids ) { - features.push(await fromUuid(id)); - } - - // Class powers should always be prepared - for ( const feature of features ) { - if ( feature.type === "power" ) { - const preparation = feature.data.data.preparation; - preparation.mode = "always"; - preparation.prepared = true; - } - } - return features; - } - - /* -------------------------------------------- */ - - /** @override */ - async updateEmbeddedEntity(embeddedName, data, options={}) { - const createItems = embeddedName === "OwnedItem" ? await this._createClassFeatures(data) : []; - let updated = await super.updateEmbeddedEntity(embeddedName, data, options); - if ( createItems.length ) await this.createEmbeddedEntity("OwnedItem", createItems); - return updated; - } - - /* -------------------------------------------- */ - - /** - * Create additional class features in the Actor when a class item is updated. - * @private - */ - async _createClassFeatures(updated) { - let toCreate = []; - for (let u of updated instanceof Array ? updated : [updated]) { - const item = this.items.get(u._id); - if (!item || (item.data.type !== "class")) continue; - const updateData = expandObject(u); - const config = { - className: updateData.name || item.data.name, - archetypeName: getProperty(updateData, "data.archetype") || item.data.data.archetype, - level: getProperty(updateData, "data.levels"), - priorLevel: item ? item.data.data.levels : 0 - } - - // Get and create features for an increased class level - let changed = false; - if ( config.level && (config.level > config.priorLevel)) changed = true; - if ( config.archetypeName !== item.data.data.archetype ) changed = true; - - // Get features to create - if ( changed ) { - const existing = new Set(this.items.map(i => i.name)); - const features = await Actor5e.getClassFeatures(config); - for ( let f of features ) { - if ( !existing.has(f.name) ) toCreate.push(f); - } - } - } - return toCreate - } - - /* -------------------------------------------- */ - /* Data Preparation Helpers */ - /* -------------------------------------------- */ - - /** - * Prepare Character type specific data - */ - _prepareCharacterData(actorData) { - const data = actorData.data; - - // Determine character level and available hit dice based on owned Class items - const [level, hd] = actorData.items.reduce((arr, item) => { - if ( item.type === "class" ) { - const classLevels = parseInt(item.data.levels) || 1; - arr[0] += classLevels; - arr[1] += classLevels - (parseInt(item.data.hitDiceUsed) || 0); - } - return arr; - }, [0, 0]); - data.details.level = level; - data.attributes.hd = hd; - - // Character proficiency bonus - data.attributes.prof = Math.floor((level + 7) / 4); - - // Experience required for next level - const xp = data.details.xp; - xp.max = this.getLevelExp(level || 1); - const prior = this.getLevelExp(level - 1 || 0); - const required = xp.max - prior; - const pct = Math.round((xp.value - prior) * 100 / required); - xp.pct = Math.clamped(pct, 0, 100); - - // Add base Powercasting attributes - this._computeBasePowercasting(actorData); - } - - /* -------------------------------------------- */ - - /** - * Prepare NPC type specific data - */ - _prepareNPCData(actorData) { - const data = actorData.data; - - // Kill Experience - data.details.xp.value = this.getCRExp(data.details.cr); - - // Proficiency - data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4); - - this._computeBasePowercasting(actorData); - - // Powercaster Level - if ( data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel) ) { - data.details.powerLevel = Math.max(data.details.cr, 1); - } - } - - /* -------------------------------------------- */ - - /** - * Prepare vehicle type-specific data - * @param actorData - * @private - */ - _prepareVehicleData(actorData) {} - - /* -------------------------------------------- */ - - /* -------------------------------------------- */ - - /** - * Prepare starship type-specific data - * @param actorData - * @private - */ - _prepareStarshipData(actorData) { - - const data = actorData.data; - data.attributes.prof = 0; - // Determine Starship size-based properties based on owned Starship item - const size = actorData.items.filter(i => i.type === "starship"); - if (size.length !== 0) { - const sizeData = size[0].data; - const tiers = parseInt(sizeData.tier) || 0; - data.traits.size = sizeData.size; // needs to be the short code - data.details.tier = tiers; - data.attributes.ac.value = 10 + Math.max(tiers - 1, 0); - data.attributes.hull.die = sizeData.hullDice; - data.attributes.hull.dicemax = sizeData.hullDiceStart + tiers; - data.attributes.hull.dice = sizeData.hullDiceStart + tiers - (parseInt(sizeData.hullDiceUsed) || 0); - data.attributes.shld.die = sizeData.shldDice; - data.attributes.shld.dicemax = sizeData.shldDiceStart + tiers; - data.attributes.shld.dice = sizeData.shldDiceStart + tiers - (parseInt(sizeData.shldDiceUsed) || 0); - sizeData.pwrDice = SW5E.powerDieTypes[tiers]; - data.attributes.power.die = sizeData.pwrDice; - data.attributes.cost.baseBuild = sizeData.buildBaseCost; - data.attributes.workforce.minBuild = sizeData.buildMinWorkforce; - data.attributes.workforce.max = data.attributes.workforce.minBuild * 5; - data.attributes.cost.baseUpgrade = SW5E.baseUpgradeCost[tiers]; - data.attributes.cost.multUpgrade = sizeData.upgrdCostMult; - data.attributes.workforce.minUpgrade = sizeData.upgrdMinWorkforce; - data.attributes.equip.size.crewMinWorkforce = (parseInt(sizeData.crewMinWorkforce) || 1); - data.attributes.mods.capLimit = sizeData.modBaseCap; - data.attributes.mods.suites.cap = sizeData.modMaxSuiteCap; - data.attributes.cost.multModification = sizeData.modCostMult; - data.attributes.workforce.minModification = sizeData.modMinWorkforce; - data.attributes.cost.multEquip = sizeData.equipCostMult; - data.attributes.workforce.minEquip = sizeData.equipMinWorkforce; - data.attributes.equip.size.cargoCap = sizeData.cargoCap; - data.attributes.fuel.cost = sizeData.fuelCost; - data.attributes.fuel.cap = sizeData.fuelCap; - data.attributes.equip.size.foodCap = sizeData.foodCap; - } - - // Determine Starship armor-based properties based on owned Starship item - const armor = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "ssarmor"))); // && (i.data.equipped === true))); - if (armor.length !== 0) { - const armorData = armor[0].data; - data.attributes.equip.armor.dr = (parseInt(armorData.dmgred.value) || 0); - data.attributes.equip.armor.maxDex = armorData.armor.dex; - data.attributes.equip.armor.stealthDisadv = armorData.stealth; - } - - // Determine Starship hyperdrive-based properties based on owned Starship item - const hyperdrive = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "hyper"))); // && (i.data.equipped === true))); - if (hyperdrive.length !== 0) { - const hdData = hyperdrive[0].data; - data.attributes.equip.hyperdrive.class = (parseFloat(hdData.hdclass.value) || null); - } - - // Determine Starship power coupling-based properties based on owned Starship item - const pwrcpl = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "powerc"))); // && (i.data.equipped === true))); - if (pwrcpl.length !== 0) { - const pwrcplData = pwrcpl[0].data; - data.attributes.equip.powerCoupling.centralCap = (parseInt(pwrcplData.cscap.value) || 0); - data.attributes.equip.powerCoupling.systemCap = (parseInt(pwrcplData.sscap.value) || 0); - data.attributes.power.central.max = 0; - data.attributes.power.comms.max = 0; - data.attributes.power.engines.max = 0; - data.attributes.power.shields.max = 0; - data.attributes.power.sensors.max = 0; - data.attributes.power.weapons.max = 0; - } - - // Determine Starship reactor-based properties based on owned Starship item - const reactor = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "reactor"))); // && (i.data.equipped === true))); - if (reactor.length !== 0) { - const reactorData = reactor[0].data; - data.attributes.equip.reactor.fuelMult = (parseFloat(reactorData.fuelcostsmod.value) || 0); - data.attributes.equip.reactor.powerRecDie = reactorData.powdicerec.value; - } - - // Determine Starship shield-based properties based on owned Starship item - const shields = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "ssshield"))); // && (i.data.equipped === true))); - if (shields.length !== 0) { - const shieldsData = shields[0].data; - data.attributes.equip.shields.capMult = (parseFloat(shieldsData.capx.value) || 1); - data.attributes.equip.shields.regenRateMult = (parseFloat(shieldsData.regrateco.value) || 1); - } - - } - - /* -------------------------------------------- */ - - /** - * Prepare skill checks. - * @param actorData - * @param bonuses Global bonus data. - * @param checkBonus Ability check specific bonus. - * @param originalSkills A transformed actor's original actor's skills. - * @private - */ - _prepareSkills(actorData, bonuses, checkBonus, originalSkills) { - if (actorData.type === 'vehicle') return; - - const data = actorData.data; - const flags = actorData.flags.sw5e || {}; - - // Skill modifiers - const feats = SW5E.characterFlags; - const athlete = flags.remarkableAthlete; - const joat = flags.jackOfAllTrades; - const observant = flags.observantFeat; - const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0; - for (let [id, skl] of Object.entries(data.skills)) { - skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0; - let round = Math.floor; - - // Remarkable - if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) { - skl.value = 0.5; - round = Math.ceil; - } - - // Jack of All Trades - if ( joat && (skl.value < 0.5) ) { - skl.value = 0.5; - } - - // Polymorph Skill Proficiencies - if ( originalSkills ) { - skl.value = Math.max(skl.value, originalSkills[id].value); - } - - // Compute modifier - skl.bonus = checkBonus + skillBonus; - skl.mod = data.abilities[skl.ability].mod; - skl.prof = round(skl.value * data.attributes.prof); - skl.total = skl.mod + skl.prof + skl.bonus; - - // Compute passive bonus - const passive = observant && (feats.observantFeat.skills.includes(id)) ? 5 : 0; - skl.passive = 10 + skl.total + passive; - } - } - - /* -------------------------------------------- */ - - /** - * Prepare data related to the power-casting capabilities of the Actor - * @private - */ - _computeBasePowercasting (actorData) { - if (actorData.type === 'vehicle' || actorData.type === 'starship') return; - const powers = actorData.data.powers; - const isNPC = actorData.type === 'npc'; - - // Translate the list of classes into force and tech power-casting progression - const forceProgression = { - classes: 0, - levels: 0, - multi: 0, - maxClass: "none", - maxClassPriority: 0, - maxClassLevels: 0, - maxClassPowerLevel: 0, - powersKnown: 0, - points: 0 - }; - const techProgression = { - classes: 0, - levels: 0, - multi: 0, - maxClass: "none", - maxClassPriority: 0, - maxClassLevels: 0, - maxClassPowerLevel: 0, - powersKnown: 0, - points: 0 - }; - - // Tabulate the total power-casting progression - const classes = this.data.items.filter(i => i.type === "class"); - let priority = 0; - for ( let cls of classes ) { - const d = cls.data; - if ( d.powercasting === "none" ) continue; - const levels = d.levels; - const prog = d.powercasting; - - switch (prog) { - case 'consular': - priority = 3; - forceProgression.levels += levels; - forceProgression.multi += (SW5E.powerMaxLevel['consular'][19]/9)*levels; - forceProgression.classes++; - // see if class controls high level forcecasting - if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){ - forceProgression.maxClass = 'consular'; - forceProgression.maxClassLevels = levels; - forceProgression.maxClassPriority = priority; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['consular'][Math.clamped((levels - 1), 0, 20)]; - } - // calculate points and powers known - forceProgression.powersKnown += SW5E.powersKnown['consular'][Math.clamped((levels - 1), 0, 20)]; - forceProgression.points += SW5E.powerPoints['consular'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'engineer': - priority = 2 - techProgression.levels += levels; - techProgression.multi += (SW5E.powerMaxLevel['engineer'][19]/9)*levels; - techProgression.classes++; - // see if class controls high level techcasting - if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){ - techProgression.maxClass = 'engineer'; - techProgression.maxClassLevels = levels; - techProgression.maxClassPriority = priority; - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['engineer'][Math.clamped((levels - 1), 0, 20)]; - } - techProgression.powersKnown += SW5E.powersKnown['engineer'][Math.clamped((levels - 1), 0, 20)]; - techProgression.points += SW5E.powerPoints['engineer'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'guardian': - priority = 1; - forceProgression.levels += levels; - forceProgression.multi += (SW5E.powerMaxLevel['guardian'][19]/9)*levels; - forceProgression.classes++; - // see if class controls high level forcecasting - if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){ - forceProgression.maxClass = 'guardian'; - forceProgression.maxClassLevels = levels; - forceProgression.maxClassPriority = priority; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['guardian'][Math.clamped((levels - 1), 0, 20)]; - } - forceProgression.powersKnown += SW5E.powersKnown['guardian'][Math.clamped((levels - 1), 0, 20)]; - forceProgression.points += SW5E.powerPoints['guardian'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'scout': - priority = 1; - techProgression.levels += levels; - techProgression.multi += (SW5E.powerMaxLevel['scout'][19]/9)*levels; - techProgression.classes++; - // see if class controls high level techcasting - if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){ - techProgression.maxClass = 'scout'; - techProgression.maxClassLevels = levels; - techProgression.maxClassPriority = priority; - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['scout'][Math.clamped((levels - 1), 0, 20)]; - } - techProgression.powersKnown += SW5E.powersKnown['scout'][Math.clamped((levels - 1), 0, 20)]; - techProgression.points += SW5E.powerPoints['scout'][Math.clamped((levels - 1), 0, 20)]; - break; - case 'sentinel': - priority = 2; - forceProgression.levels += levels; - forceProgression.multi += (SW5E.powerMaxLevel['sentinel'][19]/9)*levels; - forceProgression.classes++; - // see if class controls high level forcecasting - if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){ - forceProgression.maxClass = 'sentinel'; - forceProgression.maxClassLevels = levels; - forceProgression.maxClassPriority = priority; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['sentinel'][Math.clamped((levels - 1), 0, 20)]; - } - forceProgression.powersKnown += SW5E.powersKnown['sentinel'][Math.clamped((levels - 1), 0, 20)]; - forceProgression.points += SW5E.powerPoints['sentinel'][Math.clamped((levels - 1), 0, 20)]; - break; } - } - - // EXCEPTION: multi-classed progression uses multi rounded down rather than levels - if (!isNPC && forceProgression.classes > 1) { - forceProgression.levels = Math.floor(forceProgression.multi); - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][forceProgression.levels - 1]; - } - if (!isNPC && techProgression.classes > 1) { - techProgression.levels = Math.floor(techProgression.multi); - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][techProgression.levels - 1]; - } - - // EXCEPTION: NPC with an explicit power-caster level - if (isNPC && actorData.data.details.powerForceLevel) { - forceProgression.levels = actorData.data.details.powerForceLevel; - actorData.data.attributes.force.level = forceProgression.levels; - forceProgression.maxClass = actorData.data.attributes.powercasting; - forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped((forceProgression.levels - 1), 0, 20)]; - } - if (isNPC && actorData.data.details.powerTechLevel) { - techProgression.levels = actorData.data.details.powerTechLevel; - actorData.data.attributes.tech.level = techProgression.levels; - techProgression.maxClass = actorData.data.attributes.powercasting; - techProgression.maxClassPowerLevel = SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped((techProgression.levels - 1), 0, 20)]; - } - - // Look up the number of slots per level from the powerLimit table - let forcePowerLimit = Array.from(SW5E.powerLimit['none']); - for (let i = 0; i < (forceProgression.maxClassPowerLevel); i++) { - forcePowerLimit[i] = SW5E.powerLimit[forceProgression.maxClass][i]; - } - - for ( let [n, lvl] of Object.entries(powers) ) { - let i = parseInt(n.slice(-1)); - if ( Number.isNaN(i) ) continue; - if ( Number.isNumeric(lvl.foverride) ) lvl.fmax = Math.max(parseInt(lvl.foverride), 0); - else lvl.fmax = forcePowerLimit[i-1] || 0; - if (isNPC){ - lvl.fvalue = lvl.fmax; - }else{ - lvl.fvalue = Math.min(parseInt(lvl.fvalue || lvl.value || lvl.fmax),lvl.fmax); - } - } - - let techPowerLimit = Array.from(SW5E.powerLimit['none']); - for (let i = 0; i < (techProgression.maxClassPowerLevel); i++) { - techPowerLimit[i] = SW5E.powerLimit[techProgression.maxClass][i]; - } - - for ( let [n, lvl] of Object.entries(powers) ) { - let i = parseInt(n.slice(-1)); - if ( Number.isNaN(i) ) continue; - if ( Number.isNumeric(lvl.toverride) ) lvl.tmax = Math.max(parseInt(lvl.toverride), 0); - else lvl.tmax = techPowerLimit[i-1] || 0; - if (isNPC){ - lvl.tvalue = lvl.tmax; - }else{ - lvl.tvalue = Math.min(parseInt(lvl.tvalue || lvl.value || lvl.tmax),lvl.tmax); - } - } - - // Set Force and tech power for PC Actors - if (!isNPC && forceProgression.levels){ - actorData.data.attributes.force.known.max = forceProgression.powersKnown; - actorData.data.attributes.force.points.max = forceProgression.points; // + Math.max(actorData.data.abilities.wis.mod,actorData.data.abilities.cha.mod); - actorData.data.attributes.force.level = forceProgression.levels; - } - if (!isNPC && techProgression.levels){ - actorData.data.attributes.tech.known.max = techProgression.powersKnown; - actorData.data.attributes.tech.points.max = techProgression.points; // + actorData.data.abilities.int.mod; - actorData.data.attributes.tech.level = techProgression.levels; - } - - // Tally Powers Known and check for migration first to avoid errors - let hasKnownPowers = actorData?.data?.attributes?.force?.known?.value !== undefined; - if ( hasKnownPowers ) { - const knownPowers = this.data.items.filter(i => i.type === "power"); - let knownForcePowers = 0; - let knownTechPowers = 0; - for ( let knownPower of knownPowers ) { - const d = knownPower.data; - switch (knownPower.data.school){ - case "lgt": - case "uni": - case "drk":{ - knownForcePowers++; - break; - } - case "tec":{ - knownTechPowers++; - break; - } - } - continue; - } - actorData.data.attributes.force.known.value = knownForcePowers; - actorData.data.attributes.tech.known.value = knownTechPowers; - } - } - - /* -------------------------------------------- */ - - /** - * Prepare data related to the power-casting capabilities of the Actor - * @private - */ - _computeDerivedPowercasting (actorData) { - if (actorData.type !== 'actor') return; - - // Set Force and tech power for PC Actors - if (!!actorData.data.attributes.force.level){ - actorData.data.attributes.force.points.max += Math.max(actorData.data.abilities.wis.mod,actorData.data.abilities.cha.mod); - } - if (!!actorData.data.attributes.tech.level){ - actorData.data.attributes.tech.points.max += actorData.data.abilities.int.mod; - } - - } - - /* -------------------------------------------- */ - - /** - * Compute the level and percentage of encumbrance for an Actor. - * - * Optionally include the weight of carried currency across all denominations by applying the standard rule - * from the PHB pg. 143 - * @param {Object} actorData The data object for the Actor being rendered - * @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level - * @private - */ - _computeEncumbrance(actorData) { - - // Get the total weight from items - const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"]; - let weight = actorData.items.reduce((weight, i) => { - if ( !physicalItems.includes(i.type) ) return weight; - const q = i.data.quantity || 0; - const w = i.data.weight || 0; - return weight + (q * w); - }, 0); - - // [Optional] add Currency Weight (for non-transformed actors) - if ( game.settings.get("sw5e", "currencyWeight") && actorData.data.currency ) { - const currency = actorData.data.currency; - const numCoins = Object.values(currency).reduce((val, denom) => val += Math.max(denom, 0), 0); - weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; - } - - // Determine the encumbrance size class - let mod = { - tiny: 0.5, - sm: 1, - med: 1, - lg: 2, - huge: 4, - grg: 8 - }[actorData.data.traits.size] || 1; - if ( this.getFlag("sw5e", "powerfulBuild") ) mod = Math.min(mod * 2, 8); - - // Compute Encumbrance percentage - weight = weight.toNearest(0.1); - const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod; - const pct = Math.clamped((weight * 100) / max, 0, 100); - return { value: weight.toNearest(0.1), max, pct, encumbered: pct > (2/3) }; - } - - _computeFuel(actorData) { - const fuel = actorData.data.attributes.fuel; - // Compute Fuel percentage - const pct = Math.clamped((fuel.value.toNearest(0.1) * 100) / fuel.cap, 0, 100); - return { ...fuel, pct, fueled: pct > 0 }; - } - /* -------------------------------------------- */ - /* Socket Listeners and Handlers - /* -------------------------------------------- */ - /** @override */ - static async create(data, options={}) { - data.token = data.token || {}; - if ( data.type === "character" ) { - mergeObject(data.token, { - vision: true, - dimSight: 30, - brightSight: 0, - actorLink: true, - disposition: 1 - }, {overwrite: false}); - } - return super.create(data, options); - } - - /* -------------------------------------------- */ - - /** @override */ - async update(data, options={}) { - - // Apply changes in Actor size to Token width/height - const newSize = getProperty(data, "data.traits.size"); - if ( newSize && (newSize !== getProperty(this.data, "data.traits.size")) ) { - let size = CONFIG.SW5E.tokenSizes[newSize]; - if ( this.isToken ) this.token.update({height: size, width: size}); - else if ( !data["token.width"] && !hasProperty(data, "token.width") ) { - data["token.height"] = size; - data["token.width"] = size; - } - } - - // Reset death save counters - if ( (this.data.data.attributes.hp.value <= 0) && (getProperty(data, "data.attributes.hp.value") > 0) ) { - setProperty(data, "data.attributes.death.success", 0); - setProperty(data, "data.attributes.death.failure", 0); - } - - // Perform the update - return super.update(data, options); - } - - /* -------------------------------------------- */ - - /** @override */ - async createEmbeddedEntity(embeddedName, itemData, options={}) { - - // Pre-creation steps for owned items - if ( embeddedName === "OwnedItem" ) this._preCreateOwnedItem(itemData, options); - - // Standard embedded entity creation - return super.createEmbeddedEntity(embeddedName, itemData, options); - } - - /* -------------------------------------------- */ - - /** - * A temporary shim function which will eventually (in core fvtt version 0.8.0+) be migrated to the new abstraction layer - * @param itemData - * @param options - * @private - */ - _preCreateOwnedItem(itemData, options) { - if ( this.data.type === "vehicle" ) return; - const isNPC = this.data.type === 'npc'; - let initial = {}; - switch ( itemData.type ) { - - case "weapon": - if ( getProperty(itemData, "data.equipped") === undefined ) { - initial["data.equipped"] = isNPC; // NPCs automatically equip weapons + /** @override */ + prepareBaseData() { + switch (this.data.type) { + case "character": + return this._prepareCharacterData(this.data); + case "npc": + return this._prepareNPCData(this.data); + case "starship": + return this._prepareStarshipData(this.data); + case "vehicle": + return this._prepareVehicleData(this.data); } - if ( getProperty(itemData, "data.proficient") === undefined ) { - if ( isNPC ) { - initial["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - const weaponProf = { - "natural": true, - "simpleVW": "sim", - "simpleB": "sim", - "simpleLW": "sim", - "martialVW": "mar", - "martialB": "mar", - "martialLW": "mar" - }[itemData.data?.weaponType]; // Player characters check proficiency - const actorWeaponProfs = this.data.data.traits?.weaponProf?.value || []; - const hasWeaponProf = (weaponProf === true) || actorWeaponProfs.includes(weaponProf); - initial["data.proficient"] = hasWeaponProf; - } + } + + /* -------------------------------------------- */ + + /** @override */ + prepareDerivedData() { + const actorData = this.data; + const data = actorData.data; + const flags = actorData.flags.sw5e || {}; + const bonuses = getProperty(data, "bonuses.abilities") || {}; + + // Retrieve data for polymorphed actors + let originalSaves = null; + let originalSkills = null; + if (this.isPolymorphed) { + const transformOptions = this.getFlag("sw5e", "transformOptions"); + const original = game.actors?.get(this.getFlag("sw5e", "originalActor")); + if (original) { + if (transformOptions.mergeSaves) { + originalSaves = original.data.data.abilities; + } + if (transformOptions.mergeSkills) { + originalSkills = original.data.data.skills; + } + } } - break; - case "equipment": - if ( getProperty(itemData, "data.equipped") === undefined ) { - initial["data.equipped"] = isNPC; // NPCs automatically equip equipment + // Ability modifiers and saves + const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0; + const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0; + const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0; + for (let [id, abl] of Object.entries(data.abilities)) { + abl.mod = Math.floor((abl.value - 10) / 2); + abl.prof = (abl.proficient || 0) * data.attributes.prof; + abl.saveBonus = saveBonus; + abl.checkBonus = checkBonus; + abl.save = abl.mod + abl.prof + abl.saveBonus; + abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus; + + // If we merged saves when transforming, take the highest bonus here. + if (originalSaves && abl.proficient) { + abl.save = Math.max(abl.save, originalSaves[id].save); + } } - if ( getProperty(itemData, "data.proficient") === undefined ) { - if ( isNPC ) { - initial["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - const armorProf = { - "natural": true, - "clothing": true, - "light": "lgt", - "medium": "med", - "heavy": "hvy", - "shield": "shl" - }[itemData.data?.armor?.type]; // Player characters check proficiency - const actorArmorProfs = this.data.data.traits?.armorProf?.value || []; - const hasEquipmentProf = (armorProf === true) || actorArmorProfs.includes(armorProf); - initial["data.proficient"] = hasEquipmentProf; - } + + // Inventory encumbrance + data.attributes.encumbrance = this._computeEncumbrance(actorData); + + if (actorData.type === "starship") { + // Calculate AC + data.attributes.ac.value += Math.min(data.abilities.dex.mod, data.attributes.equip.armor.maxDex); + + // Set Power Die Storage + data.attributes.power.central.max += data.attributes.equip.powerCoupling.centralCap; + data.attributes.power.comms.max += data.attributes.equip.powerCoupling.systemCap; + data.attributes.power.engines.max += data.attributes.equip.powerCoupling.systemCap; + data.attributes.power.shields.max += data.attributes.equip.powerCoupling.systemCap; + data.attributes.power.sensors.max += data.attributes.equip.powerCoupling.systemCap; + data.attributes.power.weapons.max += data.attributes.equip.powerCoupling.systemCap; + + // Find Size info of Starship + const size = actorData.items.filter((i) => i.type === "starship"); + if (size.length === 0) return; + const sizeData = size[0].data; + + // Prepare Hull Points + data.attributes.hp.max = + sizeData.hullDiceRolled.reduce((a, b) => a + b, 0) + + data.abilities.con.mod * data.attributes.hull.dicemax; + if (data.attributes.hp.value === null) data.attributes.hp.value = data.attributes.hp.max; + + // Prepare Shield Points + data.attributes.hp.tempmax = + (sizeData.shldDiceRolled.reduce((a, b) => a + b, 0) + + data.abilities.str.mod * data.attributes.shld.dicemax) * + data.attributes.equip.shields.capMult; + if (data.attributes.hp.temp === null) data.attributes.hp.temp = data.attributes.hp.tempmax; + + // Prepare Speeds + data.attributes.movement.space = + sizeData.baseSpaceSpeed + 50 * (data.abilities.str.mod - data.abilities.con.mod); + data.attributes.movement.turn = Math.min( + data.attributes.movement.space, + Math.max(50, sizeData.baseTurnSpeed - 50 * (data.abilities.dex.mod - data.abilities.con.mod)) + ); + + // Prepare Max Suites + data.attributes.mods.suites.max = + sizeData.modMaxSuitesBase + sizeData.modMaxSuitesMult * data.abilities.con.mod; + + // Prepare Hardpoints + data.attributes.mods.hardpoints.max = sizeData.hardpointMult * Math.max(1, data.abilities.str.mod); + + //Prepare Fuel + data.attributes.fuel = this._computeFuel(actorData); } - break; - case "power": - initial["data.prepared"] = true; // automatically prepare powers for everyone - break; - } - mergeObject(itemData, initial); - } + // Prepare skills + this._prepareSkills(actorData, bonuses, checkBonus, originalSkills); - /* -------------------------------------------- */ - /* Gameplay Mechanics */ - /* -------------------------------------------- */ + // Determine Initiative Modifier + const init = data.attributes.init; + const athlete = flags.remarkableAthlete; + const joat = flags.jackOfAllTrades; + init.mod = data.abilities.dex.mod; + if (joat) init.prof = Math.floor(0.5 * data.attributes.prof); + else if (athlete) init.prof = Math.ceil(0.5 * data.attributes.prof); + else init.prof = 0; + init.value = init.value ?? 0; + init.bonus = init.value + (flags.initiativeAlert ? 5 : 0); + init.total = init.mod + init.prof + init.bonus; - /** @override */ - async modifyTokenAttribute(attribute, value, isDelta, isBar) { - if ( attribute === "attributes.hp" ) { - const hp = getProperty(this.data.data, attribute); - const delta = isDelta ? (-1 * value) : (hp.value + hp.temp) - value; - return this.applyDamage(delta); - } - return super.modifyTokenAttribute(attribute, value, isDelta, isBar); - } + // Prepare power-casting data + data.attributes.powerForceLightDC = 8 + data.abilities.wis.mod + data.attributes.prof ?? 10; + data.attributes.powerForceDarkDC = 8 + data.abilities.cha.mod + data.attributes.prof ?? 10; + data.attributes.powerForceUnivDC = + Math.max(data.attributes.powerForceLightDC, data.attributes.powerForceDarkDC) ?? 10; + data.attributes.powerTechDC = 8 + data.abilities.int.mod + data.attributes.prof ?? 10; + this._computeDerivedPowercasting(this.data); - /* -------------------------------------------- */ - - /** - * Apply a certain amount of damage or healing to the health pool for Actor - * @param {number} amount An amount of damage (positive) or healing (negative) to sustain - * @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing - * @return {Promise} A Promise which resolves once the damage has been applied - */ - async applyDamage(amount=0, multiplier=1) { - amount = Math.floor(parseInt(amount) * multiplier); - const hp = this.data.data.attributes.hp; - - // Deduct damage from temp HP first - const tmp = parseInt(hp.temp) || 0; - const dt = amount > 0 ? Math.min(tmp, amount) : 0; - - // Remaining goes to health - const tmpMax = parseInt(hp.tempmax) || 0; - const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax); - - // Update the Actor - const updates = { - "data.attributes.hp.temp": tmp - dt, - "data.attributes.hp.value": dh - }; - - // Delegate damage application to a hook - // TODO replace this in the future with a better modifyTokenAttribute function in the core - const allowed = Hooks.call("modifyTokenAttribute", { - attribute: "attributes.hp", - value: amount, - isDelta: false, - isBar: true - }, updates); - return allowed !== false ? this.update(updates) : this; - } - - /* -------------------------------------------- */ - - /** - * Roll a Skill Check - * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus - * @param {string} skillId The skill id (e.g. "ins") - * @param {Object} options Options which configure how the skill check is rolled - * @return {Promise} A Promise which resolves to the created Roll instance - */ - rollSkill(skillId, options={}) { - const skl = this.data.data.skills[skillId]; - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; - - // Compose roll parts and data - const parts = ["@mod"]; - const data = {mod: skl.mod + skl.prof}; - - // Ability test bonus - if ( bonuses.check ) { - data["checkBonus"] = bonuses.check; - parts.push("@checkBonus"); - } - - // Skill check bonus - if ( bonuses.skill ) { - data["skillBonus"] = bonuses.skill; - parts.push("@skillBonus"); - } - - // Add provided extra roll parts now because they will get clobbered by mergeObject below - if (options.parts?.length > 0) { - parts.push(...options.parts); - } - - // Reliable Talent applies to any skill check we have full or better proficiency in - const reliableTalent = (skl.value >= 1 && this.getFlag("sw5e", "reliableTalent")); - - // Roll and return - const rollData = mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.format("SW5E.SkillPromptTitle", {skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId]}), - halflingLucky: this.getFlag("sw5e", "halflingLucky"), - reliableTalent: reliableTalent, - messageData: {"flags.sw5e.roll": {type: "skill", skillId }} - }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); - return d20Roll(rollData); - } - - /* -------------------------------------------- */ - - /** - * Roll a generic ability test or saving throw. - * Prompt the user for input on which variety of roll they want to do. - * @param {String}abilityId The ability id (e.g. "str") - * @param {Object} options Options which configure how ability tests or saving throws are rolled - */ - rollAbility(abilityId, options={}) { - const label = CONFIG.SW5E.abilities[abilityId]; - new Dialog({ - title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), - content: `

${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}

`, - buttons: { - test: { - label: game.i18n.localize("SW5E.ActionAbil"), - callback: () => this.rollAbilityTest(abilityId, options) - }, - save: { - label: game.i18n.localize("SW5E.ActionSave"), - callback: () => this.rollAbilitySave(abilityId, options) - } - } - }).render(true); - } - - /* -------------------------------------------- */ - - /** - * Roll an Ability Test - * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus - * @param {String} abilityId The ability ID (e.g. "str") - * @param {Object} options Options which configure how ability tests are rolled - * @return {Promise} A Promise which resolves to the created Roll instance - */ - rollAbilityTest(abilityId, options={}) { - const label = CONFIG.SW5E.abilities[abilityId]; - const abl = this.data.data.abilities[abilityId]; - - // Construct parts - const parts = ["@mod"]; - const data = {mod: abl.mod}; - - // Add feat-related proficiency bonuses - const feats = this.data.flags.sw5e || {}; - if ( feats.remarkableAthlete && SW5E.characterFlags.remarkableAthlete.abilities.includes(abilityId) ) { - parts.push("@proficiency"); - data.proficiency = Math.ceil(0.5 * this.data.data.attributes.prof); - } - else if ( feats.jackOfAllTrades ) { - parts.push("@proficiency"); - data.proficiency = Math.floor(0.5 * this.data.data.attributes.prof); - } - - // Add global actor bonus - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; - if ( bonuses.check ) { - parts.push("@checkBonus"); - data.checkBonus = bonuses.check; - } - - // Add provided extra roll parts now because they will get clobbered by mergeObject below - if (options.parts?.length > 0) { - parts.push(...options.parts); - } - - // Roll and return - const rollData = mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), - halflingLucky: feats.halflingLucky, - messageData: {"flags.sw5e.roll": {type: "ability", abilityId }} - }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); - return d20Roll(rollData); - } - - /* -------------------------------------------- */ - - /** - * Roll an Ability Saving Throw - * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus - * @param {String} abilityId The ability ID (e.g. "str") - * @param {Object} options Options which configure how ability tests are rolled - * @return {Promise} A Promise which resolves to the created Roll instance - */ - rollAbilitySave(abilityId, options={}) { - const label = CONFIG.SW5E.abilities[abilityId]; - const abl = this.data.data.abilities[abilityId]; - - // Construct parts - const parts = ["@mod"]; - const data = {mod: abl.mod}; - - // Include proficiency bonus - if ( abl.prof > 0 ) { - parts.push("@prof"); - data.prof = abl.prof; - } - - // Include a global actor ability save bonus - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; - if ( bonuses.save ) { - parts.push("@saveBonus"); - data.saveBonus = bonuses.save; - } - - // Add provided extra roll parts now because they will get clobbered by mergeObject below - if (options.parts?.length > 0) { - parts.push(...options.parts); - } - - // Roll and return - const rollData = mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}), - halflingLucky: this.getFlag("sw5e", "halflingLucky"), - messageData: {"flags.sw5e.roll": {type: "save", abilityId }} - }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); - return d20Roll(rollData); - } - - /* -------------------------------------------- */ - - /** - * Perform a death saving throw, rolling a d20 plus any global save bonuses - * @param {Object} options Additional options which modify the roll - * @return {Promise} A Promise which resolves to the Roll instance - */ - async rollDeathSave(options={}) { - - // Display a warning if we are not at zero HP or if we already have reached 3 - const death = this.data.data.attributes.death; - if ( (this.data.data.attributes.hp.value > 0) || (death.failure >= 3) || (death.success >= 3)) { - ui.notifications.warn(game.i18n.localize("SW5E.DeathSaveUnnecessary")); - return null; - } - - // Evaluate a global saving throw bonus - const parts = []; - const data = {}; - const speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); - - // Include a global actor ability save bonus - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; - if ( bonuses.save ) { - parts.push("@saveBonus"); - data.saveBonus = bonuses.save; - } - - // Evaluate the roll - const rollData = mergeObject(options, { - parts: parts, - data: data, - title: game.i18n.localize("SW5E.DeathSavingThrow"), - speaker: speaker, - halflingLucky: this.getFlag("sw5e", "halflingLucky"), - targetValue: 10, - messageData: {"flags.sw5e.roll": {type: "death"}} - }); - rollData.speaker = speaker; - const roll = await d20Roll(rollData); - if ( !roll ) return null; - - // Take action depending on the result - const success = roll.total >= 10; - const d20 = roll.dice[0].total; - - // Save success - if ( success ) { - let successes = (death.success || 0) + 1; - - // Critical Success = revive with 1hp - if ( d20 === 20 ) { - await this.update({ - "data.attributes.death.success": 0, - "data.attributes.death.failure": 0, - "data.attributes.hp.value": 1 + // Compute owned item attributes which depend on prepared Actor data + this.items.forEach((item) => { + item.getSaveDC(); + item.getAttackToHit(); }); - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveCriticalSuccess", {name: this.name}), speaker}); - } + } - // 3 Successes = survive and reset checks - else if ( successes === 3 ) { - await this.update({ - "data.attributes.death.success": 0, - "data.attributes.death.failure": 0 + /* -------------------------------------------- */ + + /** + * Return the amount of experience required to gain a certain character level. + * @param level {Number} The desired level + * @return {Number} The XP required + */ + getLevelExp(level) { + const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS; + return levels[Math.min(level, levels.length - 1)]; + } + + /* -------------------------------------------- */ + + /** + * Return the amount of experience granted by killing a creature of a certain CR. + * @param cr {Number} The creature's challenge rating + * @return {Number} The amount of experience granted per kill + */ + getCRExp(cr) { + if (cr < 1.0) return Math.max(200 * cr, 10); + return CONFIG.SW5E.CR_EXP_LEVELS[cr]; + } + + /* -------------------------------------------- */ + + /** @override */ + getRollData() { + const data = super.getRollData(); + data.classes = this.data.items.reduce((obj, i) => { + if (i.type === "class") { + obj[i.name.slugify({strict: true})] = i.data; + } + return obj; + }, {}); + data.prof = this.data.data.attributes.prof || 0; + return data; + } + + /* -------------------------------------------- */ + + /** + * Return the features which a character is awarded for each class level + * @param {string} className The class name being added + * @param {string} archetypeName The archetype of the class being added, if any + * @param {number} level The number of levels in the added class + * @param {number} priorLevel The previous level of the added class + * @return {Promise} Array of Item5e entities + */ + static async getClassFeatures({className = "", archetypeName = "", level = 1, priorLevel = 0} = {}) { + className = className.toLowerCase(); + archetypeName = archetypeName.slugify(); + + // Get the configuration of features which may be added + const clsConfig = CONFIG.SW5E.classFeatures[className]; + if (!clsConfig) return []; + + // Acquire class features + let ids = []; + for (let [l, f] of Object.entries(clsConfig.features || {})) { + l = parseInt(l); + if (l <= level && l > priorLevel) ids = ids.concat(f); + } + + // Acquire archetype features + const archConfig = clsConfig.archetypes[archetypeName] || {}; + for (let [l, f] of Object.entries(archConfig.features || {})) { + l = parseInt(l); + if (l <= level && l > priorLevel) ids = ids.concat(f); + } + + // Load item data for all identified features + const features = []; + for (let id of ids) { + features.push(await fromUuid(id)); + } + + // Class powers should always be prepared + for (const feature of features) { + if (feature.type === "power") { + const preparation = feature.data.data.preparation; + preparation.mode = "always"; + preparation.prepared = true; + } + } + return features; + } + + /* -------------------------------------------- */ + + /** @override */ + async updateEmbeddedEntity(embeddedName, data, options = {}) { + const createItems = embeddedName === "OwnedItem" ? await this._createClassFeatures(data) : []; + let updated = await super.updateEmbeddedEntity(embeddedName, data, options); + if (createItems.length) await this.createEmbeddedEntity("OwnedItem", createItems); + return updated; + } + + /* -------------------------------------------- */ + + /** + * Create additional class features in the Actor when a class item is updated. + * @private + */ + async _createClassFeatures(updated) { + let toCreate = []; + for (let u of updated instanceof Array ? updated : [updated]) { + const item = this.items.get(u._id); + if (!item || item.data.type !== "class") continue; + const updateData = expandObject(u); + const config = { + className: updateData.name || item.data.name, + archetypeName: getProperty(updateData, "data.archetype") || item.data.data.archetype, + level: getProperty(updateData, "data.levels"), + priorLevel: item ? item.data.data.levels : 0 + }; + + // Get and create features for an increased class level + let changed = false; + if (config.level && config.level > config.priorLevel) changed = true; + if (config.archetypeName !== item.data.data.archetype) changed = true; + + // Get features to create + if (changed) { + const existing = new Set(this.items.map((i) => i.name)); + const features = await Actor5e.getClassFeatures(config); + for (let f of features) { + if (!existing.has(f.name)) toCreate.push(f); + } + } + } + return toCreate; + } + + /* -------------------------------------------- */ + /* Data Preparation Helpers */ + /* -------------------------------------------- */ + + /** + * Prepare Character type specific data + */ + _prepareCharacterData(actorData) { + const data = actorData.data; + + // Determine character level and available hit dice based on owned Class items + const [level, hd] = actorData.items.reduce( + (arr, item) => { + if (item.type === "class") { + const classLevels = parseInt(item.data.levels) || 1; + arr[0] += classLevels; + arr[1] += classLevels - (parseInt(item.data.hitDiceUsed) || 0); + } + return arr; + }, + [0, 0] + ); + data.details.level = level; + data.attributes.hd = hd; + + // Character proficiency bonus + data.attributes.prof = Math.floor((level + 7) / 4); + + // Experience required for next level + const xp = data.details.xp; + xp.max = this.getLevelExp(level || 1); + const prior = this.getLevelExp(level - 1 || 0); + const required = xp.max - prior; + const pct = Math.round(((xp.value - prior) * 100) / required); + xp.pct = Math.clamped(pct, 0, 100); + + // Add base Powercasting attributes + this._computeBasePowercasting(actorData); + } + + /* -------------------------------------------- */ + + /** + * Prepare NPC type specific data + */ + _prepareNPCData(actorData) { + const data = actorData.data; + + // Kill Experience + data.details.xp.value = this.getCRExp(data.details.cr); + + // Proficiency + data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4); + + this._computeBasePowercasting(actorData); + + // Powercaster Level + if (data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel)) { + data.details.powerLevel = Math.max(data.details.cr, 1); + } + } + + /* -------------------------------------------- */ + + /** + * Prepare vehicle type-specific data + * @param actorData + * @private + */ + _prepareVehicleData(actorData) {} + + /* -------------------------------------------- */ + + /* -------------------------------------------- */ + + /** + * Prepare starship type-specific data + * @param actorData + * @private + */ + _prepareStarshipData(actorData) { + const data = actorData.data; + data.attributes.prof = 0; + // Determine Starship size-based properties based on owned Starship item + const size = actorData.items.filter((i) => i.type === "starship"); + if (size.length !== 0) { + const sizeData = size[0].data; + const tiers = parseInt(sizeData.tier) || 0; + data.traits.size = sizeData.size; // needs to be the short code + data.details.tier = tiers; + data.attributes.ac.value = 10 + Math.max(tiers - 1, 0); + data.attributes.hull.die = sizeData.hullDice; + data.attributes.hull.dicemax = sizeData.hullDiceStart + tiers; + data.attributes.hull.dice = sizeData.hullDiceStart + tiers - (parseInt(sizeData.hullDiceUsed) || 0); + data.attributes.shld.die = sizeData.shldDice; + data.attributes.shld.dicemax = sizeData.shldDiceStart + tiers; + data.attributes.shld.dice = sizeData.shldDiceStart + tiers - (parseInt(sizeData.shldDiceUsed) || 0); + sizeData.pwrDice = SW5E.powerDieTypes[tiers]; + data.attributes.power.die = sizeData.pwrDice; + data.attributes.cost.baseBuild = sizeData.buildBaseCost; + data.attributes.workforce.minBuild = sizeData.buildMinWorkforce; + data.attributes.workforce.max = data.attributes.workforce.minBuild * 5; + data.attributes.cost.baseUpgrade = SW5E.baseUpgradeCost[tiers]; + data.attributes.cost.multUpgrade = sizeData.upgrdCostMult; + data.attributes.workforce.minUpgrade = sizeData.upgrdMinWorkforce; + data.attributes.equip.size.crewMinWorkforce = parseInt(sizeData.crewMinWorkforce) || 1; + data.attributes.mods.capLimit = sizeData.modBaseCap; + data.attributes.mods.suites.cap = sizeData.modMaxSuiteCap; + data.attributes.cost.multModification = sizeData.modCostMult; + data.attributes.workforce.minModification = sizeData.modMinWorkforce; + data.attributes.cost.multEquip = sizeData.equipCostMult; + data.attributes.workforce.minEquip = sizeData.equipMinWorkforce; + data.attributes.equip.size.cargoCap = sizeData.cargoCap; + data.attributes.fuel.cost = sizeData.fuelCost; + data.attributes.fuel.cap = sizeData.fuelCap; + data.attributes.equip.size.foodCap = sizeData.foodCap; + } + + // Determine Starship armor-based properties based on owned Starship item + const armor = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "ssarmor"); // && (i.data.equipped === true))); + if (armor.length !== 0) { + const armorData = armor[0].data; + data.attributes.equip.armor.dr = parseInt(armorData.dmgred.value) || 0; + data.attributes.equip.armor.maxDex = armorData.armor.dex; + data.attributes.equip.armor.stealthDisadv = armorData.stealth; + } + + // Determine Starship hyperdrive-based properties based on owned Starship item + const hyperdrive = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "hyper"); // && (i.data.equipped === true))); + if (hyperdrive.length !== 0) { + const hdData = hyperdrive[0].data; + data.attributes.equip.hyperdrive.class = parseFloat(hdData.hdclass.value) || null; + } + + // Determine Starship power coupling-based properties based on owned Starship item + const pwrcpl = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "powerc"); // && (i.data.equipped === true))); + if (pwrcpl.length !== 0) { + const pwrcplData = pwrcpl[0].data; + data.attributes.equip.powerCoupling.centralCap = parseInt(pwrcplData.cscap.value) || 0; + data.attributes.equip.powerCoupling.systemCap = parseInt(pwrcplData.sscap.value) || 0; + data.attributes.power.central.max = 0; + data.attributes.power.comms.max = 0; + data.attributes.power.engines.max = 0; + data.attributes.power.shields.max = 0; + data.attributes.power.sensors.max = 0; + data.attributes.power.weapons.max = 0; + } + + // Determine Starship reactor-based properties based on owned Starship item + const reactor = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "reactor"); // && (i.data.equipped === true))); + if (reactor.length !== 0) { + const reactorData = reactor[0].data; + data.attributes.equip.reactor.fuelMult = parseFloat(reactorData.fuelcostsmod.value) || 0; + data.attributes.equip.reactor.powerRecDie = reactorData.powdicerec.value; + } + + // Determine Starship shield-based properties based on owned Starship item + const shields = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "ssshield"); // && (i.data.equipped === true))); + if (shields.length !== 0) { + const shieldsData = shields[0].data; + data.attributes.equip.shields.capMult = parseFloat(shieldsData.capx.value) || 1; + data.attributes.equip.shields.regenRateMult = parseFloat(shieldsData.regrateco.value) || 1; + } + } + + /* -------------------------------------------- */ + + /** + * Prepare skill checks. + * @param actorData + * @param bonuses Global bonus data. + * @param checkBonus Ability check specific bonus. + * @param originalSkills A transformed actor's original actor's skills. + * @private + */ + _prepareSkills(actorData, bonuses, checkBonus, originalSkills) { + if (actorData.type === "vehicle") return; + + const data = actorData.data; + const flags = actorData.flags.sw5e || {}; + + // Skill modifiers + const feats = SW5E.characterFlags; + const athlete = flags.remarkableAthlete; + const joat = flags.jackOfAllTrades; + const observant = flags.observantFeat; + const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0; + for (let [id, skl] of Object.entries(data.skills)) { + skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0; + let round = Math.floor; + + // Remarkable + if (athlete && skl.value < 0.5 && feats.remarkableAthlete.abilities.includes(skl.ability)) { + skl.value = 0.5; + round = Math.ceil; + } + + // Jack of All Trades + if (joat && skl.value < 0.5) { + skl.value = 0.5; + } + + // Polymorph Skill Proficiencies + if (originalSkills) { + skl.value = Math.max(skl.value, originalSkills[id].value); + } + + // Compute modifier + skl.bonus = checkBonus + skillBonus; + skl.mod = data.abilities[skl.ability].mod; + skl.prof = round(skl.value * data.attributes.prof); + skl.total = skl.mod + skl.prof + skl.bonus; + + // Compute passive bonus + const passive = observant && feats.observantFeat.skills.includes(id) ? 5 : 0; + skl.passive = 10 + skl.total + passive; + } + } + + /* -------------------------------------------- */ + + /** + * Prepare data related to the power-casting capabilities of the Actor + * @private + */ + _computeBasePowercasting(actorData) { + if (actorData.type === "vehicle" || actorData.type === "starship") return; + const powers = actorData.data.powers; + const isNPC = actorData.type === "npc"; + + // Translate the list of classes into force and tech power-casting progression + const forceProgression = { + classes: 0, + levels: 0, + multi: 0, + maxClass: "none", + maxClassPriority: 0, + maxClassLevels: 0, + maxClassPowerLevel: 0, + powersKnown: 0, + points: 0 + }; + const techProgression = { + classes: 0, + levels: 0, + multi: 0, + maxClass: "none", + maxClassPriority: 0, + maxClassLevels: 0, + maxClassPowerLevel: 0, + powersKnown: 0, + points: 0 + }; + + // Tabulate the total power-casting progression + const classes = this.data.items.filter((i) => i.type === "class"); + let priority = 0; + for (let cls of classes) { + const d = cls.data; + if (d.powercasting === "none") continue; + const levels = d.levels; + const prog = d.powercasting; + + switch (prog) { + case "consular": + priority = 3; + forceProgression.levels += levels; + forceProgression.multi += (SW5E.powerMaxLevel["consular"][19] / 9) * levels; + forceProgression.classes++; + // see if class controls high level forcecasting + if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) { + forceProgression.maxClass = "consular"; + forceProgression.maxClassLevels = levels; + forceProgression.maxClassPriority = priority; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["consular"][Math.clamped(levels - 1, 0, 20)]; + } + // calculate points and powers known + forceProgression.powersKnown += SW5E.powersKnown["consular"][Math.clamped(levels - 1, 0, 20)]; + forceProgression.points += SW5E.powerPoints["consular"][Math.clamped(levels - 1, 0, 20)]; + break; + case "engineer": + priority = 2; + techProgression.levels += levels; + techProgression.multi += (SW5E.powerMaxLevel["engineer"][19] / 9) * levels; + techProgression.classes++; + // see if class controls high level techcasting + if (levels >= techProgression.maxClassLevels && priority > techProgression.maxClassPriority) { + techProgression.maxClass = "engineer"; + techProgression.maxClassLevels = levels; + techProgression.maxClassPriority = priority; + techProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["engineer"][Math.clamped(levels - 1, 0, 20)]; + } + techProgression.powersKnown += SW5E.powersKnown["engineer"][Math.clamped(levels - 1, 0, 20)]; + techProgression.points += SW5E.powerPoints["engineer"][Math.clamped(levels - 1, 0, 20)]; + break; + case "guardian": + priority = 1; + forceProgression.levels += levels; + forceProgression.multi += (SW5E.powerMaxLevel["guardian"][19] / 9) * levels; + forceProgression.classes++; + // see if class controls high level forcecasting + if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) { + forceProgression.maxClass = "guardian"; + forceProgression.maxClassLevels = levels; + forceProgression.maxClassPriority = priority; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["guardian"][Math.clamped(levels - 1, 0, 20)]; + } + forceProgression.powersKnown += SW5E.powersKnown["guardian"][Math.clamped(levels - 1, 0, 20)]; + forceProgression.points += SW5E.powerPoints["guardian"][Math.clamped(levels - 1, 0, 20)]; + break; + case "scout": + priority = 1; + techProgression.levels += levels; + techProgression.multi += (SW5E.powerMaxLevel["scout"][19] / 9) * levels; + techProgression.classes++; + // see if class controls high level techcasting + if (levels >= techProgression.maxClassLevels && priority > techProgression.maxClassPriority) { + techProgression.maxClass = "scout"; + techProgression.maxClassLevels = levels; + techProgression.maxClassPriority = priority; + techProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["scout"][Math.clamped(levels - 1, 0, 20)]; + } + techProgression.powersKnown += SW5E.powersKnown["scout"][Math.clamped(levels - 1, 0, 20)]; + techProgression.points += SW5E.powerPoints["scout"][Math.clamped(levels - 1, 0, 20)]; + break; + case "sentinel": + priority = 2; + forceProgression.levels += levels; + forceProgression.multi += (SW5E.powerMaxLevel["sentinel"][19] / 9) * levels; + forceProgression.classes++; + // see if class controls high level forcecasting + if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) { + forceProgression.maxClass = "sentinel"; + forceProgression.maxClassLevels = levels; + forceProgression.maxClassPriority = priority; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel["sentinel"][Math.clamped(levels - 1, 0, 20)]; + } + forceProgression.powersKnown += SW5E.powersKnown["sentinel"][Math.clamped(levels - 1, 0, 20)]; + forceProgression.points += SW5E.powerPoints["sentinel"][Math.clamped(levels - 1, 0, 20)]; + break; + } + } + + // EXCEPTION: multi-classed progression uses multi rounded down rather than levels + if (!isNPC && forceProgression.classes > 1) { + forceProgression.levels = Math.floor(forceProgression.multi); + forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel["multi"][forceProgression.levels - 1]; + } + if (!isNPC && techProgression.classes > 1) { + techProgression.levels = Math.floor(techProgression.multi); + techProgression.maxClassPowerLevel = SW5E.powerMaxLevel["multi"][techProgression.levels - 1]; + } + + // EXCEPTION: NPC with an explicit power-caster level + if (isNPC && actorData.data.details.powerForceLevel) { + forceProgression.levels = actorData.data.details.powerForceLevel; + actorData.data.attributes.force.level = forceProgression.levels; + forceProgression.maxClass = actorData.data.attributes.powercasting; + forceProgression.maxClassPowerLevel = + SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped(forceProgression.levels - 1, 0, 20)]; + } + if (isNPC && actorData.data.details.powerTechLevel) { + techProgression.levels = actorData.data.details.powerTechLevel; + actorData.data.attributes.tech.level = techProgression.levels; + techProgression.maxClass = actorData.data.attributes.powercasting; + techProgression.maxClassPowerLevel = + SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped(techProgression.levels - 1, 0, 20)]; + } + + // Look up the number of slots per level from the powerLimit table + let forcePowerLimit = Array.from(SW5E.powerLimit["none"]); + for (let i = 0; i < forceProgression.maxClassPowerLevel; i++) { + forcePowerLimit[i] = SW5E.powerLimit[forceProgression.maxClass][i]; + } + + for (let [n, lvl] of Object.entries(powers)) { + let i = parseInt(n.slice(-1)); + if (Number.isNaN(i)) continue; + if (Number.isNumeric(lvl.foverride)) lvl.fmax = Math.max(parseInt(lvl.foverride), 0); + else lvl.fmax = forcePowerLimit[i - 1] || 0; + if (isNPC) { + lvl.fvalue = lvl.fmax; + } else { + lvl.fvalue = Math.min(parseInt(lvl.fvalue || lvl.value || lvl.fmax), lvl.fmax); + } + } + + let techPowerLimit = Array.from(SW5E.powerLimit["none"]); + for (let i = 0; i < techProgression.maxClassPowerLevel; i++) { + techPowerLimit[i] = SW5E.powerLimit[techProgression.maxClass][i]; + } + + for (let [n, lvl] of Object.entries(powers)) { + let i = parseInt(n.slice(-1)); + if (Number.isNaN(i)) continue; + if (Number.isNumeric(lvl.toverride)) lvl.tmax = Math.max(parseInt(lvl.toverride), 0); + else lvl.tmax = techPowerLimit[i - 1] || 0; + if (isNPC) { + lvl.tvalue = lvl.tmax; + } else { + lvl.tvalue = Math.min(parseInt(lvl.tvalue || lvl.value || lvl.tmax), lvl.tmax); + } + } + + // Set Force and tech power for PC Actors + if (!isNPC && forceProgression.levels) { + actorData.data.attributes.force.known.max = forceProgression.powersKnown; + actorData.data.attributes.force.points.max = forceProgression.points; // + Math.max(actorData.data.abilities.wis.mod,actorData.data.abilities.cha.mod); + actorData.data.attributes.force.level = forceProgression.levels; + } + if (!isNPC && techProgression.levels) { + actorData.data.attributes.tech.known.max = techProgression.powersKnown; + actorData.data.attributes.tech.points.max = techProgression.points; // + actorData.data.abilities.int.mod; + actorData.data.attributes.tech.level = techProgression.levels; + } + + // Tally Powers Known and check for migration first to avoid errors + let hasKnownPowers = actorData?.data?.attributes?.force?.known?.value !== undefined; + if (hasKnownPowers) { + const knownPowers = this.data.items.filter((i) => i.type === "power"); + let knownForcePowers = 0; + let knownTechPowers = 0; + for (let knownPower of knownPowers) { + const d = knownPower.data; + switch (knownPower.data.school) { + case "lgt": + case "uni": + case "drk": { + knownForcePowers++; + break; + } + case "tec": { + knownTechPowers++; + break; + } + } + continue; + } + actorData.data.attributes.force.known.value = knownForcePowers; + actorData.data.attributes.tech.known.value = knownTechPowers; + } + } + + /* -------------------------------------------- */ + + /** + * Prepare data related to the power-casting capabilities of the Actor + * @private + */ + _computeDerivedPowercasting(actorData) { + if (actorData.type !== "actor") return; + + // Set Force and tech power for PC Actors + if (!!actorData.data.attributes.force.level) { + actorData.data.attributes.force.points.max += Math.max( + actorData.data.abilities.wis.mod, + actorData.data.abilities.cha.mod + ); + } + if (!!actorData.data.attributes.tech.level) { + actorData.data.attributes.tech.points.max += actorData.data.abilities.int.mod; + } + } + + /* -------------------------------------------- */ + + /** + * Compute the level and percentage of encumbrance for an Actor. + * + * Optionally include the weight of carried currency across all denominations by applying the standard rule + * from the PHB pg. 143 + * @param {Object} actorData The data object for the Actor being rendered + * @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level + * @private + */ + _computeEncumbrance(actorData) { + // Get the total weight from items + const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"]; + let weight = actorData.items.reduce((weight, i) => { + if (!physicalItems.includes(i.type)) return weight; + const q = i.data.quantity || 0; + const w = i.data.weight || 0; + return weight + q * w; + }, 0); + + // [Optional] add Currency Weight (for non-transformed actors) + if (game.settings.get("sw5e", "currencyWeight") && actorData.data.currency) { + const currency = actorData.data.currency; + const numCoins = Object.values(currency).reduce((val, denom) => (val += Math.max(denom, 0)), 0); + weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; + } + + // Determine the encumbrance size class + let mod = + { + tiny: 0.5, + sm: 1, + med: 1, + lg: 2, + huge: 4, + grg: 8 + }[actorData.data.traits.size] || 1; + if (this.getFlag("sw5e", "powerfulBuild")) mod = Math.min(mod * 2, 8); + + // Compute Encumbrance percentage + weight = weight.toNearest(0.1); + const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod; + const pct = Math.clamped((weight * 100) / max, 0, 100); + return {value: weight.toNearest(0.1), max, pct, encumbered: pct > 2 / 3}; + } + + _computeFuel(actorData) { + const fuel = actorData.data.attributes.fuel; + // Compute Fuel percentage + const pct = Math.clamped((fuel.value.toNearest(0.1) * 100) / fuel.cap, 0, 100); + return {...fuel, pct, fueled: pct > 0}; + } + + /* -------------------------------------------- */ + /* Socket Listeners and Handlers + /* -------------------------------------------- */ + + /** @override */ + static async create(data, options = {}) { + data.token = data.token || {}; + if (data.type === "character") { + mergeObject( + data.token, + { + vision: true, + dimSight: 30, + brightSight: 0, + actorLink: true, + disposition: 1 + }, + {overwrite: false} + ); + } + return super.create(data, options); + } + + /* -------------------------------------------- */ + + /** @override */ + async update(data, options = {}) { + // Apply changes in Actor size to Token width/height + const newSize = getProperty(data, "data.traits.size"); + if (newSize && newSize !== getProperty(this.data, "data.traits.size")) { + let size = CONFIG.SW5E.tokenSizes[newSize]; + if (this.isToken) this.token.update({height: size, width: size}); + else if (!data["token.width"] && !hasProperty(data, "token.width")) { + data["token.height"] = size; + data["token.width"] = size; + } + } + + // Reset death save counters + if (this.data.data.attributes.hp.value <= 0 && getProperty(data, "data.attributes.hp.value") > 0) { + setProperty(data, "data.attributes.death.success", 0); + setProperty(data, "data.attributes.death.failure", 0); + } + + // Perform the update + return super.update(data, options); + } + + /* -------------------------------------------- */ + + /** @override */ + async createEmbeddedEntity(embeddedName, itemData, options = {}) { + // Pre-creation steps for owned items + if (embeddedName === "OwnedItem") this._preCreateOwnedItem(itemData, options); + + // Standard embedded entity creation + return super.createEmbeddedEntity(embeddedName, itemData, options); + } + + /* -------------------------------------------- */ + + /** + * A temporary shim function which will eventually (in core fvtt version 0.8.0+) be migrated to the new abstraction layer + * @param itemData + * @param options + * @private + */ + _preCreateOwnedItem(itemData, options) { + if (this.data.type === "vehicle") return; + const isNPC = this.data.type === "npc"; + let initial = {}; + switch (itemData.type) { + case "weapon": + if (getProperty(itemData, "data.equipped") === undefined) { + initial["data.equipped"] = isNPC; // NPCs automatically equip weapons + } + if (getProperty(itemData, "data.proficient") === undefined) { + if (isNPC) { + initial["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + const weaponProf = { + natural: true, + simpleVW: "sim", + simpleB: "sim", + simpleLW: "sim", + martialVW: "mar", + martialB: "mar", + martialLW: "mar" + }[itemData.data?.weaponType]; // Player characters check proficiency + const actorWeaponProfs = this.data.data.traits?.weaponProf?.value || []; + const hasWeaponProf = weaponProf === true || actorWeaponProfs.includes(weaponProf); + initial["data.proficient"] = hasWeaponProf; + } + } + break; + + case "equipment": + if (getProperty(itemData, "data.equipped") === undefined) { + initial["data.equipped"] = isNPC; // NPCs automatically equip equipment + } + if (getProperty(itemData, "data.proficient") === undefined) { + if (isNPC) { + initial["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + const armorProf = { + natural: true, + clothing: true, + light: "lgt", + medium: "med", + heavy: "hvy", + shield: "shl" + }[itemData.data?.armor?.type]; // Player characters check proficiency + const actorArmorProfs = this.data.data.traits?.armorProf?.value || []; + const hasEquipmentProf = armorProf === true || actorArmorProfs.includes(armorProf); + initial["data.proficient"] = hasEquipmentProf; + } + } + break; + + case "power": + initial["data.prepared"] = true; // automatically prepare powers for everyone + break; + } + mergeObject(itemData, initial); + } + + /* -------------------------------------------- */ + /* Gameplay Mechanics */ + /* -------------------------------------------- */ + + /** @override */ + async modifyTokenAttribute(attribute, value, isDelta, isBar) { + if (attribute === "attributes.hp") { + const hp = getProperty(this.data.data, attribute); + const delta = isDelta ? -1 * value : hp.value + hp.temp - value; + return this.applyDamage(delta); + } + return super.modifyTokenAttribute(attribute, value, isDelta, isBar); + } + + /* -------------------------------------------- */ + + /** + * Apply a certain amount of damage or healing to the health pool for Actor + * @param {number} amount An amount of damage (positive) or healing (negative) to sustain + * @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing + * @return {Promise} A Promise which resolves once the damage has been applied + */ + async applyDamage(amount = 0, multiplier = 1) { + amount = Math.floor(parseInt(amount) * multiplier); + const hp = this.data.data.attributes.hp; + + // Deduct damage from temp HP first + const tmp = parseInt(hp.temp) || 0; + const dt = amount > 0 ? Math.min(tmp, amount) : 0; + + // Remaining goes to health + const tmpMax = parseInt(hp.tempmax) || 0; + const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax); + + // Update the Actor + const updates = { + "data.attributes.hp.temp": tmp - dt, + "data.attributes.hp.value": dh + }; + + // Delegate damage application to a hook + // TODO replace this in the future with a better modifyTokenAttribute function in the core + const allowed = Hooks.call( + "modifyTokenAttribute", + { + attribute: "attributes.hp", + value: amount, + isDelta: false, + isBar: true + }, + updates + ); + return allowed !== false ? this.update(updates) : this; + } + + /* -------------------------------------------- */ + + /** + * Roll a Skill Check + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {string} skillId The skill id (e.g. "ins") + * @param {Object} options Options which configure how the skill check is rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollSkill(skillId, options = {}) { + const skl = this.data.data.skills[skillId]; + const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + + // Compose roll parts and data + const parts = ["@mod"]; + const data = {mod: skl.mod + skl.prof}; + + // Ability test bonus + if (bonuses.check) { + data["checkBonus"] = bonuses.check; + parts.push("@checkBonus"); + } + + // Skill check bonus + if (bonuses.skill) { + data["skillBonus"] = bonuses.skill; + parts.push("@skillBonus"); + } + + // Add provided extra roll parts now because they will get clobbered by mergeObject below + if (options.parts?.length > 0) { + parts.push(...options.parts); + } + + // Reliable Talent applies to any skill check we have full or better proficiency in + const reliableTalent = skl.value >= 1 && this.getFlag("sw5e", "reliableTalent"); + + // Roll and return + const rollData = mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.format("SW5E.SkillPromptTitle", { + skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId] + }), + halflingLucky: this.getFlag("sw5e", "halflingLucky"), + reliableTalent: reliableTalent, + messageData: {"flags.sw5e.roll": {type: "skill", skillId}} }); - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveSuccess", {name: this.name}), speaker}); - } - - // Increment successes - else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)}); + rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); + return d20Roll(rollData); } - // Save failure - else { - let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1); - await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)}); - if ( failures >= 3 ) { // 3 Failures = death - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveFailure", {name: this.name}), speaker}); - } + /* -------------------------------------------- */ + + /** + * Roll a generic ability test or saving throw. + * Prompt the user for input on which variety of roll they want to do. + * @param {String}abilityId The ability id (e.g. "str") + * @param {Object} options Options which configure how ability tests or saving throws are rolled + */ + rollAbility(abilityId, options = {}) { + const label = CONFIG.SW5E.abilities[abilityId]; + new Dialog({ + title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), + content: `

${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}

`, + buttons: { + test: { + label: game.i18n.localize("SW5E.ActionAbil"), + callback: () => this.rollAbilityTest(abilityId, options) + }, + save: { + label: game.i18n.localize("SW5E.ActionSave"), + callback: () => this.rollAbilitySave(abilityId, options) + } + } + }).render(true); } - // Return the rolled result - return roll; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Roll an Ability Test + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {String} abilityId The ability ID (e.g. "str") + * @param {Object} options Options which configure how ability tests are rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollAbilityTest(abilityId, options = {}) { + const label = CONFIG.SW5E.abilities[abilityId]; + const abl = this.data.data.abilities[abilityId]; - /** - * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier - * @param {string} [denomination] The hit denomination of hit die to roll. Example "d8". - * If no denomination is provided, the first available HD will be used - * @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll? - * @return {Promise} The created Roll instance, or null if no hit die was rolled - */ - async rollHitDie(denomination, {dialog=true}={}) { + // Construct parts + const parts = ["@mod"]; + const data = {mod: abl.mod}; - // If no denomination was provided, choose the first available - let cls = null; - if ( !denomination ) { - cls = this.itemTypes.class.find(c => c.data.data.hitDiceUsed < c.data.data.levels); - if ( !cls ) return null; - denomination = cls.data.data.hitDice; + // Add feat-related proficiency bonuses + const feats = this.data.flags.sw5e || {}; + if (feats.remarkableAthlete && SW5E.characterFlags.remarkableAthlete.abilities.includes(abilityId)) { + parts.push("@proficiency"); + data.proficiency = Math.ceil(0.5 * this.data.data.attributes.prof); + } else if (feats.jackOfAllTrades) { + parts.push("@proficiency"); + data.proficiency = Math.floor(0.5 * this.data.data.attributes.prof); + } + + // Add global actor bonus + const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + if (bonuses.check) { + parts.push("@checkBonus"); + data.checkBonus = bonuses.check; + } + + // Add provided extra roll parts now because they will get clobbered by mergeObject below + if (options.parts?.length > 0) { + parts.push(...options.parts); + } + + // Roll and return + const rollData = mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), + halflingLucky: feats.halflingLucky, + messageData: {"flags.sw5e.roll": {type: "ability", abilityId}} + }); + rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); + return d20Roll(rollData); } - // Otherwise locate a class (if any) which has an available hit die of the requested denomination - else { - cls = this.items.find(i => { - const d = i.data.data; - return (d.hitDice === denomination) && ((d.hitDiceUsed || 0) < (d.levels || 1)); - }); + /* -------------------------------------------- */ + + /** + * Roll an Ability Saving Throw + * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus + * @param {String} abilityId The ability ID (e.g. "str") + * @param {Object} options Options which configure how ability tests are rolled + * @return {Promise} A Promise which resolves to the created Roll instance + */ + rollAbilitySave(abilityId, options = {}) { + const label = CONFIG.SW5E.abilities[abilityId]; + const abl = this.data.data.abilities[abilityId]; + + // Construct parts + const parts = ["@mod"]; + const data = {mod: abl.mod}; + + // Include proficiency bonus + if (abl.prof > 0) { + parts.push("@prof"); + data.prof = abl.prof; + } + + // Include a global actor ability save bonus + const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + if (bonuses.save) { + parts.push("@saveBonus"); + data.saveBonus = bonuses.save; + } + + // Add provided extra roll parts now because they will get clobbered by mergeObject below + if (options.parts?.length > 0) { + parts.push(...options.parts); + } + + // Roll and return + const rollData = mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}), + halflingLucky: this.getFlag("sw5e", "halflingLucky"), + messageData: {"flags.sw5e.roll": {type: "save", abilityId}} + }); + rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); + return d20Roll(rollData); } - // If no class is available, display an error notification - if ( !cls ) { - ui.notifications.error(game.i18n.format("SW5E.HitDiceWarn", {name: this.name, formula: denomination})); - return null; + /* -------------------------------------------- */ + + /** + * Perform a death saving throw, rolling a d20 plus any global save bonuses + * @param {Object} options Additional options which modify the roll + * @return {Promise} A Promise which resolves to the Roll instance + */ + async rollDeathSave(options = {}) { + // Display a warning if we are not at zero HP or if we already have reached 3 + const death = this.data.data.attributes.death; + if (this.data.data.attributes.hp.value > 0 || death.failure >= 3 || death.success >= 3) { + ui.notifications.warn(game.i18n.localize("SW5E.DeathSaveUnnecessary")); + return null; + } + + // Evaluate a global saving throw bonus + const parts = []; + const data = {}; + const speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); + + // Include a global actor ability save bonus + const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + if (bonuses.save) { + parts.push("@saveBonus"); + data.saveBonus = bonuses.save; + } + + // Evaluate the roll + const rollData = mergeObject(options, { + parts: parts, + data: data, + title: game.i18n.localize("SW5E.DeathSavingThrow"), + speaker: speaker, + halflingLucky: this.getFlag("sw5e", "halflingLucky"), + targetValue: 10, + messageData: {"flags.sw5e.roll": {type: "death"}} + }); + rollData.speaker = speaker; + const roll = await d20Roll(rollData); + if (!roll) return null; + + // Take action depending on the result + const success = roll.total >= 10; + const d20 = roll.dice[0].total; + + // Save success + if (success) { + let successes = (death.success || 0) + 1; + + // Critical Success = revive with 1hp + if (d20 === 20) { + await this.update({ + "data.attributes.death.success": 0, + "data.attributes.death.failure": 0, + "data.attributes.hp.value": 1 + }); + await ChatMessage.create({ + content: game.i18n.format("SW5E.DeathSaveCriticalSuccess", {name: this.name}), + speaker + }); + } + + // 3 Successes = survive and reset checks + else if (successes === 3) { + await this.update({ + "data.attributes.death.success": 0, + "data.attributes.death.failure": 0 + }); + await ChatMessage.create({ + content: game.i18n.format("SW5E.DeathSaveSuccess", {name: this.name}), + speaker + }); + } + + // Increment successes + else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)}); + } + + // Save failure + else { + let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1); + await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)}); + if (failures >= 3) { + // 3 Failures = death + await ChatMessage.create({ + content: game.i18n.format("SW5E.DeathSaveFailure", {name: this.name}), + speaker + }); + } + } + + // Return the rolled result + return roll; } - // Prepare roll data - const parts = [`1${denomination}`, "@abilities.con.mod"]; - const title = game.i18n.localize("SW5E.HitDiceRoll"); - const rollData = duplicate(this.data.data); + /* -------------------------------------------- */ - // Call the roll helper utility - const roll = await damageRoll({ - event: new Event("hitDie"), - parts: parts, - data: rollData, - title: title, - speaker: ChatMessage.getSpeaker({actor: this}), - allowcritical: false, - fastForward: !dialog, - dialogOptions: {width: 350}, - messageData: {"flags.sw5e.roll": {type: "hitDie"}} - }); - if ( !roll ) return null; + /** + * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier + * @param {string} [denomination] The hit denomination of hit die to roll. Example "d8". + * If no denomination is provided, the first available HD will be used + * @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll? + * @return {Promise} The created Roll instance, or null if no hit die was rolled + */ + async rollHitDie(denomination, {dialog = true} = {}) { + // If no denomination was provided, choose the first available + let cls = null; + if (!denomination) { + cls = this.itemTypes.class.find((c) => c.data.data.hitDiceUsed < c.data.data.levels); + if (!cls) return null; + denomination = cls.data.data.hitDice; + } - // Adjust actor data - await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1}); - const hp = this.data.data.attributes.hp; - const dhp = Math.min(hp.max + (hp.tempmax ?? 0) - hp.value, roll.total); - await this.update({"data.attributes.hp.value": hp.value + dhp}); - return roll; - } + // Otherwise locate a class (if any) which has an available hit die of the requested denomination + else { + cls = this.items.find((i) => { + const d = i.data.data; + return d.hitDice === denomination && (d.hitDiceUsed || 0) < (d.levels || 1); + }); + } - /* -------------------------------------------- */ + // If no class is available, display an error notification + if (!cls) { + ui.notifications.error(game.i18n.format("SW5E.HitDiceWarn", {name: this.name, formula: denomination})); + return null; + } - /** - * Roll a hull die of the appropriate type, gaining hull points equal to the die roll plus your CON modifier - * @param {string} [denomination] The hit denomination of hull die to roll. Example "d8". - * If no denomination is provided, the first available HD will be used - * @param {string} [numDice] How many damage dice to roll? - * @param {string} [keep] Which dice to keep? Example "kh1". - * @param {boolean} [dialog] Show a dialog prompt for configuring the hull die roll? - * @return {Promise} The created Roll instance, or null if no hull die was rolled - */ - async rollHullDie(denomination, numDice="1", keep="",{dialog=true}={}) { + // Prepare roll data + const parts = [`1${denomination}`, "@abilities.con.mod"]; + const title = game.i18n.localize("SW5E.HitDiceRoll"); + const rollData = duplicate(this.data.data); - // If no denomination was provided, choose the first available - let sship = null; - if ( !denomination ) { - sship = this.itemTypes.class.find(s => s.data.data.hullDiceUsed < (s.data.data.tier + s.data.data.hullDiceStart)); - if ( !sship ) return null; - denomination = sship.data.data.hullDice; + // Call the roll helper utility + const roll = await damageRoll({ + event: new Event("hitDie"), + parts: parts, + data: rollData, + title: title, + speaker: ChatMessage.getSpeaker({actor: this}), + allowcritical: false, + fastForward: !dialog, + dialogOptions: {width: 350}, + messageData: {"flags.sw5e.roll": {type: "hitDie"}} + }); + if (!roll) return null; + + // Adjust actor data + await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1}); + const hp = this.data.data.attributes.hp; + const dhp = Math.min(hp.max + (hp.tempmax ?? 0) - hp.value, roll.total); + await this.update({"data.attributes.hp.value": hp.value + dhp}); + return roll; } - // Otherwise locate a starship (if any) which has an available hit die of the requested denomination - else { - sship = this.items.find(i => { - const d = i.data.data; - return (d.hullDice === denomination) && ((d.hitDiceUsed || 0) < ((d.tier || 0) + d.hullDiceStart)); - }); + /* -------------------------------------------- */ + + /** + * Roll a hull die of the appropriate type, gaining hull points equal to the die roll plus your CON modifier + * @param {string} [denomination] The hit denomination of hull die to roll. Example "d8". + * If no denomination is provided, the first available HD will be used + * @param {string} [numDice] How many damage dice to roll? + * @param {string} [keep] Which dice to keep? Example "kh1". + * @param {boolean} [dialog] Show a dialog prompt for configuring the hull die roll? + * @return {Promise} The created Roll instance, or null if no hull die was rolled + */ + async rollHullDie(denomination, numDice = "1", keep = "", {dialog = true} = {}) { + // If no denomination was provided, choose the first available + let sship = null; + if (!denomination) { + sship = this.itemTypes.class.find( + (s) => s.data.data.hullDiceUsed < s.data.data.tier + s.data.data.hullDiceStart + ); + if (!sship) return null; + denomination = sship.data.data.hullDice; + } + + // Otherwise locate a starship (if any) which has an available hit die of the requested denomination + else { + sship = this.items.find((i) => { + const d = i.data.data; + return d.hullDice === denomination && (d.hitDiceUsed || 0) < (d.tier || 0) + d.hullDiceStart; + }); + } + + // If no class is available, display an error notification + if (!sship) { + ui.notifications.error(game.i18n.format("SW5E.HullDiceWarn", {name: this.name, formula: denomination})); + return null; + } + + // Prepare roll data + const parts = [`${numDice}${denomination}${keep}`, "@abilities.con.mod"]; + const title = game.i18n.localize("SW5E.HullDiceRoll"); + const rollData = duplicate(this.data.data); + + // Call the roll helper utility + const roll = await damageRoll({ + event: new Event("hitDie"), + parts: parts, + data: rollData, + title: title, + speaker: ChatMessage.getSpeaker({actor: this}), + allowcritical: false, + fastForward: !dialog, + dialogOptions: {width: 350}, + messageData: {"flags.sw5e.roll": {type: "hullDie"}} + }); + if (!roll) return null; + + // Adjust actor data + await sship.update({"data.hullDiceUsed": sship.data.data.hullDiceUsed + 1}); + const hp = this.data.data.attributes.hp; + const dhp = Math.min(hp.max - hp.value, roll.total); + await this.update({"data.attributes.hp.value": hp.value + dhp}); + return roll; } - // If no class is available, display an error notification - if ( !sship ) { - ui.notifications.error(game.i18n.format("SW5E.HullDiceWarn", {name: this.name, formula: denomination})); - return null; - } + /* -------------------------------------------- */ - // Prepare roll data - const parts = [`${numDice}${denomination}${keep}`, "@abilities.con.mod"]; - const title = game.i18n.localize("SW5E.HullDiceRoll"); - const rollData = duplicate(this.data.data); - - // Call the roll helper utility - const roll = await damageRoll({ - event: new Event("hitDie"), - parts: parts, - data: rollData, - title: title, - speaker: ChatMessage.getSpeaker({actor: this}), - allowcritical: false, - fastForward: !dialog, - dialogOptions: {width: 350}, - messageData: {"flags.sw5e.roll": {type: "hullDie"}} - }); - if ( !roll ) return null; - - // Adjust actor data - await sship.update({"data.hullDiceUsed": sship.data.data.hullDiceUsed + 1}); - const hp = this.data.data.attributes.hp; - const dhp = Math.min(hp.max - hp.value, roll.total); - await this.update({"data.attributes.hp.value": hp.value + dhp}); - return roll; - } - - /* -------------------------------------------- */ - - /** - * Roll a hull die of the appropriate type, gaining hull points equal to the die roll plus your CON modifier - * @return {Promise} The created Roll instance, or null if no hull die was rolled - */ + /** + * Roll a hull die of the appropriate type, gaining hull points equal to the die roll plus your CON modifier + * @return {Promise} The created Roll instance, or null if no hull die was rolled + */ async rollHullDieCheck() { + // If no denomination was provided, choose the first available + let sship = null; + if (!denomination) { + sship = this.itemTypes.class.find( + (s) => s.data.data.hullDiceUsed < s.data.data.tier + s.data.data.hullDiceStart + ); + if (!sship) return null; + denomination = sship.data.data.hullDice; + } - // If no denomination was provided, choose the first available - let sship = null; - if ( !denomination ) { - sship = this.itemTypes.class.find(s => s.data.data.hullDiceUsed < (s.data.data.tier + s.data.data.hullDiceStart)); - if ( !sship ) return null; - denomination = sship.data.data.hullDice; - } - - // Otherwise locate a starship (if any) which has an available hit die of the requested denomination - else { - sship = this.items.find(i => { - const d = i.data.data; - return (d.hullDice === denomination) && ((d.hitDiceUsed || 0) < ((d.tier || 0) + d.hullDiceStart)); + // Otherwise locate a starship (if any) which has an available hit die of the requested denomination + else { + sship = this.items.find((i) => { + const d = i.data.data; + return d.hullDice === denomination && (d.hitDiceUsed || 0) < (d.tier || 0) + d.hullDiceStart; + }); + } + + // If no class is available, display an error notification + if (!sship) { + ui.notifications.error(game.i18n.format("SW5E.HullDiceWarn", {name: this.name, formula: denomination})); + return null; + } + + // Prepare roll data + const parts = [`${numDice}${denomination}${keep}`, "@abilities.con.mod"]; + const title = game.i18n.localize("SW5E.HullDiceRoll"); + const rollData = duplicate(this.data.data); + + // Call the roll helper utility + const roll = await damageRoll({ + event: new Event("hitDie"), + parts: parts, + data: rollData, + title: title, + speaker: ChatMessage.getSpeaker({actor: this}), + allowcritical: false, + fastForward: !dialog, + dialogOptions: {width: 350}, + messageData: {"flags.sw5e.roll": {type: "hullDie"}} }); - } - - // If no class is available, display an error notification - if ( !sship ) { - ui.notifications.error(game.i18n.format("SW5E.HullDiceWarn", {name: this.name, formula: denomination})); - return null; - } - - // Prepare roll data - const parts = [`${numDice}${denomination}${keep}`, "@abilities.con.mod"]; - const title = game.i18n.localize("SW5E.HullDiceRoll"); - const rollData = duplicate(this.data.data); - - // Call the roll helper utility - const roll = await damageRoll({ - event: new Event("hitDie"), - parts: parts, - data: rollData, - title: title, - speaker: ChatMessage.getSpeaker({actor: this}), - allowcritical: false, - fastForward: !dialog, - dialogOptions: {width: 350}, - messageData: {"flags.sw5e.roll": {type: "hullDie"}} - }); - if ( !roll ) return null; - - // Adjust actor data - await sship.update({"data.hullDiceUsed": sship.data.data.hullDiceUsed + 1}); - const hp = this.data.data.attributes.hp; - const dhp = Math.min(hp.max - hp.value, roll.total); - await this.update({"data.attributes.hp.value": hp.value + dhp}); - return roll; + if (!roll) return null; + + // Adjust actor data + await sship.update({"data.hullDiceUsed": sship.data.data.hullDiceUsed + 1}); + const hp = this.data.data.attributes.hp; + const dhp = Math.min(hp.max - hp.value, roll.total); + await this.update({"data.attributes.hp.value": hp.value + dhp}); + return roll; } - + /* -------------------------------------------- */ - /** - * Roll a shield die of the appropriate type, gaining shield points equal to the die roll - * multiplied by the shield regeneration coefficient - * @param {string} [denomination] The denomination of shield die to roll. Example "d8". - * If no denomination is provided, the first available SD will be used - * @param {boolean} [natural] Natural ship shield regeneration (true) or user action (false)? - * @param {string} [numDice] How many damage dice to roll? - * @param {string} [keep] Which dice to keep? Example "kh1". - * @param {boolean} [dialog] Show a dialog prompt for configuring the shield die roll? - * @return {Promise} The created Roll instance, or null if no shield die was rolled - */ - async rollShieldDie(denomination, natural=false, numDice="1", keep="", {dialog=true}={}) { - - // If no denomination was provided, choose the first available - let sship = null; - if ( !denomination ) { - sship = this.itemTypes.class.find(s => s.data.data.shldDiceUsed < (s.data.data.tier + s.data.data.shldDiceStart)); - if ( !sship ) return null; - denomination = sship.data.data.shldDice; - } - - // Otherwise locate a starship (if any) which has an available hit die of the requested denomination - else { - sship = this.items.find(i => { - const d = i.data.data; - return (d.shldDice === denomination) && ((d.shldDiceUsed || 0) < ((d.tier || 0) + d.shldDiceStart)); - }); - } - - // If no starship is available, display an error notification - if ( !sship ) { - ui.notifications.error(game.i18n.format("SW5E.ShldDiceWarn", {name: this.name, formula: denomination})); - return null; - } - - // if natural regeneration roll max - if (natural) { - numdice = denomination.substring(1); - denomination = ""; - keep = ""; - } - - // Prepare roll data - const parts = [`${numDice}${denomination}${keep} * @attributes.regenRate`]; - const title = game.i18n.localize("SW5E.ShieldDiceRoll"); - const rollData = duplicate(this.data.data); - - // Call the roll helper utility - roll = await damageRoll({ - event: new Event("shldDie"), - parts: parts, - data: rollData, - title: title, - speaker: ChatMessage.getSpeaker({actor: this}), - allowcritical: false, - fastForward: !dialog, - dialogOptions: {width: 350}, - messageData: {"flags.sw5e.roll": {type: "shldDie"}} - }); - if ( !roll ) return null; - - // Adjust actor data - await sship.update({"data.shldDiceUsed": sship.data.data.shldDiceUsed + 1}); - const hp = this.data.data.attributes.hp; - const dhp = Math.min(hp.tempmax - hp.temp, roll.total); - await this.update({"data.attributes.hp.temp": hp.temp + dhp}); - return roll; - } - - /* -------------------------------------------- */ - - /** - * Cause this Actor to take a Short Rest and regain all Tech Points - * During a Short Rest resources and limited item uses may be recovered - * @param {boolean} dialog Present a dialog window which allows for rolling hit dice as part of the Short Rest - * @param {boolean} chat Summarize the results of the rest workflow as a chat message - * @param {boolean} autoHD Automatically spend Hit Dice if you are missing 3 or more hit points - * @param {boolean} autoHDThreshold A number of missing hit points which would trigger an automatic HD roll - * @return {Promise} A Promise which resolves once the short rest workflow has completed - */ - async shortRest({dialog=true, chat=true, autoHD=false, autoHDThreshold=3}={}) { - - // Take note of the initial hit points and number of hit dice the Actor has - const hp = this.data.data.attributes.hp; - const hd0 = this.data.data.attributes.hd; - const hp0 = hp.value; - let newDay = false; - - // Display a Dialog for rolling hit dice - if ( dialog ) { - try { - newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0}); - } catch(err) { - return; - } - } - - // Automatically spend hit dice - else if ( autoHD ) { - while ( (hp.value + autoHDThreshold) <= hp.max ) { - const r = await this.rollHitDie(undefined, {dialog: false}); - if ( r === null ) break; - } - } - - // Note the change in HP and HD and TP which occurred - const dhd = this.data.data.attributes.hd - hd0; - const dhp = this.data.data.attributes.hp.value - hp0; - const dtp = this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value; - - // Automatically Retore Tech Points - this.update({"data.attributes.tech.points.value": this.data.data.attributes.tech.points.max}); - - // Recover character resources - const updateData = {}; - for ( let [k, r] of Object.entries(this.data.data.resources) ) { - if ( r.max && r.sr ) { - updateData[`data.resources.${k}.value`] = r.max; - } - } - - // Recover item uses - const recovery = newDay ? ["sr", "day"] : ["sr"]; - const items = this.items.filter(item => item.data.data.uses && recovery.includes(item.data.data.uses.per)); - const updateItems = items.map(item => { - return { - _id: item._id, - "data.uses.value": item.data.data.uses.max - }; - }); - await this.updateEmbeddedEntity("OwnedItem", updateItems); - - // Display a Chat Message summarizing the rest effects - if ( chat ) { - - // Summarize the rest duration - let restFlavor; - switch (game.settings.get("sw5e", "restVariant")) { - case 'normal': restFlavor = game.i18n.localize("SW5E.ShortRestNormal"); break; - case 'gritty': restFlavor = game.i18n.localize(newDay ? "SW5E.ShortRestOvernight" : "SW5E.ShortRestGritty"); break; - case 'epic': restFlavor = game.i18n.localize("SW5E.ShortRestEpic"); break; - } - - // Summarize the health effects - let srMessage = "SW5E.ShortRestResultShort"; - if ((dhd !== 0) && (dhp !== 0)){ - if (dtp !== 0){ - srMessage = "SW5E.ShortRestResultWithTech"; - }else{ - srMessage = "SW5E.ShortRestResult"; + /** + * Roll a shield die of the appropriate type, gaining shield points equal to the die roll + * multiplied by the shield regeneration coefficient + * @param {string} [denomination] The denomination of shield die to roll. Example "d8". + * If no denomination is provided, the first available SD will be used + * @param {boolean} [natural] Natural ship shield regeneration (true) or user action (false)? + * @param {string} [numDice] How many damage dice to roll? + * @param {string} [keep] Which dice to keep? Example "kh1". + * @param {boolean} [dialog] Show a dialog prompt for configuring the shield die roll? + * @return {Promise} The created Roll instance, or null if no shield die was rolled + */ + async rollShieldDie(denomination, natural = false, numDice = "1", keep = "", {dialog = true} = {}) { + // If no denomination was provided, choose the first available + let sship = null; + if (!denomination) { + sship = this.itemTypes.class.find( + (s) => s.data.data.shldDiceUsed < s.data.data.tier + s.data.data.shldDiceStart + ); + if (!sship) return null; + denomination = sship.data.data.shldDice; } - }else{ - if (dtp !== 0){ - srMessage = "SW5E.ShortRestResultOnlyTech"; + + // Otherwise locate a starship (if any) which has an available hit die of the requested denomination + else { + sship = this.items.find((i) => { + const d = i.data.data; + return d.shldDice === denomination && (d.shldDiceUsed || 0) < (d.tier || 0) + d.shldDiceStart; + }); } - } - // Create a chat message - ChatMessage.create({ - user: game.user._id, - speaker: {actor: this, alias: this.name}, - flavor: restFlavor, - content: game.i18n.format(srMessage, {name: this.name, dice: -dhd, health: dhp, tech: dtp}) - }); + // If no starship is available, display an error notification + if (!sship) { + ui.notifications.error(game.i18n.format("SW5E.ShldDiceWarn", {name: this.name, formula: denomination})); + return null; + } + + // if natural regeneration roll max + if (natural) { + numdice = denomination.substring(1); + denomination = ""; + keep = ""; + } + + // Prepare roll data + const parts = [`${numDice}${denomination}${keep} * @attributes.regenRate`]; + const title = game.i18n.localize("SW5E.ShieldDiceRoll"); + const rollData = duplicate(this.data.data); + + // Call the roll helper utility + roll = await damageRoll({ + event: new Event("shldDie"), + parts: parts, + data: rollData, + title: title, + speaker: ChatMessage.getSpeaker({actor: this}), + allowcritical: false, + fastForward: !dialog, + dialogOptions: {width: 350}, + messageData: {"flags.sw5e.roll": {type: "shldDie"}} + }); + if (!roll) return null; + + // Adjust actor data + await sship.update({"data.shldDiceUsed": sship.data.data.shldDiceUsed + 1}); + const hp = this.data.data.attributes.hp; + const dhp = Math.min(hp.tempmax - hp.temp, roll.total); + await this.update({"data.attributes.hp.temp": hp.temp + dhp}); + return roll; } - // Return data summarizing the rest effects - return { - dhd: dhd, - dhp: dhp, - dtp: dtp, - updateData: updateData, - updateItems: updateItems, - newDay: newDay - } - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Cause this Actor to take a Short Rest and regain all Tech Points + * During a Short Rest resources and limited item uses may be recovered + * @param {boolean} dialog Present a dialog window which allows for rolling hit dice as part of the Short Rest + * @param {boolean} chat Summarize the results of the rest workflow as a chat message + * @param {boolean} autoHD Automatically spend Hit Dice if you are missing 3 or more hit points + * @param {boolean} autoHDThreshold A number of missing hit points which would trigger an automatic HD roll + * @return {Promise} A Promise which resolves once the short rest workflow has completed + */ + async shortRest({dialog = true, chat = true, autoHD = false, autoHDThreshold = 3} = {}) { + // Take note of the initial hit points and number of hit dice the Actor has + const hp = this.data.data.attributes.hp; + const hd0 = this.data.data.attributes.hd; + const hp0 = hp.value; + let newDay = false; - /** - * Take a long rest, recovering HP, HD, resources, Force and Power points and power slots - * @param {boolean} dialog Present a confirmation dialog window whether or not to take a long rest - * @param {boolean} chat Summarize the results of the rest workflow as a chat message - * @param {boolean} newDay Whether the long rest carries over to a new day - * @return {Promise} A Promise which resolves once the long rest workflow has completed - */ - async longRest({dialog=true, chat=true, newDay=true}={}) { - const data = this.data.data; + // Display a Dialog for rolling hit dice + if (dialog) { + try { + newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0}); + } catch (err) { + return; + } + } - // Maybe present a confirmation dialog - if ( dialog ) { - try { - newDay = await LongRestDialog.longRestDialog({actor: this}); - } catch(err) { - return; - } + // Automatically spend hit dice + else if (autoHD) { + while (hp.value + autoHDThreshold <= hp.max) { + const r = await this.rollHitDie(undefined, {dialog: false}); + if (r === null) break; + } + } + + // Note the change in HP and HD and TP which occurred + const dhd = this.data.data.attributes.hd - hd0; + const dhp = this.data.data.attributes.hp.value - hp0; + const dtp = this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value; + + // Automatically Retore Tech Points + this.update({"data.attributes.tech.points.value": this.data.data.attributes.tech.points.max}); + + // Recover character resources + const updateData = {}; + for (let [k, r] of Object.entries(this.data.data.resources)) { + if (r.max && r.sr) { + updateData[`data.resources.${k}.value`] = r.max; + } + } + + // Recover item uses + const recovery = newDay ? ["sr", "day"] : ["sr"]; + const items = this.items.filter((item) => item.data.data.uses && recovery.includes(item.data.data.uses.per)); + const updateItems = items.map((item) => { + return { + "_id": item._id, + "data.uses.value": item.data.data.uses.max + }; + }); + await this.updateEmbeddedEntity("OwnedItem", updateItems); + + // Display a Chat Message summarizing the rest effects + if (chat) { + // Summarize the rest duration + let restFlavor; + switch (game.settings.get("sw5e", "restVariant")) { + case "normal": + restFlavor = game.i18n.localize("SW5E.ShortRestNormal"); + break; + case "gritty": + restFlavor = game.i18n.localize(newDay ? "SW5E.ShortRestOvernight" : "SW5E.ShortRestGritty"); + break; + case "epic": + restFlavor = game.i18n.localize("SW5E.ShortRestEpic"); + break; + } + + // Summarize the health effects + let srMessage = "SW5E.ShortRestResultShort"; + if (dhd !== 0 && dhp !== 0) { + if (dtp !== 0) { + srMessage = "SW5E.ShortRestResultWithTech"; + } else { + srMessage = "SW5E.ShortRestResult"; + } + } else { + if (dtp !== 0) { + srMessage = "SW5E.ShortRestResultOnlyTech"; + } + } + + // Create a chat message + ChatMessage.create({ + user: game.user._id, + speaker: {actor: this, alias: this.name}, + flavor: restFlavor, + content: game.i18n.format(srMessage, {name: this.name, dice: -dhd, health: dhp, tech: dtp}) + }); + } + + // Return data summarizing the rest effects + return { + dhd: dhd, + dhp: dhp, + dtp: dtp, + updateData: updateData, + updateItems: updateItems, + newDay: newDay + }; } - // Recover hit, tech, and force points to full, and eliminate any existing temporary HP, TP, and FP - const dhp = data.attributes.hp.max - data.attributes.hp.value; - const dtp = data.attributes.tech.points.max - data.attributes.tech.points.value; - const dfp = data.attributes.force.points.max - data.attributes.force.points.value; - const updateData = { - "data.attributes.hp.value": data.attributes.hp.max, - "data.attributes.hp.temp": 0, - "data.attributes.hp.tempmax": 0, - "data.attributes.tech.points.value": data.attributes.tech.points.max, - "data.attributes.tech.points.temp": 0, - "data.attributes.tech.points.tempmax": 0, - "data.attributes.force.points.value": data.attributes.force.points.max, - "data.attributes.force.points.temp": 0, - "data.attributes.force.points.tempmax": 0 - }; + /* -------------------------------------------- */ - // Recover character resources - for ( let [k, r] of Object.entries(data.resources) ) { - if ( r.max && (r.sr || r.lr) ) { - updateData[`data.resources.${k}.value`] = r.max; - } + /** + * Take a long rest, recovering HP, HD, resources, Force and Power points and power slots + * @param {boolean} dialog Present a confirmation dialog window whether or not to take a long rest + * @param {boolean} chat Summarize the results of the rest workflow as a chat message + * @param {boolean} newDay Whether the long rest carries over to a new day + * @return {Promise} A Promise which resolves once the long rest workflow has completed + */ + async longRest({dialog = true, chat = true, newDay = true} = {}) { + const data = this.data.data; + + // Maybe present a confirmation dialog + if (dialog) { + try { + newDay = await LongRestDialog.longRestDialog({actor: this}); + } catch (err) { + return; + } + } + + // Recover hit, tech, and force points to full, and eliminate any existing temporary HP, TP, and FP + const dhp = data.attributes.hp.max - data.attributes.hp.value; + const dtp = data.attributes.tech.points.max - data.attributes.tech.points.value; + const dfp = data.attributes.force.points.max - data.attributes.force.points.value; + const updateData = { + "data.attributes.hp.value": data.attributes.hp.max, + "data.attributes.hp.temp": 0, + "data.attributes.hp.tempmax": 0, + "data.attributes.tech.points.value": data.attributes.tech.points.max, + "data.attributes.tech.points.temp": 0, + "data.attributes.tech.points.tempmax": 0, + "data.attributes.force.points.value": data.attributes.force.points.max, + "data.attributes.force.points.temp": 0, + "data.attributes.force.points.tempmax": 0 + }; + + // Recover character resources + for (let [k, r] of Object.entries(data.resources)) { + if (r.max && (r.sr || r.lr)) { + updateData[`data.resources.${k}.value`] = r.max; + } + } + + // Recover power slots + for (let [k, v] of Object.entries(data.powers)) { + updateData[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : v.fmax ?? 0; + } + for (let [k, v] of Object.entries(data.powers)) { + updateData[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : v.tmax ?? 0; + } + // Determine the number of hit dice which may be recovered + let recoverHD = Math.max(Math.floor(data.details.level / 2), 1); + let dhd = 0; + + // Sort classes which can recover HD, assuming players prefer recovering larger HD first. + const updateItems = this.items + .filter((item) => item.data.type === "class") + .sort((a, b) => { + let da = parseInt(a.data.data.hitDice.slice(1)) || 0; + let db = parseInt(b.data.data.hitDice.slice(1)) || 0; + return db - da; + }) + .reduce((updates, item) => { + const d = item.data.data; + if (recoverHD > 0 && d.hitDiceUsed > 0) { + let delta = Math.min(d.hitDiceUsed || 0, recoverHD); + recoverHD -= delta; + dhd += delta; + updates.push({"_id": item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); + } + return updates; + }, []); + + // Iterate over owned items, restoring uses per day and recovering Hit Dice + const recovery = newDay ? ["sr", "lr", "day"] : ["sr", "lr"]; + for (let item of this.items) { + const d = item.data.data; + if (d.uses && recovery.includes(d.uses.per)) { + updateItems.push({"_id": item.id, "data.uses.value": d.uses.max}); + } else if (d.recharge && d.recharge.value) { + updateItems.push({"_id": item.id, "data.recharge.charged": true}); + } + } + + // Perform the updates + await this.update(updateData); + if (updateItems.length) await this.updateEmbeddedEntity("OwnedItem", updateItems); + + // Display a Chat Message summarizing the rest effects + let restFlavor; + switch (game.settings.get("sw5e", "restVariant")) { + case "normal": + restFlavor = game.i18n.localize(newDay ? "SW5E.LongRestOvernight" : "SW5E.LongRestNormal"); + break; + case "gritty": + restFlavor = game.i18n.localize("SW5E.LongRestGritty"); + break; + case "epic": + restFlavor = game.i18n.localize("SW5E.LongRestEpic"); + break; + } + + // Determine the chat message to display + if (chat) { + let lrMessage = "SW5E.LongRestResult"; + if (dhp !== 0) lrMessage += "HP"; + if (dfp !== 0) lrMessage += "FP"; + if (dtp !== 0) lrMessage += "TP"; + if (dhd !== 0) lrMessage += "HD"; + ChatMessage.create({ + user: game.user._id, + speaker: {actor: this, alias: this.name}, + flavor: restFlavor, + content: game.i18n.format(lrMessage, {name: this.name, health: dhp, tech: dtp, force: dfp, dice: dhd}) + }); + } + + // Return data summarizing the rest effects + return { + dhd: dhd, + dhp: dhp, + dtp: dtp, + dfp: dfp, + updateData: updateData, + updateItems: updateItems, + newDay: newDay + }; } - // Recover power slots - for ( let [k, v] of Object.entries(data.powers) ) { - updateData[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0); - } - for ( let [k, v] of Object.entries(data.powers) ) { - updateData[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0); - } - // Determine the number of hit dice which may be recovered - let recoverHD = Math.max(Math.floor(data.details.level / 2), 1); - let dhd = 0; + /* -------------------------------------------- */ - // Sort classes which can recover HD, assuming players prefer recovering larger HD first. - const updateItems = this.items.filter(item => item.data.type === "class").sort((a, b) => { - let da = parseInt(a.data.data.hitDice.slice(1)) || 0; - let db = parseInt(b.data.data.hitDice.slice(1)) || 0; - return db - da; - }).reduce((updates, item) => { - const d = item.data.data; - if ( (recoverHD > 0) && (d.hitDiceUsed > 0) ) { - let delta = Math.min(d.hitDiceUsed || 0, recoverHD); - recoverHD -= delta; - dhd += delta; - updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); - } - return updates; - }, []); + /** + * Deploy an Actor into this one. + * + * @param {Actor} target The Actor to be deployed. + * @param {boolean} [coord] Deploy as Coordinator + * @param {boolean} [gunner] Deploy as Gunner + * @param {boolean} [mech] Deploy as Mechanic + * @param {boolean} [oper] Deploy as Operator + * @param {boolean} [pilot] Deploy as Pilot + * @param {boolean} [tech] Deploy as Technician + * @param {boolean} [crew] Deploy as Crew + * @param {boolean} [pass] Deploy as Passenger + */ + async deployInto( + target, + { + coord = false, + gunner = false, + mech = false, + oper = false, + pilot = false, + tech = false, + crew = false, + pass = false + } = {} + ) { + // Get the starship Actor data and the new char data + const sship = duplicate(this.toJSON()); + const ssDeploy = sship.data.attributes.deployment; + const char = target; + const charUUID = char.uuid; + const charName = char.data.name; + const charRank = char.data.data.attributes.rank; + let charProf = 0; + if (charRank.total > 0) { + charProf = char.data.data.attributes.prof; + } - // Iterate over owned items, restoring uses per day and recovering Hit Dice - const recovery = newDay ? ["sr", "lr", "day"] : ["sr", "lr"]; - for ( let item of this.items ) { - const d = item.data.data; - if ( d.uses && recovery.includes(d.uses.per) ) { - updateItems.push({_id: item.id, "data.uses.value": d.uses.max}); - } - else if ( d.recharge && d.recharge.value ) { - updateItems.push({_id: item.id, "data.recharge.charged": true}); - } + if (coord) { + ssDeploy.coord.uuid = charUUID; + ssDeploy.coord.name = charName; + ssDeploy.coord.rank = charRank.coord; + ssDeploy.coord.prof = charProf; + } + + if (gunner) { + ssDeploy.gunner.uuid = charUUID; + ssDeploy.gunner.name = charName; + ssDeploy.gunner.rank = charRank.gunner; + ssDeploy.gunner.prof = charProf; + } + + if (mech) { + ssDeploy.mechanic.uuid = charUUID; + ssDeploy.mechanic.name = charName; + ssDeploy.mechanic.rank = charRank.mechanic; + ssDeploy.mechanic.prof = charProf; + } + + if (oper) { + ssDeploy.operator.uuid = charUUID; + ssDeploy.operator.name = charName; + ssDeploy.operator.rank = charRank.operator; + ssDeploy.operator.prof = charProf; + } + + if (pilot) { + ssDeploy.pilot.uuid = charUUID; + ssDeploy.pilot.name = charName; + ssDeploy.pilot.rank = charRank.pilot; + ssDeploy.pilot.prof = charProf; + } + + if (tech) { + ssDeploy.technician.uuid = charUUID; + ssDeploy.technician.name = charName; + ssDeploy.technician.rank = charRank.technician; + ssDeploy.technician.prof = charProf; + } + + if (crew) { + ssDeploy.crew.push({uuid: charUUID, name: charName, rank: charRank, prof: charProf}); + } + + if (pass) { + ssDeploy.passenger.push({uuid: charUUID, name: charName, rank: charRank, prof: charProf}); + } + this.update({"data.attributes.deployment": ssDeploy}); } - // Perform the updates - await this.update(updateData); - if ( updateItems.length ) await this.updateEmbeddedEntity("OwnedItem", updateItems); - - // Display a Chat Message summarizing the rest effects - let restFlavor; - switch (game.settings.get("sw5e", "restVariant")) { - case 'normal': restFlavor = game.i18n.localize(newDay ? "SW5E.LongRestOvernight" : "SW5E.LongRestNormal"); break; - case 'gritty': restFlavor = game.i18n.localize("SW5E.LongRestGritty"); break; - case 'epic': restFlavor = game.i18n.localize("SW5E.LongRestEpic"); break; - } - - // Determine the chat message to display - if ( chat ) { - let lrMessage = "SW5E.LongRestResult"; - if (dhp !== 0) lrMessage += "HP"; - if (dfp !== 0) lrMessage += "FP"; - if (dtp !== 0) lrMessage += "TP"; - if (dhd !== 0) lrMessage += "HD"; - ChatMessage.create({ - user: game.user._id, - speaker: {actor: this, alias: this.name}, - flavor: restFlavor, - content: game.i18n.format(lrMessage, {name: this.name, health: dhp, tech: dtp, force: dfp, dice: dhd}) - }); - } - - // Return data summarizing the rest effects - return { - dhd: dhd, - dhp: dhp, - dtp: dtp, - dfp: dfp, - updateData: updateData, - updateItems: updateItems, - newDay: newDay - } - } - - /* -------------------------------------------- */ - - - /** - * Deploy an Actor into this one. - * - * @param {Actor} target The Actor to be deployed. - * @param {boolean} [coord] Deploy as Coordinator - * @param {boolean} [gunner] Deploy as Gunner - * @param {boolean} [mech] Deploy as Mechanic - * @param {boolean} [oper] Deploy as Operator - * @param {boolean} [pilot] Deploy as Pilot - * @param {boolean} [tech] Deploy as Technician - * @param {boolean} [crew] Deploy as Crew - * @param {boolean} [pass] Deploy as Passenger - */ - async deployInto(target, { coord=false, gunner=false, mech=false, oper=false, - pilot=false, tech=false, crew=false, pass=false}={}) { - - // Get the starship Actor data and the new char data - const sship = duplicate(this.toJSON()); - const ssDeploy = sship.data.attributes.deployment; - const char = target; - const charUUID = char.uuid; - const charName = char.data.name; - const charRank = char.data.data.attributes.rank; - let charProf = 0; - if (charRank.total > 0) { - charProf = char.data.data.attributes.prof; - } - - if (coord){ - ssDeploy.coord.uuid = charUUID; - ssDeploy.coord.name = charName; - ssDeploy.coord.rank = charRank.coord; - ssDeploy.coord.prof = charProf; - } - - if (gunner){ - ssDeploy.gunner.uuid = charUUID; - ssDeploy.gunner.name = charName; - ssDeploy.gunner.rank = charRank.gunner; - ssDeploy.gunner.prof = charProf; - } - - if (mech){ - ssDeploy.mechanic.uuid = charUUID; - ssDeploy.mechanic.name = charName; - ssDeploy.mechanic.rank = charRank.mechanic; - ssDeploy.mechanic.prof = charProf; - } - - if (oper){ - ssDeploy.operator.uuid = charUUID; - ssDeploy.operator.name = charName; - ssDeploy.operator.rank = charRank.operator; - ssDeploy.operator.prof = charProf; - } - - if (pilot){ - ssDeploy.pilot.uuid = charUUID; - ssDeploy.pilot.name = charName; - ssDeploy.pilot.rank = charRank.pilot; - ssDeploy.pilot.prof = charProf; - } - - if (tech){ - ssDeploy.technician.uuid = charUUID; - ssDeploy.technician.name = charName; - ssDeploy.technician.rank = charRank.technician; - ssDeploy.technician.prof = charProf; - } - - if (crew){ - ssDeploy.crew.push({"uuid": charUUID, "name": charName, "rank": charRank, "prof": charProf}); - } - - if (pass){ - ssDeploy.passenger.push({"uuid": charUUID, "name": charName, "rank": charRank, "prof": charProf}); - } - this.update({"data.attributes.deployment": ssDeploy}); - } - - - /** - * Transform this Actor into another one. - * - * @param {Actor} target The target Actor. - * @param {boolean} [keepPhysical] Keep physical abilities (str, dex, con) - * @param {boolean} [keepMental] Keep mental abilities (int, wis, cha) - * @param {boolean} [keepSaves] Keep saving throw proficiencies - * @param {boolean} [keepSkills] Keep skill proficiencies - * @param {boolean} [mergeSaves] Take the maximum of the save proficiencies - * @param {boolean} [mergeSkills] Take the maximum of the skill proficiencies - * @param {boolean} [keepClass] Keep proficiency bonus - * @param {boolean} [keepFeats] Keep features - * @param {boolean} [keepPowers] Keep powers - * @param {boolean} [keepItems] Keep items - * @param {boolean} [keepBio] Keep biography - * @param {boolean} [keepVision] Keep vision - * @param {boolean} [transformTokens] Transform linked tokens too - */ - async transformInto(target, { keepPhysical=false, keepMental=false, keepSaves=false, keepSkills=false, - mergeSaves=false, mergeSkills=false, keepClass=false, keepFeats=false, keepPowers=false, - keepItems=false, keepBio=false, keepVision=false, transformTokens=true}={}) { - - // Ensure the player is allowed to polymorph - const allowed = game.settings.get("sw5e", "allowPolymorphing"); - if ( !allowed && !game.user.isGM ) { - return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphWarn")); - } - - // Get the original Actor data and the new source data - const o = duplicate(this.toJSON()); - o.flags.sw5e = o.flags.sw5e || {}; - o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves}; - const source = duplicate(target.toJSON()); - - // Prepare new data to merge from the source - const d = { - type: o.type, // Remain the same actor type - name: `${o.name} (${source.name})`, // Append the new shape to your old name - data: source.data, // Get the data model of your new form - items: source.items, // Get the items of your new form - effects: o.effects.concat(source.effects), // Combine active effects from both forms - token: source.token, // New token configuration - img: source.img, // New appearance - permission: o.permission, // Use the original actor permissions - folder: o.folder, // Be displayed in the same sidebar folder - flags: o.flags // Use the original actor flags - }; - - // Additional adjustments - delete d.data.resources; // Don't change your resource pools - delete d.data.currency; // Don't lose currency - delete d.data.bonuses; // Don't lose global bonuses - delete d.token.actorId; // Don't reference the old actor ID - d.token.actorLink = o.token.actorLink; // Keep your actor link - d.token.name = d.name; // Token name same as actor name - d.data.details.alignment = o.data.details.alignment; // Don't change alignment - d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level - d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration - d.data.powers = o.data.powers; // Keep power slots - - // Handle wildcard - if ( source.token.randomImg ) { - const images = await target.getTokenImages(); - d.token.img = images[Math.floor(Math.random() * images.length)]; - } - - // Keep Token configurations - const tokenConfig = ["displayName", "vision", "actorLink", "disposition", "displayBars", "bar1", "bar2"]; - if ( keepVision ) { - tokenConfig.push(...['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle']); - } - for ( let c of tokenConfig ) { - d.token[c] = o.token[c]; - } - - // Transfer ability scores - const abilities = d.data.abilities; - for ( let k of Object.keys(abilities) ) { - const oa = o.data.abilities[k]; - const prof = abilities[k].proficient; - if ( keepPhysical && ["str", "dex", "con"].includes(k) ) abilities[k] = oa; - else if ( keepMental && ["int", "wis", "cha"].includes(k) ) abilities[k] = oa; - if ( keepSaves ) abilities[k].proficient = oa.proficient; - else if ( mergeSaves ) abilities[k].proficient = Math.max(prof, oa.proficient); - } - - // Transfer skills - if ( keepSkills ) d.data.skills = o.data.skills; - else if ( mergeSkills ) { - for ( let [k, s] of Object.entries(d.data.skills) ) { - s.value = Math.max(s.value, o.data.skills[k].value); - } - } - - // Keep specific items from the original data - d.items = d.items.concat(o.items.filter(i => { - if ( i.type === "class" ) return keepClass; - else if ( i.type === "feat" ) return keepFeats; - else if ( i.type === "power" ) return keepPowers; - else return keepItems; - })); - - // Transfer classes for NPCs - if (!keepClass && d.data.details.cr) { - d.items.push({ - type: 'class', - name: game.i18n.localize('SW5E.PolymorphTmpClass'), - data: { levels: d.data.details.cr } - }); - } - - // Keep biography - if (keepBio) d.data.details.biography = o.data.details.biography; - - // Keep senses - if (keepVision) d.data.traits.senses = o.data.traits.senses; - - // Set new data flags - if ( !this.isPolymorphed || !d.flags.sw5e.originalActor ) d.flags.sw5e.originalActor = this.id; - d.flags.sw5e.isPolymorphed = true; - - // Update unlinked Tokens in place since they can simply be re-dropped from the base actor - if (this.isToken) { - const tokenData = d.token; - tokenData.actorData = d; - delete tokenData.actorData.token; - return this.token.update(tokenData); - } - - // Update regular Actors by creating a new Actor with the Polymorphed data - await this.sheet.close(); - Hooks.callAll('sw5e.transformActor', this, target, d, { - keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills, - keepClass, keepFeats, keepPowers, keepItems, keepBio, keepVision, transformTokens - }); - const newActor = await this.constructor.create(d, {renderSheet: true}); - - // Update placed Token instances - if ( !transformTokens ) return; - const tokens = this.getActiveTokens(true); - const updates = tokens.map(t => { - const newTokenData = duplicate(d.token); - if ( !t.data.actorLink ) newTokenData.actorData = newActor.data; - newTokenData._id = t.data._id; - newTokenData.actorId = newActor.id; - return newTokenData; - }); - return canvas.scene?.updateEmbeddedEntity("Token", updates); - } - - /* -------------------------------------------- */ - - /** - * If this actor was transformed with transformTokens enabled, then its - * active tokens need to be returned to their original state. If not, then - * we can safely just delete this actor. - */ - async revertOriginalForm() { - if ( !this.isPolymorphed ) return; - if ( !this.owner ) { - return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn")); - } - - // If we are reverting an unlinked token, simply replace it with the base actor prototype - if ( this.isToken ) { - const baseActor = game.actors.get(this.token.data.actorId); - const prototypeTokenData = duplicate(baseActor.token); - prototypeTokenData.actorData = null; - return this.token.update(prototypeTokenData); - } - - // Obtain a reference to the original actor - const original = game.actors.get(this.getFlag('sw5e', 'originalActor')); - if ( !original ) return; - - // Get the Tokens which represent this actor - if ( canvas.ready ) { - const tokens = this.getActiveTokens(true); - const tokenUpdates = tokens.map(t => { - const tokenData = duplicate(original.data.token); - tokenData._id = t.id; - tokenData.actorId = original.id; - return tokenData; - }); - canvas.scene.updateEmbeddedEntity("Token", tokenUpdates); - } - - // Delete the polymorphed Actor and maybe re-render the original sheet - const isRendered = this.sheet.rendered; - if ( game.user.isGM ) await this.delete(); - original.sheet.render(isRendered); - return original; - } - - /* -------------------------------------------- */ - - /** - * Add additional system-specific sidebar directory context menu options for SW5e Actor entities - * @param {jQuery} html The sidebar HTML - * @param {Array} entryOptions The default array of context menu options - */ - static addDirectoryContextOptions(html, entryOptions) { - entryOptions.push({ - name: 'SW5E.PolymorphRestoreTransformation', - icon: '', - callback: li => { - const actor = game.actors.get(li.data('entityId')); - return actor.revertOriginalForm(); - }, - condition: li => { + /** + * Transform this Actor into another one. + * + * @param {Actor} target The target Actor. + * @param {boolean} [keepPhysical] Keep physical abilities (str, dex, con) + * @param {boolean} [keepMental] Keep mental abilities (int, wis, cha) + * @param {boolean} [keepSaves] Keep saving throw proficiencies + * @param {boolean} [keepSkills] Keep skill proficiencies + * @param {boolean} [mergeSaves] Take the maximum of the save proficiencies + * @param {boolean} [mergeSkills] Take the maximum of the skill proficiencies + * @param {boolean} [keepClass] Keep proficiency bonus + * @param {boolean} [keepFeats] Keep features + * @param {boolean} [keepPowers] Keep powers + * @param {boolean} [keepItems] Keep items + * @param {boolean} [keepBio] Keep biography + * @param {boolean} [keepVision] Keep vision + * @param {boolean} [transformTokens] Transform linked tokens too + */ + async transformInto( + target, + { + keepPhysical = false, + keepMental = false, + keepSaves = false, + keepSkills = false, + mergeSaves = false, + mergeSkills = false, + keepClass = false, + keepFeats = false, + keepPowers = false, + keepItems = false, + keepBio = false, + keepVision = false, + transformTokens = true + } = {} + ) { + // Ensure the player is allowed to polymorph const allowed = game.settings.get("sw5e", "allowPolymorphing"); - if ( !allowed && !game.user.isGM ) return false; - const actor = game.actors.get(li.data('entityId')); - return actor && actor.isPolymorphed; - } - }); - } + if (!allowed && !game.user.isGM) { + return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphWarn")); + } - /* -------------------------------------------- */ - /* DEPRECATED METHODS */ - /* -------------------------------------------- */ + // Get the original Actor data and the new source data + const o = duplicate(this.toJSON()); + o.flags.sw5e = o.flags.sw5e || {}; + o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves}; + const source = duplicate(target.toJSON()); - /** - * @deprecated since sw5e 0.97 - */ - getPowerDC(ability) { - console.warn(`The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc`); - return this.data.data.abilities[ability]?.dc; - } + // Prepare new data to merge from the source + const d = { + type: o.type, // Remain the same actor type + name: `${o.name} (${source.name})`, // Append the new shape to your old name + data: source.data, // Get the data model of your new form + items: source.items, // Get the items of your new form + effects: o.effects.concat(source.effects), // Combine active effects from both forms + token: source.token, // New token configuration + img: source.img, // New appearance + permission: o.permission, // Use the original actor permissions + folder: o.folder, // Be displayed in the same sidebar folder + flags: o.flags // Use the original actor flags + }; - /* -------------------------------------------- */ + // Additional adjustments + delete d.data.resources; // Don't change your resource pools + delete d.data.currency; // Don't lose currency + delete d.data.bonuses; // Don't lose global bonuses + delete d.token.actorId; // Don't reference the old actor ID + d.token.actorLink = o.token.actorLink; // Keep your actor link + d.token.name = d.name; // Token name same as actor name + d.data.details.alignment = o.data.details.alignment; // Don't change alignment + d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level + d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration + d.data.powers = o.data.powers; // Keep power slots - /** - * Cast a Power, consuming a power slot of a certain level - * @param {Item5e} item The power being cast by the actor - * @param {Event} event The originating user interaction which triggered the cast - * @deprecated since sw5e 1.2.0 - */ - async usePower(item, {configureDialog=true}={}) { - console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`); - if ( item.data.type !== "power" ) throw new Error("Wrong Item type"); - return item.roll(); - } -} \ No newline at end of file + // Handle wildcard + if (source.token.randomImg) { + const images = await target.getTokenImages(); + d.token.img = images[Math.floor(Math.random() * images.length)]; + } + + // Keep Token configurations + const tokenConfig = ["displayName", "vision", "actorLink", "disposition", "displayBars", "bar1", "bar2"]; + if (keepVision) { + tokenConfig.push(...["dimSight", "brightSight", "dimLight", "brightLight", "vision", "sightAngle"]); + } + for (let c of tokenConfig) { + d.token[c] = o.token[c]; + } + + // Transfer ability scores + const abilities = d.data.abilities; + for (let k of Object.keys(abilities)) { + const oa = o.data.abilities[k]; + const prof = abilities[k].proficient; + if (keepPhysical && ["str", "dex", "con"].includes(k)) abilities[k] = oa; + else if (keepMental && ["int", "wis", "cha"].includes(k)) abilities[k] = oa; + if (keepSaves) abilities[k].proficient = oa.proficient; + else if (mergeSaves) abilities[k].proficient = Math.max(prof, oa.proficient); + } + + // Transfer skills + if (keepSkills) d.data.skills = o.data.skills; + else if (mergeSkills) { + for (let [k, s] of Object.entries(d.data.skills)) { + s.value = Math.max(s.value, o.data.skills[k].value); + } + } + + // Keep specific items from the original data + d.items = d.items.concat( + o.items.filter((i) => { + if (i.type === "class") return keepClass; + else if (i.type === "feat") return keepFeats; + else if (i.type === "power") return keepPowers; + else return keepItems; + }) + ); + + // Transfer classes for NPCs + if (!keepClass && d.data.details.cr) { + d.items.push({ + type: "class", + name: game.i18n.localize("SW5E.PolymorphTmpClass"), + data: {levels: d.data.details.cr} + }); + } + + // Keep biography + if (keepBio) d.data.details.biography = o.data.details.biography; + + // Keep senses + if (keepVision) d.data.traits.senses = o.data.traits.senses; + + // Set new data flags + if (!this.isPolymorphed || !d.flags.sw5e.originalActor) d.flags.sw5e.originalActor = this.id; + d.flags.sw5e.isPolymorphed = true; + + // Update unlinked Tokens in place since they can simply be re-dropped from the base actor + if (this.isToken) { + const tokenData = d.token; + tokenData.actorData = d; + delete tokenData.actorData.token; + return this.token.update(tokenData); + } + + // Update regular Actors by creating a new Actor with the Polymorphed data + await this.sheet.close(); + Hooks.callAll("sw5e.transformActor", this, target, d, { + keepPhysical, + keepMental, + keepSaves, + keepSkills, + mergeSaves, + mergeSkills, + keepClass, + keepFeats, + keepPowers, + keepItems, + keepBio, + keepVision, + transformTokens + }); + const newActor = await this.constructor.create(d, {renderSheet: true}); + + // Update placed Token instances + if (!transformTokens) return; + const tokens = this.getActiveTokens(true); + const updates = tokens.map((t) => { + const newTokenData = duplicate(d.token); + if (!t.data.actorLink) newTokenData.actorData = newActor.data; + newTokenData._id = t.data._id; + newTokenData.actorId = newActor.id; + return newTokenData; + }); + return canvas.scene?.updateEmbeddedEntity("Token", updates); + } + + /* -------------------------------------------- */ + + /** + * If this actor was transformed with transformTokens enabled, then its + * active tokens need to be returned to their original state. If not, then + * we can safely just delete this actor. + */ + async revertOriginalForm() { + if (!this.isPolymorphed) return; + if (!this.owner) { + return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn")); + } + + // If we are reverting an unlinked token, simply replace it with the base actor prototype + if (this.isToken) { + const baseActor = game.actors.get(this.token.data.actorId); + const prototypeTokenData = duplicate(baseActor.token); + prototypeTokenData.actorData = null; + return this.token.update(prototypeTokenData); + } + + // Obtain a reference to the original actor + const original = game.actors.get(this.getFlag("sw5e", "originalActor")); + if (!original) return; + + // Get the Tokens which represent this actor + if (canvas.ready) { + const tokens = this.getActiveTokens(true); + const tokenUpdates = tokens.map((t) => { + const tokenData = duplicate(original.data.token); + tokenData._id = t.id; + tokenData.actorId = original.id; + return tokenData; + }); + canvas.scene.updateEmbeddedEntity("Token", tokenUpdates); + } + + // Delete the polymorphed Actor and maybe re-render the original sheet + const isRendered = this.sheet.rendered; + if (game.user.isGM) await this.delete(); + original.sheet.render(isRendered); + return original; + } + + /* -------------------------------------------- */ + + /** + * Add additional system-specific sidebar directory context menu options for SW5e Actor entities + * @param {jQuery} html The sidebar HTML + * @param {Array} entryOptions The default array of context menu options + */ + static addDirectoryContextOptions(html, entryOptions) { + entryOptions.push({ + name: "SW5E.PolymorphRestoreTransformation", + icon: '', + callback: (li) => { + const actor = game.actors.get(li.data("entityId")); + return actor.revertOriginalForm(); + }, + condition: (li) => { + const allowed = game.settings.get("sw5e", "allowPolymorphing"); + if (!allowed && !game.user.isGM) return false; + const actor = game.actors.get(li.data("entityId")); + return actor && actor.isPolymorphed; + } + }); + } + + /* -------------------------------------------- */ + /* DEPRECATED METHODS */ + /* -------------------------------------------- */ + + /** + * @deprecated since sw5e 0.97 + */ + getPowerDC(ability) { + console.warn( + `The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc` + ); + return this.data.data.abilities[ability]?.dc; + } + + /* -------------------------------------------- */ + + /** + * Cast a Power, consuming a power slot of a certain level + * @param {Item5e} item The power being cast by the actor + * @param {Event} event The originating user interaction which triggered the cast + * @deprecated since sw5e 1.2.0 + */ + async usePower(item, {configureDialog = true} = {}) { + console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`); + if (item.data.type !== "power") throw new Error("Wrong Item type"); + return item.roll(); + } +} diff --git a/module/actor/sheets/newSheet/base.js b/module/actor/sheets/newSheet/base.js index d6636c0b..0b2136bc 100644 --- a/module/actor/sheets/newSheet/base.js +++ b/module/actor/sheets/newSheet/base.js @@ -5,7 +5,7 @@ import ActorHitDiceConfig from "../../../apps/hit-dice-config.js"; import ActorMovementConfig from "../../../apps/movement-config.js"; import ActorSensesConfig from "../../../apps/senses-config.js"; import ActorTypeConfig from "../../../apps/actor-type.js"; -import {SW5E} from '../../../config.js'; +import {SW5E} from "../../../config.js"; import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effects.js"; /** @@ -14,963 +14,976 @@ import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effe * @extends {ActorSheet} */ export default class ActorSheet5e extends ActorSheet { - constructor(...args) { - super(...args); + constructor(...args) { + super(...args); + + /** + * Track the set of item filters which are applied + * @type {Set} + */ + this._filters = { + inventory: new Set(), + forcePowerbook: new Set(), + techPowerbook: new Set(), + features: new Set(), + effects: new Set() + }; + } + + /* -------------------------------------------- */ + + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + scrollY: [ + ".inventory .group-list", + ".features .group-list", + ".force-powerbook .group-list", + ".tech-powerbook .group-list", + ".effects .effects-list" + ], + tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] + }); + } + + /* -------------------------------------------- */ /** - * Track the set of item filters which are applied - * @type {Set} + * A set of item types that should be prevented from being dropped on this type of actor sheet. + * @type {Set} */ - this._filters = { - inventory: new Set(), - forcePowerbook: new Set(), - techPowerbook: new Set(), - features: new Set(), - effects: new Set() - }; - } + static unsupportedItemTypes = new Set(); - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - scrollY: [ - ".inventory .group-list", - ".features .group-list", - ".force-powerbook .group-list", - ".tech-powerbook .group-list", - ".effects .effects-list" - ], - tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] - }); - } - - /* -------------------------------------------- */ - - /** - * A set of item types that should be prevented from being dropped on this type of actor sheet. - * @type {Set} - */ - static unsupportedItemTypes = new Set(); - - /* -------------------------------------------- */ - - /** @override */ - get template() { - if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/newActor/expanded-limited-sheet.html"; - return `systems/sw5e/templates/actors/newActor/${this.actor.data.type}-sheet.html`; - } - - /* -------------------------------------------- */ - - /** @override */ - getData(options) { - - // Basic data - let isOwner = this.actor.isOwner; - const data = { - owner: isOwner, - limited: this.actor.limited, - options: this.options, - editable: this.isEditable, - cssClass: isOwner ? "editable" : "locked", - isCharacter: this.actor.type === "character", - isNPC: this.actor.type === "npc", - isStarship: this.actor.type === "starship", - isVehicle: this.actor.type === 'vehicle', - config: CONFIG.SW5E, - rollData: this.actor.getRollData.bind(this.actor) - }; - - // The Actor's data - const actorData = this.actor.data.toObject(false); - data.actor = actorData; - data.data = actorData.data; - - // Owned Items - data.items = actorData.items; - for ( let i of data.items ) { - const item = this.actor.items.get(i._id); - i.labels = item.labels; - } - data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); - - // Labels and filters - data.labels = this.actor.labels || {}; - data.filters = this._filters; - - // Ability Scores - for ( let [a, abl] of Object.entries(actorData.data.abilities)) { - abl.icon = this._getProficiencyIcon(abl.proficient); - abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient]; - abl.label = CONFIG.SW5E.abilities[a]; + /** @override */ + get template() { + if (!game.user.isGM && this.actor.limited) + return "systems/sw5e/templates/actors/newActor/expanded-limited-sheet.html"; + return `systems/sw5e/templates/actors/newActor/${this.actor.data.type}-sheet.html`; } - // Skills - if (actorData.data.skills) { - for (let [s, skl] of Object.entries(actorData.data.skills)) { - skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability]; - skl.icon = this._getProficiencyIcon(skl.value); - skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; - if (data.actor.type === "starship") { - skl.label = CONFIG.SW5E.starshipSkills[s]; - }else{ - skl.label = CONFIG.SW5E.skills[s]; + /* -------------------------------------------- */ + + /** @override */ + getData(options) { + // Basic data + let isOwner = this.actor.isOwner; + const data = { + owner: isOwner, + limited: this.actor.limited, + options: this.options, + editable: this.isEditable, + cssClass: isOwner ? "editable" : "locked", + isCharacter: this.actor.type === "character", + isNPC: this.actor.type === "npc", + isStarship: this.actor.type === "starship", + isVehicle: this.actor.type === "vehicle", + config: CONFIG.SW5E, + rollData: this.actor.getRollData.bind(this.actor) + }; + + // The Actor's data + const actorData = this.actor.data.toObject(false); + data.actor = actorData; + data.data = actorData.data; + + // Owned Items + data.items = actorData.items; + for (let i of data.items) { + const item = this.actor.items.get(i._id); + i.labels = item.labels; } - } - } + data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); - // Movement speeds - data.movement = this._getMovementSpeed(actorData); + // Labels and filters + data.labels = this.actor.labels || {}; + data.filters = this._filters; - // Senses - data.senses = this._getSenses(actorData); - - // Update traits - this._prepareTraits(actorData.data.traits); - - // Prepare owned items - this._prepareItems(data); - - // Prepare active effects - data.effects = prepareActiveEffectCategories(this.actor.effects); - - // Return data to the sheet - return data - } - - /* -------------------------------------------- */ - - /** - * Prepare the display of movement speed data for the Actor* - * @param {object} actorData The Actor data being prepared. - * @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk" - * @returns {{primary: string, special: string}} - * @private - */ - _getMovementSpeed(actorData, largestPrimary=false) { - const movement = actorData.data.attributes.movement || {}; - - // Prepare an array of available movement speeds - let speeds = [ - [movement.burrow, `${game.i18n.localize("SW5E.MovementBurrow")} ${movement.burrow}`], - [movement.climb, `${game.i18n.localize("SW5E.MovementClimb")} ${movement.climb}`], - [movement.fly, `${game.i18n.localize("SW5E.MovementFly")} ${movement.fly}` + (movement.hover ? ` (${game.i18n.localize("SW5E.MovementHover")})` : "")], - [movement.swim, `${game.i18n.localize("SW5E.MovementSwim")} ${movement.swim}`] - ] - if ( largestPrimary ) { - speeds.push([movement.walk, `${game.i18n.localize("SW5E.MovementWalk")} ${movement.walk}`]); - } - - // Filter and sort speeds on their values - speeds = speeds.filter(s => !!s[0]).sort((a, b) => b[0] - a[0]); - - // Case 1: Largest as primary - if ( largestPrimary ) { - let primary = speeds.shift(); - return { - primary: `${primary ? primary[1] : "0"} ${movement.units}`, - special: speeds.map(s => s[1]).join(", ") - } - } - - // Case 2: Walk as primary - else { - return { - primary: `${movement.walk || 0} ${movement.units}`, - special: speeds.length ? speeds.map(s => s[1]).join(", ") : "" - } - } - } - - /* -------------------------------------------- */ - - _getSenses(actorData) { - const senses = actorData.data.attributes.senses || {}; - const tags = {}; - for ( let [k, label] of Object.entries(CONFIG.SW5E.senses) ) { - const v = senses[k] ?? 0 - if ( v === 0 ) continue; - tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`; - } - if ( !!senses.special ) tags["special"] = senses.special; - return tags; - } - - /* -------------------------------------------- */ - - /** - * Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies - * @param {object} traits The raw traits data object from the actor data - * @private - */ - _prepareTraits(traits) { - const map = { - "dr": CONFIG.SW5E.damageResistanceTypes, - "di": CONFIG.SW5E.damageResistanceTypes, - "dv": CONFIG.SW5E.damageResistanceTypes, - "ci": CONFIG.SW5E.conditionTypes, - "languages": CONFIG.SW5E.languages, - "armorProf": CONFIG.SW5E.armorProficiencies, - "weaponProf": CONFIG.SW5E.weaponProficiencies, - "toolProf": CONFIG.SW5E.toolProficiencies - }; - for ( let [t, choices] of Object.entries(map) ) { - const trait = traits[t]; - if ( !trait ) continue; - let values = []; - if ( trait.value ) { - values = trait.value instanceof Array ? trait.value : [trait.value]; - } - trait.selected = values.reduce((obj, t) => { - obj[t] = choices[t]; - return obj; - }, {}); - - // Add custom entry - if ( trait.custom ) { - trait.custom.split(";").forEach((c, i) => trait.selected[`custom${i+1}`] = c.trim()); - } - trait.cssClass = !isObjectEmpty(trait.selected) ? "" : "inactive"; - } - } - - /* -------------------------------------------- */ - - /** - * Insert a power into the powerbook object when rendering the character sheet - * @param {Object} data The Actor data being prepared - * @param {Array} powers The power data being prepared - * @param {string} school The school of the powerbook being prepared - * @private - */ - _preparePowerbook(data, powers, school) { - const owner = this.actor.isOwner; - const levels = data.data.powers; - const powerbook = {}; - - // Define some mappings - const sections = { - "atwill": -20, - "innate": -10, - }; - - // Label power slot uses headers - const useLabels = { - "-20": "-", - "-10": "-", - "0": "∞" - }; - - // Format a powerbook entry for a certain indexed level - const registerSection = (sl, i, label, {prepMode="prepared", value, max, override}={}) => { - powerbook[i] = { - order: i, - label: label, - usesSlots: i > 0, - canCreate: owner, - canPrepare: (data.actor.type === "character") && (i >= 1), - powers: [], - uses: useLabels[i] || value || 0, - slots: useLabels[i] || max || 0, - override: override || 0, - dataset: {"type": "power", "level": prepMode in sections ? 1 : i, "preparation.mode": prepMode, "school": school}, - prop: sl - }; - }; - - // Determine the maximum power level which has a slot - const maxLevel = Array.fromRange(10).reduce((max, i) => { - if ( i === 0 ) return max; - const level = levels[`power${i}`]; - if ( (level.max || level.override ) && ( i > max ) ) max = i; - return max; - }, 0); - - // Level-based powercasters have cantrips and leveled slots - if ( maxLevel > 0 ) { - registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); - for (let lvl = 1; lvl <= maxLevel; lvl++) { - const sl = `power${lvl}`; - registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]); - } - } - - // Iterate over every power item, adding powers to the powerbook by section - powers.forEach(power => { - const mode = power.data.preparation.mode || "prepared"; - let s = power.data.level || 0; - const sl = `power${s}`; - - // Specialized powercasting modes (if they exist) - if ( mode in sections ) { - s = sections[mode]; - if ( !powerbook[s] ){ - const l = levels[mode] || {}; - const config = CONFIG.SW5E.powerPreparationModes[mode]; - registerSection(mode, s, config, { - prepMode: mode, - value: l.value, - max: l.max, - override: l.override - }); + // Ability Scores + for (let [a, abl] of Object.entries(actorData.data.abilities)) { + abl.icon = this._getProficiencyIcon(abl.proficient); + abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient]; + abl.label = CONFIG.SW5E.abilities[a]; } - } - // Sections for higher-level powers which the caster "should not" have, but power items exist for - else if ( !powerbook[s] ) { - registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]}); - } - - // Add the power to the relevant heading - powerbook[s].powers.push(power); - }); - - // Sort the powerbook by section level - const sorted = Object.values(powerbook); - sorted.sort((a, b) => a.order - b.order); - return sorted; - } - - /* -------------------------------------------- */ - - /** - * Determine whether an Owned Item will be shown based on the current set of filters - * @return {boolean} - * @private - */ - _filterItems(items, filters) { - return items.filter(item => { - const data = item.data; - - // Action usage - for ( let f of ["action", "bonus", "reaction"] ) { - if ( filters.has(f) ) { - if ((data.activation && (data.activation.type !== f))) return false; + // Skills + if (actorData.data.skills) { + for (let [s, skl] of Object.entries(actorData.data.skills)) { + skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability]; + skl.icon = this._getProficiencyIcon(skl.value); + skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; + if (data.actor.type === "starship") { + skl.label = CONFIG.SW5E.starshipSkills[s]; + } else { + skl.label = CONFIG.SW5E.skills[s]; + } + } } - } - // Power-specific filters - if ( filters.has("ritual") ) { - if (data.components.ritual !== true) return false; - } - if ( filters.has("concentration") ) { - if (data.components.concentration !== true) return false; - } - if ( filters.has("prepared") ) { - if ( data.level === 0 || ["innate", "always"].includes(data.preparation.mode) ) return true; - if ( this.actor.data.type === "npc" ) return true; - if ( this.actor.data.type === "starship" ) return true; - return data.preparation.prepared; - } + // Movement speeds + data.movement = this._getMovementSpeed(actorData); - // Equipment-specific filters - if ( filters.has("equipped") ) { - if ( data.equipped !== true ) return false; - } - return true; - }); - } + // Senses + data.senses = this._getSenses(actorData); - /* -------------------------------------------- */ + // Update traits + this._prepareTraits(actorData.data.traits); - /** - * Get the font-awesome icon used to display a certain level of skill proficiency - * @private - */ - _getProficiencyIcon(level) { - const icons = { - 0: '', - 0.5: '', - 1: '', - 2: '' - }; - return icons[level] || icons[0]; - } + // Prepare owned items + this._prepareItems(data); - /* -------------------------------------------- */ - /* Event Listeners and Handlers - /* -------------------------------------------- */ + // Prepare active effects + data.effects = prepareActiveEffectCategories(this.actor.effects); - /** @inheritdoc */ - activateListeners(html) { - - // Activate Item Filters - const filterLists = html.find(".filter-list"); - filterLists.each(this._initializeFilterItemList.bind(this)); - filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this)); - - // Item summaries - html.find('.item .item-name.rollable h4').click(event => this._onItemSummary(event)); - - // View Item Sheets - html.find('.item-edit').click(this._onItemEdit.bind(this)); - - // Editable Only Listeners - if ( this.isEditable ) { - - // Input focus and update - const inputs = html.find("input"); - inputs.focus(ev => ev.currentTarget.select()); - inputs.addBack().find('[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this)); - - // Ability Proficiency - html.find('.ability-proficiency').click(this._onToggleAbilityProficiency.bind(this)); - - // Toggle Skill Proficiency - html.find('.skill-proficiency').on("click contextmenu", this._onCycleSkillProficiency.bind(this)); - - // Trait Selector - html.find('.trait-selector').click(this._onTraitSelector.bind(this)); - - // Configure Special Flags - html.find('.config-button').click(this._onConfigMenu.bind(this)); - - // Owned Item management - html.find('.item-create').click(this._onItemCreate.bind(this)); - html.find('.item-delete').click(this._onItemDelete.bind(this)); - html.find('.item-collapse').click(this._onItemCollapse.bind(this)); - html.find('.item-uses input').click(ev => ev.target.select()).change(this._onUsesChange.bind(this)); - html.find('.slot-max-override').click(this._onPowerSlotOverride.bind(this)); - html.find('.increment-class-level').click(this._onIncrementClassLevel.bind(this)); - html.find('.decrement-class-level').click(this._onDecrementClassLevel.bind(this)); - - // Active Effect management - html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.actor)); + // Return data to the sheet + return data; } - // Owner Only Listeners - if ( this.actor.isOwner ) { + /* -------------------------------------------- */ - // Ability Checks - html.find('.ability-name').click(this._onRollAbilityTest.bind(this)); + /** + * Prepare the display of movement speed data for the Actor* + * @param {object} actorData The Actor data being prepared. + * @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk" + * @returns {{primary: string, special: string}} + * @private + */ + _getMovementSpeed(actorData, largestPrimary = false) { + const movement = actorData.data.attributes.movement || {}; - - // Roll Skill Checks - html.find('.skill-name').click(this._onRollSkillCheck.bind(this)); - - // Item Rolling - html.find('.item .item-image').click(event => this._onItemRoll(event)); - html.find('.item .item-recharge').click(event => this._onItemRecharge(event)); - } - - // Otherwise remove rollable classes - else { - html.find(".rollable").each((i, el) => el.classList.remove("rollable")); - } - - // Handle default listeners last so system listeners are triggered first - super.activateListeners(html); - } - - /* -------------------------------------------- */ - - /** - * Iinitialize Item list filters by activating the set of filters which are currently applied - * @private - */ - _initializeFilterItemList(i, ul) { - const set = this._filters[ul.dataset.filter]; - const filters = ul.querySelectorAll(".filter-item"); - for ( let li of filters ) { - if ( set.has(li.dataset.filter) ) li.classList.add("active"); - } - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs - * @param event - * @private - */ - _onChangeInputDelta(event) { - const input = event.target; - const value = input.value; - if ( ["+", "-"].includes(value[0]) ) { - let delta = parseFloat(value); - input.value = getProperty(this.actor.data, input.name) + delta; - } else if ( value[0] === "=" ) { - input.value = value.slice(1); - } - } - - /* -------------------------------------------- */ - - /** - * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options - * @param {Event} event The click event which originated the selection - * @private - */ - _onConfigMenu(event) { - event.preventDefault(); - const button = event.currentTarget; - let app; - switch ( button.dataset.action ) { - case "hit-dice": - app = new ActorHitDiceConfig(this.object); - break; - case "movement": - app = new ActorMovementConfig(this.object); - break; - case "flags": - app = new ActorSheetFlags(this.object); - break; - case "senses": - app = new ActorSensesConfig(this.object); - break; - case "type": - new ActorTypeConfig(this.object).render(true); - break; - } - app?.render(true); - } - - /* -------------------------------------------- */ - - /** - * Handle cycling proficiency in a Skill - * @param {Event} event A click or contextmenu event which triggered the handler - * @private - */ - _onCycleSkillProficiency(event) { - event.preventDefault(); - const field = $(event.currentTarget).siblings('input[type="hidden"]'); - - // Get the current level and the array of levels - const level = parseFloat(field.val()); - const levels = [0, 1, 0.5, 2]; - let idx = levels.indexOf(level); - - // Toggle next level - forward on click, backwards on right - if ( event.type === "click" ) { - field.val(levels[(idx === levels.length - 1) ? 0 : idx + 1]); - } else if ( event.type === "contextmenu" ) { - field.val(levels[(idx === 0) ? levels.length - 1 : idx - 1]); - } - - // Update the field value and save the form - this._onSubmit(event); - } - - /* -------------------------------------------- */ - - /** @override */ - async _onDropActor(event, data) { - const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get('sw5e', 'allowPolymorphing')); - if ( !canPolymorph ) return false; - - // Get the target actor - let sourceActor = null; - if (data.pack) { - const pack = game.packs.find(p => p.collection === data.pack); - sourceActor = await pack.getEntity(data.id); - } else { - sourceActor = game.actors.get(data.id); - } - if ( !sourceActor ) return; - - // Define a function to record polymorph settings for future use - const rememberOptions = html => { - const options = {}; - html.find('input').each((i, el) => { - options[el.name] = el.checked; - }); - const settings = mergeObject(game.settings.get('sw5e', 'polymorphSettings') || {}, options); - game.settings.set('sw5e', 'polymorphSettings', settings); - return settings; - }; - - // Create and render the Dialog - return new Dialog({ - title: game.i18n.localize('SW5E.PolymorphPromptTitle'), - content: { - options: game.settings.get('sw5e', 'polymorphSettings'), - i18n: SW5E.polymorphSettings, - isToken: this.actor.isToken - }, - default: 'accept', - buttons: { - accept: { - icon: '', - label: game.i18n.localize('SW5E.PolymorphAcceptSettings'), - callback: html => this.actor.transformInto(sourceActor, rememberOptions(html)) - }, - wildshape: { - icon: '', - label: game.i18n.localize('SW5E.PolymorphWildShape'), - callback: html => this.actor.transformInto(sourceActor, { - keepBio: true, - keepClass: true, - keepMental: true, - mergeSaves: true, - mergeSkills: true, - transformTokens: rememberOptions(html).transformTokens - }) - }, - polymorph: { - icon: '', - label: game.i18n.localize('SW5E.Polymorph'), - callback: html => this.actor.transformInto(sourceActor, { - transformTokens: rememberOptions(html).transformTokens - }) - }, - cancel: { - icon: '', - label: game.i18n.localize('Cancel') + // Prepare an array of available movement speeds + let speeds = [ + [movement.burrow, `${game.i18n.localize("SW5E.MovementBurrow")} ${movement.burrow}`], + [movement.climb, `${game.i18n.localize("SW5E.MovementClimb")} ${movement.climb}`], + [ + movement.fly, + `${game.i18n.localize("SW5E.MovementFly")} ${movement.fly}` + + (movement.hover ? ` (${game.i18n.localize("SW5E.MovementHover")})` : "") + ], + [movement.swim, `${game.i18n.localize("SW5E.MovementSwim")} ${movement.swim}`] + ]; + if (largestPrimary) { + speeds.push([movement.walk, `${game.i18n.localize("SW5E.MovementWalk")} ${movement.walk}`]); } - } - }, { - classes: ['dialog', 'sw5e'], - width: 600, - template: 'systems/sw5e/templates/apps/polymorph-prompt.html' - }).render(true); - } - /* -------------------------------------------- */ + // Filter and sort speeds on their values + speeds = speeds.filter((s) => !!s[0]).sort((a, b) => b[0] - a[0]); - /** @override */ - async _onDropItemCreate(itemData) { + // Case 1: Largest as primary + if (largestPrimary) { + let primary = speeds.shift(); + return { + primary: `${primary ? primary[1] : "0"} ${movement.units}`, + special: speeds.map((s) => s[1]).join(", ") + }; + } - // Check to make sure items of this type are allowed on this actor - if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) { - return ui.notifications.warn(game.i18n.format("SW5E.ActorWarningInvalidItem", { - itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]), - actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type]) - })); + // Case 2: Walk as primary + else { + return { + primary: `${movement.walk || 0} ${movement.units}`, + special: speeds.length ? speeds.map((s) => s[1]).join(", ") : "" + }; + } } - // Create a Consumable power scroll on the Inventory tab - if ( (itemData.type === "power") && (this._tabs[0].active === "inventory") ) { - const scroll = await Item5e.createScrollFromPower(itemData); - itemData = scroll.data; + /* -------------------------------------------- */ + + _getSenses(actorData) { + const senses = actorData.data.attributes.senses || {}; + const tags = {}; + for (let [k, label] of Object.entries(CONFIG.SW5E.senses)) { + const v = senses[k] ?? 0; + if (v === 0) continue; + tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`; + } + if (!!senses.special) tags["special"] = senses.special; + return tags; } - if ( itemData.data ) { - // Ignore certain statuses - ["equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + /* -------------------------------------------- */ - // Downgrade ATTUNED to REQUIRED - itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED); + /** + * Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies + * @param {object} traits The raw traits data object from the actor data + * @private + */ + _prepareTraits(traits) { + const map = { + dr: CONFIG.SW5E.damageResistanceTypes, + di: CONFIG.SW5E.damageResistanceTypes, + dv: CONFIG.SW5E.damageResistanceTypes, + ci: CONFIG.SW5E.conditionTypes, + languages: CONFIG.SW5E.languages, + armorProf: CONFIG.SW5E.armorProficiencies, + weaponProf: CONFIG.SW5E.weaponProficiencies, + toolProf: CONFIG.SW5E.toolProficiencies + }; + for (let [t, choices] of Object.entries(map)) { + const trait = traits[t]; + if (!trait) continue; + let values = []; + if (trait.value) { + values = trait.value instanceof Array ? trait.value : [trait.value]; + } + trait.selected = values.reduce((obj, t) => { + obj[t] = choices[t]; + return obj; + }, {}); + + // Add custom entry + if (trait.custom) { + trait.custom.split(";").forEach((c, i) => (trait.selected[`custom${i + 1}`] = c.trim())); + } + trait.cssClass = !isObjectEmpty(trait.selected) ? "" : "inactive"; + } } - // Stack identical consumables - if ( itemData.type === "consumable" && itemData.flags.core?.sourceId ) { - const similarItem = this.actor.items.find(i => { - const sourceId = i.getFlag("core", "sourceId"); - return sourceId && (sourceId === itemData.flags.core?.sourceId) && - (i.type === "consumable"); - }); - if ( similarItem && itemData.name !== "Power Cell" ) { // Always create a new powercell instead of increasing quantity - return similarItem.update({ - 'data.quantity': similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1) + /* -------------------------------------------- */ + + /** + * Insert a power into the powerbook object when rendering the character sheet + * @param {Object} data The Actor data being prepared + * @param {Array} powers The power data being prepared + * @param {string} school The school of the powerbook being prepared + * @private + */ + _preparePowerbook(data, powers, school) { + const owner = this.actor.isOwner; + const levels = data.data.powers; + const powerbook = {}; + + // Define some mappings + const sections = { + atwill: -20, + innate: -10 + }; + + // Label power slot uses headers + const useLabels = { + "-20": "-", + "-10": "-", + "0": "∞" + }; + + // Format a powerbook entry for a certain indexed level + const registerSection = (sl, i, label, {prepMode = "prepared", value, max, override} = {}) => { + powerbook[i] = { + order: i, + label: label, + usesSlots: i > 0, + canCreate: owner, + canPrepare: data.actor.type === "character" && i >= 1, + powers: [], + uses: useLabels[i] || value || 0, + slots: useLabels[i] || max || 0, + override: override || 0, + dataset: { + "type": "power", + "level": prepMode in sections ? 1 : i, + "preparation.mode": prepMode, + "school": school + }, + prop: sl + }; + }; + + // Determine the maximum power level which has a slot + const maxLevel = Array.fromRange(10).reduce((max, i) => { + if (i === 0) return max; + const level = levels[`power${i}`]; + if ((level.max || level.override) && i > max) max = i; + return max; + }, 0); + + // Level-based powercasters have cantrips and leveled slots + if (maxLevel > 0) { + registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); + for (let lvl = 1; lvl <= maxLevel; lvl++) { + const sl = `power${lvl}`; + registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]); + } + } + + // Iterate over every power item, adding powers to the powerbook by section + powers.forEach((power) => { + const mode = power.data.preparation.mode || "prepared"; + let s = power.data.level || 0; + const sl = `power${s}`; + + // Specialized powercasting modes (if they exist) + if (mode in sections) { + s = sections[mode]; + if (!powerbook[s]) { + const l = levels[mode] || {}; + const config = CONFIG.SW5E.powerPreparationModes[mode]; + registerSection(mode, s, config, { + prepMode: mode, + value: l.value, + max: l.max, + override: l.override + }); + } + } + + // Sections for higher-level powers which the caster "should not" have, but power items exist for + else if (!powerbook[s]) { + registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]}); + } + + // Add the power to the relevant heading + powerbook[s].powers.push(power); }); - } + + // Sort the powerbook by section level + const sorted = Object.values(powerbook); + sorted.sort((a, b) => a.order - b.order); + return sorted; } - // Create the owned item as normal - return super._onDropItemCreate(itemData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Determine whether an Owned Item will be shown based on the current set of filters + * @return {boolean} + * @private + */ + _filterItems(items, filters) { + return items.filter((item) => { + const data = item.data; - /** - * Handle enabling editing for a power slot override value - * @param {MouseEvent} event The originating click event - * @private - */ - async _onPowerSlotOverride (event) { - const span = event.currentTarget.parentElement; - const level = span.dataset.level; - const override = this.actor.data.data.powers[level].override || span.dataset.slots; - const input = document.createElement("INPUT"); - input.type = "text"; - input.name = `data.powers.${level}.override`; - input.value = override; - input.placeholder = span.dataset.slots; - input.dataset.dtype = "Number"; + // Action usage + for (let f of ["action", "bonus", "reaction"]) { + if (filters.has(f)) { + if (data.activation && data.activation.type !== f) return false; + } + } - // Replace the HTML - const parent = span.parentElement; - parent.removeChild(span); - parent.appendChild(input); - } + // Power-specific filters + if (filters.has("ritual")) { + if (data.components.ritual !== true) return false; + } + if (filters.has("concentration")) { + if (data.components.concentration !== true) return false; + } + if (filters.has("prepared")) { + if (data.level === 0 || ["innate", "always"].includes(data.preparation.mode)) return true; + if (this.actor.data.type === "npc") return true; + if (this.actor.data.type === "starship") return true; + return data.preparation.prepared; + } - /* -------------------------------------------- */ - - /** - * Change the uses amount of an Owned Item within the Actor - * @param {Event} event The triggering click event - * @private - */ - async _onUsesChange(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max); - event.target.value = uses; - return item.update({ 'data.uses.value': uses }); - } - - /* -------------------------------------------- */ - - /** - * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method - * @private - */ - _onItemRoll(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - return item.roll(); - } - - /* -------------------------------------------- */ - - /** - * Handle attempting to recharge an item usage by rolling a recharge check - * @param {Event} event The originating click event - * @private - */ - _onItemRecharge(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - return item.rollRecharge(); - }; - - /* -------------------------------------------- */ - - /** - * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method - * @private - */ - _onItemSummary(event) { - event.preventDefault(); - let li = $(event.currentTarget).parents(".item"), - item = this.actor.items.get(li.data("item-id")), - chatData = item.getChatData({secrets: this.actor.isOwner}); - - // Toggle summary - if ( li.hasClass("expanded") ) { - let summary = li.children(".item-summary"); - summary.slideUp(200, () => summary.remove()); - } else { - let div = $(`
${chatData.description.value}
`); - let props = $(`
`); - chatData.properties.forEach(p => props.append(`${p}`)); - div.append(props); - li.append(div.hide()); - div.slideDown(200); + // Equipment-specific filters + if (filters.has("equipped")) { + if (data.equipped !== true) return false; + } + return true; + }); } - li.toggleClass("expanded"); - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset - * @param {Event} event The originating click event - * @private - */ - _onItemCreate(event) { - event.preventDefault(); - const header = event.currentTarget; - const type = header.dataset.type; - const itemData = { - name: game.i18n.format("SW5E.ItemNew", {type: game.i18n.localize(`SW5E.ItemType${type.capitalize()}`)}), - type: type, - data: foundry.utils.deepClone(header.dataset) - }; - delete itemData.data["type"]; - return this.actor.createEmbeddedDocuments("Item", [itemData]); - } - - /* -------------------------------------------- */ - - /** - * Handle editing an existing Owned Item for the Actor - * @param {Event} event The originating click event - * @private - */ - _onItemEdit(event) { - event.preventDefault(); - const li = event.currentTarget.closest(".item"); - const item = this.actor.items.get(li.dataset.itemId); - return item.sheet.render(true); - } - - /* -------------------------------------------- */ - - /** - * Handle deleting an existing Owned Item for the Actor - * @param {Event} event The originating click event - * @private - */ - _onItemDelete(event) { - event.preventDefault(); - const li = event.currentTarget.closest(".item"); - const item = this.actor.items.get(li.dataset.itemId); - if ( item ) return item.delete(); - } - - /** - * Handle collapsing a Feature row on the actor sheet - * @param {Event} event The originating click event - * @private - */ - -_onItemCollapse(event) { - event.preventDefault(); - - event.currentTarget.classList.toggle("active"); - - const li = event.currentTarget.closest("li"); - const content = li.querySelector(".content"); - - if (content.style.display === "none") { - content.style.display = "block"; - } else { - content.style.display = "none"; + /** + * Get the font-awesome icon used to display a certain level of skill proficiency + * @private + */ + _getProficiencyIcon(level) { + const icons = { + 0: '', + 0.5: '', + 1: '', + 2: '' + }; + return icons[level] || icons[0]; } - } -/** - * Handle incrementing class level on the actor sheet - * @param {Event} event The originating click event - * @private - */ - - _onIncrementClassLevel(event) { - event.preventDefault(); + /* -------------------------------------------- */ + /* Event Listeners and Handlers + /* -------------------------------------------- */ - const div = event.currentTarget.closest(".character") - const li = event.currentTarget.closest("li"); + /** @inheritdoc */ + activateListeners(html) { + // Activate Item Filters + const filterLists = html.find(".filter-list"); + filterLists.each(this._initializeFilterItemList.bind(this)); + filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this)); - const actorId = div.id.split("-")[1]; - const itemId = li.dataset.itemId; + // Item summaries + html.find(".item .item-name.rollable h4").click((event) => this._onItemSummary(event)); - const actor = game.actors.get(actorId); - const item = actor.items.get(itemId); + // View Item Sheets + html.find(".item-edit").click(this._onItemEdit.bind(this)); - let levels = item.data.data.levels; - const update = {_id: item.data._id, data: {levels: (levels + 1) }}; + // Editable Only Listeners + if (this.isEditable) { + // Input focus and update + const inputs = html.find("input"); + inputs.focus((ev) => ev.currentTarget.select()); + inputs.addBack().find('[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this)); - actor.updateEmbeddedDocuments("Item", [update]); -} - -/** - * Handle decrementing class level on the actor sheet - * @param {Event} event The originating click event - * @private - */ - - _onDecrementClassLevel(event) { - event.preventDefault(); - - const div = event.currentTarget.closest(".character") - const li = event.currentTarget.closest("li"); - - const actorId = div.id.split("-")[1]; - const itemId = li.dataset.itemId; - - const actor = game.actors.get(actorId); - const item = actor.items.get(itemId); - - let levels = item.data.data.levels; - const update = {_id: item.data._id, data: {levels: (levels - 1) }}; - - actor.updateEmbeddedDocuments("Item", [update]); -} - - /* -------------------------------------------- */ - - /** - * Handle rolling an Ability check, either a test or a saving throw - * @param {Event} event The originating click event - * @private - */ - _onRollAbilityTest(event) { - event.preventDefault(); - let ability = event.currentTarget.parentElement.dataset.ability; - return this.actor.rollAbility(ability, {event: event}); - } - - /* -------------------------------------------- */ - - /** - * Handle rolling a Skill check - * @param {Event} event The originating click event - * @private - */ - _onRollSkillCheck(event) { - event.preventDefault(); - const skill = event.currentTarget.parentElement.dataset.skill; - return this.actor.rollSkill(skill, {event: event}); - } - - /* -------------------------------------------- */ - - /** - * Handle toggling Ability score proficiency level - * @param {Event} event The originating click event - * @private - */ - _onToggleAbilityProficiency(event) { - event.preventDefault(); - const field = event.currentTarget.previousElementSibling; - return this.actor.update({[field.name]: 1 - parseInt(field.value)}); - } - - /* -------------------------------------------- */ - - /** - * Handle toggling of filters to display a different set of owned items - * @param {Event} event The click event which triggered the toggle - * @private - */ - _onToggleFilter(event) { - event.preventDefault(); - const li = event.currentTarget; - const set = this._filters[li.parentElement.dataset.filter]; - const filter = li.dataset.filter; - if ( set.has(filter) ) set.delete(filter); - else set.add(filter); - return this.render(); - } - - /* -------------------------------------------- */ - - /** - * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options - * @param {Event} event The click event which originated the selection - * @private - */ - _onTraitSelector(event) { - event.preventDefault(); - const a = event.currentTarget; - const label = a.parentElement.querySelector("label"); - const choices = CONFIG.SW5E[a.dataset.options]; - const options = { name: a.dataset.target, title: label.innerText, choices }; - return new TraitSelector(this.actor, options).render(true) - } - - /* -------------------------------------------- */ - - /** @override */ - _getHeaderButtons() { - let buttons = super._getHeaderButtons(); - if (this.actor.isPolymorphed) { - buttons.unshift({ - label: 'SW5E.PolymorphRestoreTransformation', - class: "restore-transformation", - icon: "fas fa-backward", - onclick: () => this.actor.revertOriginalForm() - }); - } - return buttons; - } + // Ability Proficiency + html.find(".ability-proficiency").click(this._onToggleAbilityProficiency.bind(this)); + + // Toggle Skill Proficiency + html.find(".skill-proficiency").on("click contextmenu", this._onCycleSkillProficiency.bind(this)); + + // Trait Selector + html.find(".trait-selector").click(this._onTraitSelector.bind(this)); + + // Configure Special Flags + html.find(".config-button").click(this._onConfigMenu.bind(this)); + + // Owned Item management + html.find(".item-create").click(this._onItemCreate.bind(this)); + html.find(".item-delete").click(this._onItemDelete.bind(this)); + html.find(".item-collapse").click(this._onItemCollapse.bind(this)); + html.find(".item-uses input") + .click((ev) => ev.target.select()) + .change(this._onUsesChange.bind(this)); + html.find(".slot-max-override").click(this._onPowerSlotOverride.bind(this)); + html.find(".increment-class-level").click(this._onIncrementClassLevel.bind(this)); + html.find(".decrement-class-level").click(this._onDecrementClassLevel.bind(this)); + + // Active Effect management + html.find(".effect-control").click((ev) => onManageActiveEffect(ev, this.actor)); + } + + // Owner Only Listeners + if (this.actor.isOwner) { + // Ability Checks + html.find(".ability-name").click(this._onRollAbilityTest.bind(this)); + + // Roll Skill Checks + html.find(".skill-name").click(this._onRollSkillCheck.bind(this)); + + // Item Rolling + html.find(".item .item-image").click((event) => this._onItemRoll(event)); + html.find(".item .item-recharge").click((event) => this._onItemRecharge(event)); + } + + // Otherwise remove rollable classes + else { + html.find(".rollable").each((i, el) => el.classList.remove("rollable")); + } + + // Handle default listeners last so system listeners are triggered first + super.activateListeners(html); + } + + /* -------------------------------------------- */ + + /** + * Iinitialize Item list filters by activating the set of filters which are currently applied + * @private + */ + _initializeFilterItemList(i, ul) { + const set = this._filters[ul.dataset.filter]; + const filters = ul.querySelectorAll(".filter-item"); + for (let li of filters) { + if (set.has(li.dataset.filter)) li.classList.add("active"); + } + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** + * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs + * @param event + * @private + */ + _onChangeInputDelta(event) { + const input = event.target; + const value = input.value; + if (["+", "-"].includes(value[0])) { + let delta = parseFloat(value); + input.value = getProperty(this.actor.data, input.name) + delta; + } else if (value[0] === "=") { + input.value = value.slice(1); + } + } + + /* -------------------------------------------- */ + + /** + * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options + * @param {Event} event The click event which originated the selection + * @private + */ + _onConfigMenu(event) { + event.preventDefault(); + const button = event.currentTarget; + let app; + switch (button.dataset.action) { + case "hit-dice": + app = new ActorHitDiceConfig(this.object); + break; + case "movement": + app = new ActorMovementConfig(this.object); + break; + case "flags": + app = new ActorSheetFlags(this.object); + break; + case "senses": + app = new ActorSensesConfig(this.object); + break; + case "type": + new ActorTypeConfig(this.object).render(true); + break; + } + app?.render(true); + } + + /* -------------------------------------------- */ + + /** + * Handle cycling proficiency in a Skill + * @param {Event} event A click or contextmenu event which triggered the handler + * @private + */ + _onCycleSkillProficiency(event) { + event.preventDefault(); + const field = $(event.currentTarget).siblings('input[type="hidden"]'); + + // Get the current level and the array of levels + const level = parseFloat(field.val()); + const levels = [0, 1, 0.5, 2]; + let idx = levels.indexOf(level); + + // Toggle next level - forward on click, backwards on right + if (event.type === "click") { + field.val(levels[idx === levels.length - 1 ? 0 : idx + 1]); + } else if (event.type === "contextmenu") { + field.val(levels[idx === 0 ? levels.length - 1 : idx - 1]); + } + + // Update the field value and save the form + this._onSubmit(event); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropActor(event, data) { + const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get("sw5e", "allowPolymorphing")); + if (!canPolymorph) return false; + + // Get the target actor + let sourceActor = null; + if (data.pack) { + const pack = game.packs.find((p) => p.collection === data.pack); + sourceActor = await pack.getEntity(data.id); + } else { + sourceActor = game.actors.get(data.id); + } + if (!sourceActor) return; + + // Define a function to record polymorph settings for future use + const rememberOptions = (html) => { + const options = {}; + html.find("input").each((i, el) => { + options[el.name] = el.checked; + }); + const settings = mergeObject(game.settings.get("sw5e", "polymorphSettings") || {}, options); + game.settings.set("sw5e", "polymorphSettings", settings); + return settings; + }; + + // Create and render the Dialog + return new Dialog( + { + title: game.i18n.localize("SW5E.PolymorphPromptTitle"), + content: { + options: game.settings.get("sw5e", "polymorphSettings"), + i18n: SW5E.polymorphSettings, + isToken: this.actor.isToken + }, + default: "accept", + buttons: { + accept: { + icon: '', + label: game.i18n.localize("SW5E.PolymorphAcceptSettings"), + callback: (html) => this.actor.transformInto(sourceActor, rememberOptions(html)) + }, + wildshape: { + icon: '', + label: game.i18n.localize("SW5E.PolymorphWildShape"), + callback: (html) => + this.actor.transformInto(sourceActor, { + keepBio: true, + keepClass: true, + keepMental: true, + mergeSaves: true, + mergeSkills: true, + transformTokens: rememberOptions(html).transformTokens + }) + }, + polymorph: { + icon: '', + label: game.i18n.localize("SW5E.Polymorph"), + callback: (html) => + this.actor.transformInto(sourceActor, { + transformTokens: rememberOptions(html).transformTokens + }) + }, + cancel: { + icon: '', + label: game.i18n.localize("Cancel") + } + } + }, + { + classes: ["dialog", "sw5e"], + width: 600, + template: "systems/sw5e/templates/apps/polymorph-prompt.html" + } + ).render(true); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropItemCreate(itemData) { + // Check to make sure items of this type are allowed on this actor + if (this.constructor.unsupportedItemTypes.has(itemData.type)) { + return ui.notifications.warn( + game.i18n.format("SW5E.ActorWarningInvalidItem", { + itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]), + actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type]) + }) + ); + } + + // Create a Consumable power scroll on the Inventory tab + if (itemData.type === "power" && this._tabs[0].active === "inventory") { + const scroll = await Item5e.createScrollFromPower(itemData); + itemData = scroll.data; + } + + if (itemData.data) { + // Ignore certain statuses + ["equipped", "proficient", "prepared"].forEach((k) => delete itemData.data[k]); + + // Downgrade ATTUNED to REQUIRED + itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED); + } + + // Stack identical consumables + if (itemData.type === "consumable" && itemData.flags.core?.sourceId) { + const similarItem = this.actor.items.find((i) => { + const sourceId = i.getFlag("core", "sourceId"); + return sourceId && sourceId === itemData.flags.core?.sourceId && i.type === "consumable"; + }); + if (similarItem && itemData.name !== "Power Cell") { + // Always create a new powercell instead of increasing quantity + return similarItem.update({ + "data.quantity": similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1) + }); + } + } + + // Create the owned item as normal + return super._onDropItemCreate(itemData); + } + + /* -------------------------------------------- */ + + /** + * Handle enabling editing for a power slot override value + * @param {MouseEvent} event The originating click event + * @private + */ + async _onPowerSlotOverride(event) { + const span = event.currentTarget.parentElement; + const level = span.dataset.level; + const override = this.actor.data.data.powers[level].override || span.dataset.slots; + const input = document.createElement("INPUT"); + input.type = "text"; + input.name = `data.powers.${level}.override`; + input.value = override; + input.placeholder = span.dataset.slots; + input.dataset.dtype = "Number"; + + // Replace the HTML + const parent = span.parentElement; + parent.removeChild(span); + parent.appendChild(input); + } + + /* -------------------------------------------- */ + + /** + * Change the uses amount of an Owned Item within the Actor + * @param {Event} event The triggering click event + * @private + */ + async _onUsesChange(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max); + event.target.value = uses; + return item.update({"data.uses.value": uses}); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method + * @private + */ + _onItemRoll(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + return item.roll(); + } + + /* -------------------------------------------- */ + + /** + * Handle attempting to recharge an item usage by rolling a recharge check + * @param {Event} event The originating click event + * @private + */ + _onItemRecharge(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + return item.rollRecharge(); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method + * @private + */ + _onItemSummary(event) { + event.preventDefault(); + let li = $(event.currentTarget).parents(".item"), + item = this.actor.items.get(li.data("item-id")), + chatData = item.getChatData({secrets: this.actor.isOwner}); + + // Toggle summary + if (li.hasClass("expanded")) { + let summary = li.children(".item-summary"); + summary.slideUp(200, () => summary.remove()); + } else { + let div = $(`
${chatData.description.value}
`); + let props = $(`
`); + chatData.properties.forEach((p) => props.append(`${p}`)); + div.append(props); + li.append(div.hide()); + div.slideDown(200); + } + li.toggleClass("expanded"); + } + + /* -------------------------------------------- */ + + /** + * Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset + * @param {Event} event The originating click event + * @private + */ + _onItemCreate(event) { + event.preventDefault(); + const header = event.currentTarget; + const type = header.dataset.type; + const itemData = { + name: game.i18n.format("SW5E.ItemNew", {type: game.i18n.localize(`SW5E.ItemType${type.capitalize()}`)}), + type: type, + data: foundry.utils.deepClone(header.dataset) + }; + delete itemData.data["type"]; + return this.actor.createEmbeddedDocuments("Item", [itemData]); + } + + /* -------------------------------------------- */ + + /** + * Handle editing an existing Owned Item for the Actor + * @param {Event} event The originating click event + * @private + */ + _onItemEdit(event) { + event.preventDefault(); + const li = event.currentTarget.closest(".item"); + const item = this.actor.items.get(li.dataset.itemId); + return item.sheet.render(true); + } + + /* -------------------------------------------- */ + + /** + * Handle deleting an existing Owned Item for the Actor + * @param {Event} event The originating click event + * @private + */ + _onItemDelete(event) { + event.preventDefault(); + const li = event.currentTarget.closest(".item"); + const item = this.actor.items.get(li.dataset.itemId); + if (item) return item.delete(); + } + + /** + * Handle collapsing a Feature row on the actor sheet + * @param {Event} event The originating click event + * @private + */ + + _onItemCollapse(event) { + event.preventDefault(); + + event.currentTarget.classList.toggle("active"); + + const li = event.currentTarget.closest("li"); + const content = li.querySelector(".content"); + + if (content.style.display === "none") { + content.style.display = "block"; + } else { + content.style.display = "none"; + } + } + + /** + * Handle incrementing class level on the actor sheet + * @param {Event} event The originating click event + * @private + */ + + _onIncrementClassLevel(event) { + event.preventDefault(); + + const div = event.currentTarget.closest(".character"); + const li = event.currentTarget.closest("li"); + + const actorId = div.id.split("-")[1]; + const itemId = li.dataset.itemId; + + const actor = game.actors.get(actorId); + const item = actor.items.get(itemId); + + let levels = item.data.data.levels; + const update = {_id: item.data._id, data: {levels: levels + 1}}; + + actor.updateEmbeddedDocuments("Item", [update]); + } + + /** + * Handle decrementing class level on the actor sheet + * @param {Event} event The originating click event + * @private + */ + + _onDecrementClassLevel(event) { + event.preventDefault(); + + const div = event.currentTarget.closest(".character"); + const li = event.currentTarget.closest("li"); + + const actorId = div.id.split("-")[1]; + const itemId = li.dataset.itemId; + + const actor = game.actors.get(actorId); + const item = actor.items.get(itemId); + + let levels = item.data.data.levels; + const update = {_id: item.data._id, data: {levels: levels - 1}}; + + actor.updateEmbeddedDocuments("Item", [update]); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling an Ability check, either a test or a saving throw + * @param {Event} event The originating click event + * @private + */ + _onRollAbilityTest(event) { + event.preventDefault(); + let ability = event.currentTarget.parentElement.dataset.ability; + return this.actor.rollAbility(ability, {event: event}); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling a Skill check + * @param {Event} event The originating click event + * @private + */ + _onRollSkillCheck(event) { + event.preventDefault(); + const skill = event.currentTarget.parentElement.dataset.skill; + return this.actor.rollSkill(skill, {event: event}); + } + + /* -------------------------------------------- */ + + /** + * Handle toggling Ability score proficiency level + * @param {Event} event The originating click event + * @private + */ + _onToggleAbilityProficiency(event) { + event.preventDefault(); + const field = event.currentTarget.previousElementSibling; + return this.actor.update({[field.name]: 1 - parseInt(field.value)}); + } + + /* -------------------------------------------- */ + + /** + * Handle toggling of filters to display a different set of owned items + * @param {Event} event The click event which triggered the toggle + * @private + */ + _onToggleFilter(event) { + event.preventDefault(); + const li = event.currentTarget; + const set = this._filters[li.parentElement.dataset.filter]; + const filter = li.dataset.filter; + if (set.has(filter)) set.delete(filter); + else set.add(filter); + return this.render(); + } + + /* -------------------------------------------- */ + + /** + * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options + * @param {Event} event The click event which originated the selection + * @private + */ + _onTraitSelector(event) { + event.preventDefault(); + const a = event.currentTarget; + const label = a.parentElement.querySelector("label"); + const choices = CONFIG.SW5E[a.dataset.options]; + const options = {name: a.dataset.target, title: label.innerText, choices}; + return new TraitSelector(this.actor, options).render(true); + } + + /* -------------------------------------------- */ + + /** @override */ + _getHeaderButtons() { + let buttons = super._getHeaderButtons(); + if (this.actor.isPolymorphed) { + buttons.unshift({ + label: "SW5E.PolymorphRestoreTransformation", + class: "restore-transformation", + icon: "fas fa-backward", + onclick: () => this.actor.revertOriginalForm() + }); + } + return buttons; + } } diff --git a/module/actor/sheets/newSheet/character.js b/module/actor/sheets/newSheet/character.js index 3390c482..91c68b1d 100644 --- a/module/actor/sheets/newSheet/character.js +++ b/module/actor/sheets/newSheet/character.js @@ -7,246 +7,339 @@ import Actor5e from "../../entity.js"; * @type {ActorSheet5e} */ export default class ActorSheet5eCharacterNew extends ActorSheet5e { + get template() { + if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/newActor/limited-sheet.html"; + return "systems/sw5e/templates/actors/newActor/character-sheet.html"; + } + /** + * Define default rendering options for the NPC sheet + * @return {Object} + */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["swalt", "sw5e", "sheet", "actor", "character"], + blockFavTab: true, + subTabs: null, + width: 800, + tabs: [ + { + navSelector: ".root-tabs", + contentSelector: ".sheet-body", + initial: "attributes" + } + ] + }); + } - get template() { - if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/newActor/limited-sheet.html"; - return "systems/sw5e/templates/actors/newActor/character-sheet.html"; - } - /** - * Define default rendering options for the NPC sheet - * @return {Object} - */ - static get defaultOptions() { + /* -------------------------------------------- */ - return mergeObject(super.defaultOptions, { - classes: ["swalt", "sw5e", "sheet", "actor", "character"], - blockFavTab: true, - subTabs: null, - width: 800, - tabs: [{ - navSelector: ".root-tabs", - contentSelector: ".sheet-body", - initial: "attributes" - }], - }); - } + /** + * Add some extra data when rendering the sheet to reduce the amount of logic required within the template. + */ + getData() { + const sheetData = super.getData(); - /* -------------------------------------------- */ + // Temporary HP + let hp = sheetData.data.attributes.hp; + if (hp.temp === 0) delete hp.temp; + if (hp.tempmax === 0) delete hp.tempmax; - /** - * Add some extra data when rendering the sheet to reduce the amount of logic required within the template. - */ - getData() { - const sheetData = super.getData(); + // Resources + sheetData["resources"] = ["primary", "secondary", "tertiary"].reduce((arr, r) => { + const res = sheetData.data.resources[r] || {}; + res.name = r; + res.placeholder = game.i18n.localize("SW5E.Resource" + r.titleCase()); + if (res && res.value === 0) delete res.value; + if (res && res.max === 0) delete res.max; + return arr.concat([res]); + }, []); - // Temporary HP - let hp = sheetData.data.attributes.hp; - if (hp.temp === 0) delete hp.temp; - if (hp.tempmax === 0) delete hp.tempmax; + // Experience Tracking + sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking"); + sheetData["classLabels"] = this.actor.itemTypes.class.map((c) => c.name).join(", "); + sheetData["multiclassLabels"] = this.actor.itemTypes.class + .map((c) => { + return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(" "); + }) + .join(", "); - // Resources - sheetData["resources"] = ["primary", "secondary", "tertiary"].reduce((arr, r) => { - const res = sheetData.data.resources[r] || {}; - res.name = r; - res.placeholder = game.i18n.localize("SW5E.Resource"+r.titleCase()); - if (res && res.value === 0) delete res.value; - if (res && res.max === 0) delete res.max; - return arr.concat([res]); - }, []); + // Return data for rendering + return sheetData; + } - // Experience Tracking - sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking"); - sheetData["classLabels"] = this.actor.itemTypes.class.map(c => c.name).join(", "); - sheetData["multiclassLabels"] = this.actor.itemTypes.class.map(c => { - return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(' ') - }).join(', '); + /* -------------------------------------------- */ - // Return data for rendering - return sheetData; - } + /** + * Organize and classify Owned Items for Character sheets + * @private + */ + _prepareItems(data) { + // Categorize items as inventory, powerbook, features, and classes + const inventory = { + weapon: {label: "SW5E.ItemTypeWeaponPl", items: [], dataset: {type: "weapon"}}, + equipment: {label: "SW5E.ItemTypeEquipmentPl", items: [], dataset: {type: "equipment"}}, + consumable: {label: "SW5E.ItemTypeConsumablePl", items: [], dataset: {type: "consumable"}}, + tool: {label: "SW5E.ItemTypeToolPl", items: [], dataset: {type: "tool"}}, + backpack: {label: "SW5E.ItemTypeContainerPl", items: [], dataset: {type: "backpack"}}, + loot: {label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"}} + }; - /* -------------------------------------------- */ + // Partition items by category + let [ + items, + forcepowers, + techpowers, + feats, + classes, + deployments, + deploymentfeatures, + ventures, + species, + archetypes, + classfeatures, + backgrounds, + fightingstyles, + fightingmasteries, + lightsaberforms + ] = data.items.reduce( + (arr, item) => { + // Item details + item.img = item.img || CONST.DEFAULT_TOKEN; + item.isStack = Number.isNumeric(item.data.quantity) && item.data.quantity !== 1; + item.attunement = { + [CONFIG.SW5E.attunementTypes.REQUIRED]: { + icon: "fa-sun", + cls: "not-attuned", + title: "SW5E.AttunementRequired" + }, + [CONFIG.SW5E.attunementTypes.ATTUNED]: { + icon: "fa-sun", + cls: "attuned", + title: "SW5E.AttunementAttuned" + } + }[item.data.attunement]; - /** - * Organize and classify Owned Items for Character sheets - * @private - */ - _prepareItems(data) { + // Item usage + item.hasUses = item.data.uses && item.data.uses.max > 0; + item.isOnCooldown = + item.data.recharge && !!item.data.recharge.value && item.data.recharge.charged === false; + item.isDepleted = item.isOnCooldown && item.data.uses.per && item.data.uses.value > 0; + item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type); - // Categorize items as inventory, powerbook, features, and classes - const inventory = { - weapon: { label: "SW5E.ItemTypeWeaponPl", items: [], dataset: {type: "weapon"} }, - equipment: { label: "SW5E.ItemTypeEquipmentPl", items: [], dataset: {type: "equipment"} }, - consumable: { label: "SW5E.ItemTypeConsumablePl", items: [], dataset: {type: "consumable"} }, - tool: { label: "SW5E.ItemTypeToolPl", items: [], dataset: {type: "tool"} }, - backpack: { label: "SW5E.ItemTypeContainerPl", items: [], dataset: {type: "backpack"} }, - loot: { label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"} } - }; + // Item toggle state + this._prepareItemToggleState(item); - // Partition items by category - let [items, forcepowers, techpowers, feats, classes, deployments, deploymentfeatures, ventures, species, archetypes, classfeatures, backgrounds, fightingstyles, fightingmasteries, lightsaberforms] = data.items.reduce((arr, item) => { + // Primary Class + if (item.type === "class") + item.isOriginalClass = item._id === this.actor.data.data.details.originalClass; - // Item details - item.img = item.img || CONST.DEFAULT_TOKEN; - item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); - item.attunement = { - [CONFIG.SW5E.attunementTypes.REQUIRED]: { - icon: "fa-sun", - cls: "not-attuned", - title: "SW5E.AttunementRequired" - }, - [CONFIG.SW5E.attunementTypes.ATTUNED]: { - icon: "fa-sun", - cls: "attuned", - title: "SW5E.AttunementAttuned" + // Classify items into types + if (item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school)) arr[1].push(item); + else if (item.type === "power" && ["tec"].includes(item.data.school)) arr[2].push(item); + else if (item.type === "feat") arr[3].push(item); + else if (item.type === "class") arr[4].push(item); + else if (item.type === "deployment") arr[5].push(item); + else if (item.type === "deploymentfeature") arr[6].push(item); + else if (item.type === "venture") arr[7].push(item); + else if (item.type === "species") arr[8].push(item); + else if (item.type === "archetype") arr[9].push(item); + else if (item.type === "classfeature") arr[10].push(item); + else if (item.type === "background") arr[11].push(item); + else if (item.type === "fightingstyle") arr[12].push(item); + else if (item.type === "fightingmastery") arr[13].push(item); + else if (item.type === "lightsaberform") arr[14].push(item); + else if (Object.keys(inventory).includes(item.type)) arr[0].push(item); + return arr; + }, + [[], [], [], [], [], [], [], [], [], [], [], [], [], [], []] + ); + + // Apply active item filters + items = this._filterItems(items, this._filters.inventory); + forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook); + techpowers = this._filterItems(techpowers, this._filters.techPowerbook); + feats = this._filterItems(feats, this._filters.features); + + // Organize items + for (let i of items) { + i.data.quantity = i.data.quantity || 0; + i.data.weight = i.data.weight || 0; + i.totalWeight = (i.data.quantity * i.data.weight).toNearest(0.1); + inventory[i.type].items.push(i); } - }[item.data.attunement]; - // Item usage - item.hasUses = item.data.uses && (item.data.uses.max > 0); - item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); - item.isDepleted = item.isOnCooldown && (item.data.uses.per && (item.data.uses.value > 0)); - item.hasTarget = !!item.data.target && !(["none",""].includes(item.data.target.type)); + // Organize Powerbook and count the number of prepared powers (excluding always, at will, etc...) + const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni"); + const techPowerbook = this._preparePowerbook(data, techpowers, "tec"); - // Item toggle state - this._prepareItemToggleState(item); + // Organize Features + const features = { + classes: { + label: "SW5E.ItemTypeClassPl", + items: [], + hasActions: false, + dataset: {type: "class"}, + isClass: true + }, + classfeatures: { + label: "SW5E.ItemTypeClassFeats", + items: [], + hasActions: true, + dataset: {type: "classfeature"}, + isClassfeature: true + }, + archetype: { + label: "SW5E.ItemTypeArchetype", + items: [], + hasActions: false, + dataset: {type: "archetype"}, + isArchetype: true + }, + deployments: { + label: "SW5E.ItemTypeDeploymentPl", + items: [], + hasActions: false, + dataset: {type: "deployment"}, + isDeployment: true + }, + deploymentfeatures: { + label: "SW5E.ItemTypeDeploymentFeaturePl", + items: [], + hasActions: true, + dataset: {type: "deploymentfeature"}, + isDeploymentfeature: true + }, + ventures: { + label: "SW5E.ItemTypeVenturePl", + items: [], + hasActions: false, + dataset: {type: "venture"}, + isVenture: true + }, + species: { + label: "SW5E.ItemTypeSpecies", + items: [], + hasActions: false, + dataset: {type: "species"}, + isSpecies: true + }, + background: { + label: "SW5E.ItemTypeBackground", + items: [], + hasActions: false, + dataset: {type: "background"}, + isBackground: true + }, + fightingstyles: { + label: "SW5E.ItemTypeFightingStylePl", + items: [], + hasActions: false, + dataset: {type: "fightingstyle"}, + isFightingstyle: true + }, + fightingmasteries: { + label: "SW5E.ItemTypeFightingMasteryPl", + items: [], + hasActions: false, + dataset: {type: "fightingmastery"}, + isFightingmastery: true + }, + lightsaberforms: { + label: "SW5E.ItemTypeLightsaberFormPl", + items: [], + hasActions: false, + dataset: {type: "lightsaberform"}, + isLightsaberform: true + }, + active: { + label: "SW5E.FeatureActive", + items: [], + hasActions: true, + dataset: {"type": "feat", "activation.type": "action"} + }, + passive: {label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"}} + }; + for (let f of feats) { + if (f.data.activation.type) features.active.items.push(f); + else features.passive.items.push(f); + } + classes.sort((a, b) => b.data.levels - a.data.levels); + features.classes.items = classes; + features.classfeatures.items = classfeatures; + features.archetype.items = archetypes; + features.deployments.items = deployments; + features.deploymentfeatures.items = deploymentfeatures; + features.ventures.items = ventures; + features.species.items = species; + features.background.items = backgrounds; + features.fightingstyles.items = fightingstyles; + features.fightingmasteries.items = fightingmasteries; + features.lightsaberforms.items = lightsaberforms; - // Primary Class - if ( item.type === "class" ) item.isOriginalClass = ( item._id === this.actor.data.data.details.originalClass ); - - // Classify items into types - if ( item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school) ) arr[1].push(item); - else if ( item.type === "power" && ["tec"].includes(item.data.school) ) arr[2].push(item); - else if ( item.type === "feat" ) arr[3].push(item); - else if ( item.type === "class" ) arr[4].push(item); - else if ( item.type === "deployment" ) arr[5].push(item); - else if ( item.type === "deploymentfeature" ) arr[6].push(item); - else if ( item.type === "venture" ) arr[7].push(item); - else if ( item.type === "species" ) arr[8].push(item); - else if ( item.type === "archetype" ) arr[9].push(item); - else if ( item.type === "classfeature" ) arr[10].push(item); - else if ( item.type === "background" ) arr[11].push(item); - else if ( item.type === "fightingstyle" ) arr[12].push(item); - else if ( item.type === "fightingmastery" ) arr[13].push(item); - else if ( item.type === "lightsaberform" ) arr[14].push(item); - else if ( Object.keys(inventory).includes(item.type ) ) arr[0].push(item); - return arr; - }, [[], [], [], [], [], [], [], [], [], [], [], [], [], [], []]); - - // Apply active item filters - items = this._filterItems(items, this._filters.inventory); - forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook); - techpowers = this._filterItems(techpowers, this._filters.techPowerbook); - feats = this._filterItems(feats, this._filters.features); - - // Organize items - for ( let i of items ) { - i.data.quantity = i.data.quantity || 0; - i.data.weight = i.data.weight || 0; - i.totalWeight = (i.data.quantity * i.data.weight).toNearest(0.1); - inventory[i.type].items.push(i); + // Assign and return + data.inventory = Object.values(inventory); + data.forcePowerbook = forcePowerbook; + data.techPowerbook = techPowerbook; + data.features = Object.values(features); } - // Organize Powerbook and count the number of prepared powers (excluding always, at will, etc...) - const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni"); - const techPowerbook = this._preparePowerbook(data, techpowers, "tec"); + /* -------------------------------------------- */ - // Organize Features - const features = { - classes: { label: "SW5E.ItemTypeClassPl", items: [], hasActions: false, dataset: {type: "class"}, isClass: true }, - classfeatures: { label: "SW5E.ItemTypeClassFeats", items: [], hasActions: true, dataset: {type: "classfeature"}, isClassfeature: true }, - archetype: { label: "SW5E.ItemTypeArchetype", items: [], hasActions: false, dataset: {type: "archetype"}, isArchetype: true }, - deployments: { label: "SW5E.ItemTypeDeploymentPl", items: [], hasActions: false, dataset: {type: "deployment"}, isDeployment: true }, - deploymentfeatures: { label: "SW5E.ItemTypeDeploymentFeaturePl", items: [], hasActions: true, dataset: {type: "deploymentfeature"}, isDeploymentfeature: true }, - ventures: { label: "SW5E.ItemTypeVenturePl", items: [], hasActions: false, dataset: {type: "venture"}, isVenture: true }, - species: { label: "SW5E.ItemTypeSpecies", items: [], hasActions: false, dataset: {type: "species"}, isSpecies: true }, - background: { label: "SW5E.ItemTypeBackground", items: [], hasActions: false, dataset: {type: "background"}, isBackground: true }, - fightingstyles: { label: "SW5E.ItemTypeFightingStylePl", items: [], hasActions: false, dataset: {type: "fightingstyle"}, isFightingstyle: true }, - fightingmasteries: { label: "SW5E.ItemTypeFightingMasteryPl", items: [], hasActions: false, dataset: {type: "fightingmastery"}, isFightingmastery: true }, - lightsaberforms: { label: "SW5E.ItemTypeLightsaberFormPl", items: [], hasActions: false, dataset: {type: "lightsaberform"}, isLightsaberform: true }, - active: { label: "SW5E.FeatureActive", items: [], hasActions: true, dataset: {type: "feat", "activation.type": "action"} }, - passive: { label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"} } - }; - for ( let f of feats ) { - if ( f.data.activation.type ) features.active.items.push(f); - else features.passive.items.push(f); + /** + * A helper method to establish the displayed preparation state for an item + * @param {Item} item + * @private + */ + _prepareItemToggleState(item) { + if (item.type === "power") { + const isAlways = getProperty(item.data, "preparation.mode") === "always"; + const isPrepared = getProperty(item.data, "preparation.prepared"); + item.toggleClass = isPrepared ? "active" : ""; + if (isAlways) item.toggleClass = "fixed"; + if (isAlways) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.always; + else if (isPrepared) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.prepared; + else item.toggleTitle = game.i18n.localize("SW5E.PowerUnprepared"); + } else { + const isActive = getProperty(item.data, "equipped"); + item.toggleClass = isActive ? "active" : ""; + item.toggleTitle = game.i18n.localize(isActive ? "SW5E.Equipped" : "SW5E.Unequipped"); + } } - classes.sort((a, b) => b.data.levels - a.data.levels); - features.classes.items = classes; - features.classfeatures.items = classfeatures; - features.archetype.items = archetypes; - features.deployments.items = deployments; - features.deploymentfeatures.items = deploymentfeatures; - features.ventures.items = ventures; - features.species.items = species; - features.background.items = backgrounds; - features.fightingstyles.items = fightingstyles; - features.fightingmasteries.items = fightingmasteries; - features.lightsaberforms.items = lightsaberforms; - - // Assign and return - data.inventory = Object.values(inventory); - data.forcePowerbook = forcePowerbook; - data.techPowerbook = techPowerbook; - data.features = Object.values(features); - } + /* -------------------------------------------- */ + /* Event Listeners and Handlers /* -------------------------------------------- */ - /** - * A helper method to establish the displayed preparation state for an item - * @param {Item} item - * @private - */ - _prepareItemToggleState(item) { - if (item.type === "power") { - const isAlways = getProperty(item.data, "preparation.mode") === "always"; - const isPrepared = getProperty(item.data, "preparation.prepared"); - item.toggleClass = isPrepared ? "active" : ""; - if ( isAlways ) item.toggleClass = "fixed"; - if ( isAlways ) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.always; - else if ( isPrepared ) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.prepared; - else item.toggleTitle = game.i18n.localize("SW5E.PowerUnprepared"); - } - else { - const isActive = getProperty(item.data, "equipped"); - item.toggleClass = isActive ? "active" : ""; - item.toggleTitle = game.i18n.localize(isActive ? "SW5E.Equipped" : "SW5E.Unequipped"); - } - } + /** + * Activate event listeners using the prepared sheet HTML + * @param html {jQuery} The prepared HTML object ready to be rendered into the DOM + */ + activateListeners(html) { + super.activateListeners(html); + if (!this.isEditable) return; - /* -------------------------------------------- */ - /* Event Listeners and Handlers - /* -------------------------------------------- */ + // Inventory Functions + // html.find(".currency-convert").click(this._onConvertCurrency.bind(this)); - /** - * Activate event listeners using the prepared sheet HTML - * @param html {jQuery} The prepared HTML object ready to be rendered into the DOM - */ - activateListeners(html) { - super.activateListeners(html); - if ( !this.isEditable ) return; + // Item State Toggling + html.find(".item-toggle").click(this._onToggleItem.bind(this)); - // Inventory Functions - // html.find(".currency-convert").click(this._onConvertCurrency.bind(this)); + // Short and Long Rest + html.find(".short-rest").click(this._onShortRest.bind(this)); + html.find(".long-rest").click(this._onLongRest.bind(this)); - // Item State Toggling - html.find('.item-toggle').click(this._onToggleItem.bind(this)); + // Rollable sheet actions + html.find(".rollable[data-action]").click(this._onSheetAction.bind(this)); - // Short and Long Rest - html.find('.short-rest').click(this._onShortRest.bind(this)); - html.find('.long-rest').click(this._onLongRest.bind(this)); - - // Rollable sheet actions - html.find(".rollable[data-action]").click(this._onSheetAction.bind(this)); - - // Send Languages to Chat onClick - html.find('[data-options="share-languages"]').click(event => { - event.preventDefault(); - let langs = this.actor.data.data.traits.languages.value.map(l => CONFIG.SW5E.languages[l] || l).join(", "); - let custom = this.actor.data.data.traits.languages.custom; - if (custom) langs += ", " + custom.replace(/;/g, ","); - let content = ` + // Send Languages to Chat onClick + html.find('[data-options="share-languages"]').click((event) => { + event.preventDefault(); + let langs = this.actor.data.data.traits.languages.value + .map((l) => CONFIG.SW5E.languages[l] || l) + .join(", "); + let custom = this.actor.data.data.traits.languages.custom; + if (custom) langs += ", " + custom.replace(/;/g, ","); + let content = `
@@ -256,404 +349,404 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e {
`; - // Send to Chat - let rollBlind = false; - let rollMode = game.settings.get("core", "rollMode"); - if (rollMode === "blindroll") rollBlind = true; - let data = { - user: game.user.data._id, - content: content, - blind: rollBlind, - speaker: { - actor: this.actor.data._id, - token: this.actor.token, - alias: this.actor.name - }, - type: CONST.CHAT_MESSAGE_TYPES.OTHER - }; + // Send to Chat + let rollBlind = false; + let rollMode = game.settings.get("core", "rollMode"); + if (rollMode === "blindroll") rollBlind = true; + let data = { + user: game.user.data._id, + content: content, + blind: rollBlind, + speaker: { + actor: this.actor.data._id, + token: this.actor.token, + alias: this.actor.name + }, + type: CONST.CHAT_MESSAGE_TYPES.OTHER + }; - if (["gmroll", "blindroll"].includes(rollMode)) data["whisper"] = ChatMessage.getWhisperRecipients("GM"); - else if (rollMode === "selfroll") data["whisper"] = [game.users.get(game.user.data._id)]; + if (["gmroll", "blindroll"].includes(rollMode)) data["whisper"] = ChatMessage.getWhisperRecipients("GM"); + else if (rollMode === "selfroll") data["whisper"] = [game.users.get(game.user.data._id)]; - ChatMessage.create(data); - }); + ChatMessage.create(data); + }); - // Item Delete Confirmation - html.find('.item-delete').off("click"); - html.find('.item-delete').click(event => { - let li = $(event.currentTarget).parents('.item'); - let itemId = li.attr("data-item-id"); - let item = this.actor.items.get(itemId); - new Dialog({ - title: `Deleting ${item.data.name}`, - content: `

Are you sure you want to delete ${item.data.name}?

`, - buttons: { - Yes: { - icon: '', - label: 'Yes', - callback: dlg => { - this.actor.deleteOwnedItem(itemId); - } - }, - cancel: { - icon: '', - label: 'No' - }, - }, - default: 'cancel' - }).render(true); - }); - } - - /* -------------------------------------------- */ - - /** - * Handle mouse click events for character sheet actions - * @param {MouseEvent} event The originating click event - * @private - */ - _onSheetAction(event) { - event.preventDefault(); - const button = event.currentTarget; - switch( button.dataset.action ) { - case "rollDeathSave": - return this.actor.rollDeathSave({event: event}); - case "rollInitiative": - return this.actor.rollInitiative({createCombatants: true}); + // Item Delete Confirmation + html.find(".item-delete").off("click"); + html.find(".item-delete").click((event) => { + let li = $(event.currentTarget).parents(".item"); + let itemId = li.attr("data-item-id"); + let item = this.actor.items.get(itemId); + new Dialog({ + title: `Deleting ${item.data.name}`, + content: `

Are you sure you want to delete ${item.data.name}?

`, + buttons: { + Yes: { + icon: '', + label: "Yes", + callback: (dlg) => { + this.actor.deleteOwnedItem(itemId); + } + }, + cancel: { + icon: '', + label: "No" + } + }, + default: "cancel" + }).render(true); + }); } - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - - /** - * Handle toggling the state of an Owned Item within the Actor - * @param {Event} event The triggering click event - * @private - */ - _onToggleItem(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped"; - return item.update({[attr]: !getProperty(item.data, attr)}); - } - - /* -------------------------------------------- */ - - /** - * Take a short rest, calling the relevant function on the Actor instance - * @param {Event} event The triggering click event - * @private - */ - async _onShortRest(event) { - event.preventDefault(); - await this._onSubmit(event); - return this.actor.shortRest(); - } - - /* -------------------------------------------- */ - - /** - * Take a long rest, calling the relevant function on the Actor instance - * @param {Event} event The triggering click event - * @private - */ - async _onLongRest(event) { - event.preventDefault(); - await this._onSubmit(event); - return this.actor.longRest(); - } - - /* -------------------------------------------- */ - - /** @override */ - async _onDropItemCreate(itemData) { - - // Increment the number of class levels of a character instead of creating a new item - if ( itemData.type === "class" ) { - const cls = this.actor.itemTypes.class.find(c => c.name === itemData.name); - let priorLevel = cls?.data.data.levels ?? 0; - if ( !!cls ) { - const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level); - if ( next > priorLevel ) { - itemData.levels = next; - return cls.update({"data.levels": next}); + /** + * Handle mouse click events for character sheet actions + * @param {MouseEvent} event The originating click event + * @private + */ + _onSheetAction(event) { + event.preventDefault(); + const button = event.currentTarget; + switch (button.dataset.action) { + case "rollDeathSave": + return this.actor.rollDeathSave({event: event}); + case "rollInitiative": + return this.actor.rollInitiative({createCombatants: true}); } - } } - // Increment the number of deployment ranks of a character instead of creating a new item - // else if ( itemData.type === "deployment" ) { - // const rnk = this.actor.itemTypes.deployment.find(c => c.name === itemData.name); - // let priorRank = rnk?.data.data.ranks ?? 0; - // if ( !!rnk ) { - // const next = Math.min(priorLevel + 1, 5 + priorRank - this.actor.data.data.details.rank); - // if ( next > priorRank ) { - // itemData.ranks = next; - // return rnk.update({"data.ranks": next}); - // } - // } - // } + /* -------------------------------------------- */ - // Default drop handling if levels were not added - return super._onDropItemCreate(itemData); - } + /** + * Handle toggling the state of an Owned Item within the Actor + * @param {Event} event The triggering click event + * @private + */ + _onToggleItem(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped"; + return item.update({[attr]: !getProperty(item.data, attr)}); + } + + /* -------------------------------------------- */ + + /** + * Take a short rest, calling the relevant function on the Actor instance + * @param {Event} event The triggering click event + * @private + */ + async _onShortRest(event) { + event.preventDefault(); + await this._onSubmit(event); + return this.actor.shortRest(); + } + + /* -------------------------------------------- */ + + /** + * Take a long rest, calling the relevant function on the Actor instance + * @param {Event} event The triggering click event + * @private + */ + async _onLongRest(event) { + event.preventDefault(); + await this._onSubmit(event); + return this.actor.longRest(); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropItemCreate(itemData) { + // Increment the number of class levels of a character instead of creating a new item + if (itemData.type === "class") { + const cls = this.actor.itemTypes.class.find((c) => c.name === itemData.name); + let priorLevel = cls?.data.data.levels ?? 0; + if (!!cls) { + const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level); + if (next > priorLevel) { + itemData.levels = next; + return cls.update({"data.levels": next}); + } + } + } + + // Increment the number of deployment ranks of a character instead of creating a new item + // else if ( itemData.type === "deployment" ) { + // const rnk = this.actor.itemTypes.deployment.find(c => c.name === itemData.name); + // let priorRank = rnk?.data.data.ranks ?? 0; + // if ( !!rnk ) { + // const next = Math.min(priorLevel + 1, 5 + priorRank - this.actor.data.data.details.rank); + // if ( next > priorRank ) { + // itemData.ranks = next; + // return rnk.update({"data.ranks": next}); + // } + // } + // } + + // Default drop handling if levels were not added + return super._onDropItemCreate(itemData); + } } async function addFavorites(app, html, data) { - // Thisfunction is adapted for the SwaltSheet from the Favorites Item - // Tab Module created for Foundry VTT - by Felix Müller (Felix#6196 on Discord). - // It is licensed under a Creative Commons Attribution 4.0 International License - // and can be found at https://github.com/syl3r86/favtab. - let favItems = []; - let favFeats = []; - let favPowers = { - 0: { - isCantrip: true, - powers: [] - }, - 1: { - powers: [], - value: data.actor.data.powers.power1.value, - max: data.actor.data.powers.power1.max - }, - 2: { - powers: [], - value: data.actor.data.powers.power2.value, - max: data.actor.data.powers.power2.max - }, - 3: { - powers: [], - value: data.actor.data.powers.power3.value, - max: data.actor.data.powers.power3.max - }, - 4: { - powers: [], - value: data.actor.data.powers.power4.value, - max: data.actor.data.powers.power4.max - }, - 5: { - powers: [], - value: data.actor.data.powers.power5.value, - max: data.actor.data.powers.power5.max - }, - 6: { - powers: [], - value: data.actor.data.powers.power6.value, - max: data.actor.data.powers.power6.max - }, - 7: { - powers: [], - value: data.actor.data.powers.power7.value, - max: data.actor.data.powers.power7.max - }, - 8: { - powers: [], - value: data.actor.data.powers.power8.value, - max: data.actor.data.powers.power8.max - }, - 9: { - powers: [], - value: data.actor.data.powers.power9.value, - max: data.actor.data.powers.power9.max - } - } + // Thisfunction is adapted for the SwaltSheet from the Favorites Item + // Tab Module created for Foundry VTT - by Felix Müller (Felix#6196 on Discord). + // It is licensed under a Creative Commons Attribution 4.0 International License + // and can be found at https://github.com/syl3r86/favtab. + let favItems = []; + let favFeats = []; + let favPowers = { + 0: { + isCantrip: true, + powers: [] + }, + 1: { + powers: [], + value: data.actor.data.powers.power1.value, + max: data.actor.data.powers.power1.max + }, + 2: { + powers: [], + value: data.actor.data.powers.power2.value, + max: data.actor.data.powers.power2.max + }, + 3: { + powers: [], + value: data.actor.data.powers.power3.value, + max: data.actor.data.powers.power3.max + }, + 4: { + powers: [], + value: data.actor.data.powers.power4.value, + max: data.actor.data.powers.power4.max + }, + 5: { + powers: [], + value: data.actor.data.powers.power5.value, + max: data.actor.data.powers.power5.max + }, + 6: { + powers: [], + value: data.actor.data.powers.power6.value, + max: data.actor.data.powers.power6.max + }, + 7: { + powers: [], + value: data.actor.data.powers.power7.value, + max: data.actor.data.powers.power7.max + }, + 8: { + powers: [], + value: data.actor.data.powers.power8.value, + max: data.actor.data.powers.power8.max + }, + 9: { + powers: [], + value: data.actor.data.powers.power9.value, + max: data.actor.data.powers.power9.max + } + }; - let powerCount = 0 - let items = data.actor.items; - for (let item of items) { - if (item.type == "class") continue; - if (item.flags.favtab === undefined || item.flags.favtab.isFavourite === undefined) { - item.flags.favtab = { - isFavourite: false - }; + let powerCount = 0; + let items = data.actor.items; + for (let item of items) { + if (item.type == "class") continue; + if (item.flags.favtab === undefined || item.flags.favtab.isFavourite === undefined) { + item.flags.favtab = { + isFavourite: false + }; + } + let isFav = item.flags.favtab.isFavourite; + if (app.options.editable) { + let favBtn = $( + `` + ); + favBtn.click((ev) => { + app.actor.items.get(item.data._id).update({ + "flags.favtab.isFavourite": !item.flags.favtab.isFavourite + }); + }); + html.find(`.item[data-item-id="${item.data._id}"]`).find(".item-controls").prepend(favBtn); + } + + if (isFav) { + item.powerComps = ""; + if (item.data.components) { + let comps = item.data.components; + let v = comps.vocal ? "V" : ""; + let s = comps.somatic ? "S" : ""; + let m = comps.material ? "M" : ""; + let c = !!comps.concentration; + let r = !!comps.ritual; + item.powerComps = `${v}${s}${m}`; + item.powerCon = c; + item.powerRit = r; + } + + item.editable = app.options.editable; + switch (item.type) { + case "feat": + if (item.flags.favtab.sort === undefined) { + item.flags.favtab.sort = (favFeats.count + 1) * 100000; // initial sort key if not present + } + favFeats.push(item); + break; + case "power": + if (item.data.preparation.mode) { + item.powerPrepMode = ` (${CONFIG.SW5E.powerPreparationModes[item.data.preparation.mode]})`; + } + if (item.data.level) { + favPowers[item.data.level].powers.push(item); + } else { + favPowers[0].powers.push(item); + } + powerCount++; + break; + default: + if (item.flags.favtab.sort === undefined) { + item.flags.favtab.sort = (favItems.count + 1) * 100000; // initial sort key if not present + } + favItems.push(item); + break; + } + } } - let isFav = item.flags.favtab.isFavourite; + + // Alter core CSS to fit new button + // if (app.options.editable) { + // html.find('.powerbook .item-controls').css('flex', '0 0 88px'); + // html.find('.inventory .item-controls, .features .item-controls').css('flex', '0 0 90px'); + // html.find('.favourite .item-controls').css('flex', '0 0 22px'); + // } + + let tabContainer = html.find(".favtabtarget"); + data.favItems = favItems.length > 0 ? favItems.sort((a, b) => a.flags.favtab.sort - b.flags.favtab.sort) : false; + data.favFeats = favFeats.length > 0 ? favFeats.sort((a, b) => a.flags.favtab.sort - b.flags.favtab.sort) : false; + data.favPowers = powerCount > 0 ? favPowers : false; + data.editable = app.options.editable; + + await loadTemplates(["systems/sw5e/templates/actors/newActor/item.hbs"]); + let favtabHtml = $(await renderTemplate("systems/sw5e/templates/actors/newActor/template.hbs", data)); + favtabHtml.find(".item-name h4").click((event) => app._onItemSummary(event)); + if (app.options.editable) { - let favBtn = $(``); - favBtn.click(ev => { - app.actor.items.get(item.data._id).update({ - "flags.favtab.isFavourite": !item.flags.favtab.isFavourite + favtabHtml.find(".item-image").click((ev) => app._onItemRoll(ev)); + let handler = (ev) => app._onDragStart(ev); + favtabHtml.find(".item").each((i, li) => { + if (li.classList.contains("inventory-header")) return; + li.setAttribute("draggable", true); + li.addEventListener("dragstart", handler, false); + }); + //favtabHtml.find('.item-toggle').click(event => app._onToggleItem(event)); + favtabHtml.find(".item-edit").click((ev) => { + let itemId = $(ev.target).parents(".item")[0].dataset.itemId; + app.actor.items.get(itemId).sheet.render(true); + }); + favtabHtml.find(".item-fav").click((ev) => { + let itemId = $(ev.target).parents(".item")[0].dataset.itemId; + let val = !app.actor.items.get(itemId).data.flags.favtab.isFavourite; + app.actor.items.get(itemId).update({ + "flags.favtab.isFavourite": val + }); + }); + + // Sorting + favtabHtml.find(".item").on("drop", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + let dropData = JSON.parse(ev.originalEvent.dataTransfer.getData("text/plain")); + // if (dropData.actorId !== app.actor.id || dropData.data.type === 'power') return; + if (dropData.actorId !== app.actor.id) return; + let list = null; + if (dropData.data.type === "feat") list = favFeats; + else list = favItems; + let dragSource = list.find((i) => i.data._id === dropData.data._id); + let siblings = list.filter((i) => i.data._id !== dropData.data._id); + let targetId = ev.target.closest(".item").dataset.itemId; + let dragTarget = siblings.find((s) => s.data._id === targetId); + + if (dragTarget === undefined) return; + const sortUpdates = SortingHelpers.performIntegerSort(dragSource, { + target: dragTarget, + siblings: siblings, + sortKey: "flags.favtab.sort" + }); + const updateData = sortUpdates.map((u) => { + const update = u.update; + update._id = u.target.data._id; + return update; + }); + app.actor.updateEmbeddedEntity("OwnedItem", updateData); }); - }); - html.find(`.item[data-item-id="${item.data._id}"]`).find('.item-controls').prepend(favBtn); } - - if (isFav) { - item.powerComps = ""; - if (item.data.components) { - let comps = item.data.components; - let v = (comps.vocal) ? "V" : ""; - let s = (comps.somatic) ? "S" : ""; - let m = (comps.material) ? "M" : ""; - let c = !!(comps.concentration); - let r = !!(comps.ritual); - item.powerComps = `${v}${s}${m}`; - item.powerCon = c; - item.powerRit = r; - } - - item.editable = app.options.editable; - switch (item.type) { - case 'feat': - if (item.flags.favtab.sort === undefined) { - item.flags.favtab.sort = (favFeats.count + 1) * 100000; // initial sort key if not present - } - favFeats.push(item); - break; - case 'power': - if (item.data.preparation.mode) { - item.powerPrepMode = ` (${CONFIG.SW5E.powerPreparationModes[item.data.preparation.mode]})` - } - if (item.data.level) { - favPowers[item.data.level].powers.push(item); - } else { - favPowers[0].powers.push(item); - } - powerCount++; - break; - default: - if (item.flags.favtab.sort === undefined) { - item.flags.favtab.sort = (favItems.count + 1) * 100000; // initial sort key if not present - } - favItems.push(item); - break; - } - } - } - - // Alter core CSS to fit new button - // if (app.options.editable) { - // html.find('.powerbook .item-controls').css('flex', '0 0 88px'); - // html.find('.inventory .item-controls, .features .item-controls').css('flex', '0 0 90px'); - // html.find('.favourite .item-controls').css('flex', '0 0 22px'); - // } - - let tabContainer = html.find('.favtabtarget'); - data.favItems = favItems.length > 0 ? favItems.sort((a, b) => (a.flags.favtab.sort) - (b.flags.favtab.sort)) : false; - data.favFeats = favFeats.length > 0 ? favFeats.sort((a, b) => (a.flags.favtab.sort) - (b.flags.favtab.sort)) : false; - data.favPowers = powerCount > 0 ? favPowers : false; - data.editable = app.options.editable; - - await loadTemplates(['systems/sw5e/templates/actors/newActor/item.hbs']); - let favtabHtml = $(await renderTemplate('systems/sw5e/templates/actors/newActor/template.hbs', data)); - favtabHtml.find('.item-name h4').click(event => app._onItemSummary(event)); - - if (app.options.editable) { - favtabHtml.find('.item-image').click(ev => app._onItemRoll(ev)); - let handler = ev => app._onDragStart(ev); - favtabHtml.find('.item').each((i, li) => { - if (li.classList.contains("inventory-header")) return; - li.setAttribute("draggable", true); - li.addEventListener("dragstart", handler, false); - }); - //favtabHtml.find('.item-toggle').click(event => app._onToggleItem(event)); - favtabHtml.find('.item-edit').click(ev => { - let itemId = $(ev.target).parents('.item')[0].dataset.itemId; - app.actor.items.get(itemId).sheet.render(true); - }); - favtabHtml.find('.item-fav').click(ev => { - let itemId = $(ev.target).parents('.item')[0].dataset.itemId; - let val = !app.actor.items.get(itemId).data.flags.favtab.isFavourite - app.actor.items.get(itemId).update({ - "flags.favtab.isFavourite": val - }); - }); - - // Sorting - favtabHtml.find('.item').on('drop', ev => { - ev.preventDefault(); - ev.stopPropagation(); - - let dropData = JSON.parse(ev.originalEvent.dataTransfer.getData('text/plain')); - // if (dropData.actorId !== app.actor.id || dropData.data.type === 'power') return; - if (dropData.actorId !== app.actor.id) return; - let list = null; - if (dropData.data.type === 'feat') list = favFeats; - else list = favItems; - let dragSource = list.find(i => i.data._id === dropData.data._id); - let siblings = list.filter(i => i.data._id !== dropData.data._id); - let targetId = ev.target.closest('.item').dataset.itemId; - let dragTarget = siblings.find(s => s.data._id === targetId); - - if (dragTarget === undefined) return; - const sortUpdates = SortingHelpers.performIntegerSort(dragSource, { - target: dragTarget, - siblings: siblings, - sortKey: 'flags.favtab.sort' - }); - const updateData = sortUpdates.map(u => { - const update = u.update; - update._id = u.target.data._id; - return update; - }); - app.actor.updateEmbeddedEntity("OwnedItem", updateData); - }); - } - tabContainer.append(favtabHtml); - // if(app.options.editable) { - // let handler = ev => app._onDragItemStart(ev); - // tabContainer.find('.item').each((i, li) => { - // if (li.classList.contains("inventory-header")) return; - // li.setAttribute("draggable", true); - // li.addEventListener("dragstart", handler, false); - // }); - //} - // try { - // if (game.modules.get("betterrolls5e") && game.modules.get("betterrolls5e").active) BetterRolls.addItemContent(app.object, favtabHtml, ".item .item-name h4", ".item-properties", ".item > .rollable div"); - // } - // catch (err) { - // // Better Rolls not found! - // } - Hooks.callAll("renderedSwaltSheet", app, html, data); + tabContainer.append(favtabHtml); + // if(app.options.editable) { + // let handler = ev => app._onDragItemStart(ev); + // tabContainer.find('.item').each((i, li) => { + // if (li.classList.contains("inventory-header")) return; + // li.setAttribute("draggable", true); + // li.addEventListener("dragstart", handler, false); + // }); + //} + // try { + // if (game.modules.get("betterrolls5e") && game.modules.get("betterrolls5e").active) BetterRolls.addItemContent(app.object, favtabHtml, ".item .item-name h4", ".item-properties", ".item > .rollable div"); + // } + // catch (err) { + // // Better Rolls not found! + // } + Hooks.callAll("renderedSwaltSheet", app, html, data); } async function addSubTabs(app, html, data) { - if(data.options.subTabs == null) { - //let subTabs = []; //{subgroup: '', target: '', active: false} - data.options.subTabs = {}; - html.find('[data-subgroup-selection] [data-subgroup]').each((idx, el) => { - let subgroup = el.getAttribute('data-subgroup'); - let target = el.getAttribute('data-target'); - let targetObj = {target: target, active: el.classList.contains("active")} - if(data.options.subTabs.hasOwnProperty(subgroup)) { - data.options.subTabs[subgroup].push(targetObj); - } else { - data.options.subTabs[subgroup] = []; - data.options.subTabs[subgroup].push(targetObj); - } - }) - } - - for(const group in data.options.subTabs) { - data.options.subTabs[group].forEach(tab => { - if(tab.active) { - html.find(`[data-subgroup=${group}][data-target=${tab.target}]`).addClass('active'); - } else { - html.find(`[data-subgroup=${group}][data-target=${tab.target}]`).removeClass('active'); - } - }) - } - - html.find('[data-subgroup-selection]').children().on('click', event => { - let subgroup = event.target.closest('[data-subgroup]').getAttribute('data-subgroup'); - let target = event.target.closest('[data-target]').getAttribute('data-target'); - html.find(`[data-subgroup=${subgroup}]`).removeClass('active'); - html.find(`[data-subgroup=${subgroup}][data-target=${target}]`).addClass('active'); - let tabId = data.options.subTabs[subgroup].find(tab => { - return tab.target == target - }); - data.options.subTabs[subgroup].map(el => { - el.active = el.target == target; - return el; - }) - - }) - + if (data.options.subTabs == null) { + //let subTabs = []; //{subgroup: '', target: '', active: false} + data.options.subTabs = {}; + html.find("[data-subgroup-selection] [data-subgroup]").each((idx, el) => { + let subgroup = el.getAttribute("data-subgroup"); + let target = el.getAttribute("data-target"); + let targetObj = {target: target, active: el.classList.contains("active")}; + if (data.options.subTabs.hasOwnProperty(subgroup)) { + data.options.subTabs[subgroup].push(targetObj); + } else { + data.options.subTabs[subgroup] = []; + data.options.subTabs[subgroup].push(targetObj); + } + }); + } + for (const group in data.options.subTabs) { + data.options.subTabs[group].forEach((tab) => { + if (tab.active) { + html.find(`[data-subgroup=${group}][data-target=${tab.target}]`).addClass("active"); + } else { + html.find(`[data-subgroup=${group}][data-target=${tab.target}]`).removeClass("active"); + } + }); + } + html.find("[data-subgroup-selection]") + .children() + .on("click", (event) => { + let subgroup = event.target.closest("[data-subgroup]").getAttribute("data-subgroup"); + let target = event.target.closest("[data-target]").getAttribute("data-target"); + html.find(`[data-subgroup=${subgroup}]`).removeClass("active"); + html.find(`[data-subgroup=${subgroup}][data-target=${target}]`).addClass("active"); + let tabId = data.options.subTabs[subgroup].find((tab) => { + return tab.target == target; + }); + data.options.subTabs[subgroup].map((el) => { + el.active = el.target == target; + return el; + }); + }); } Hooks.on("renderActorSheet5eCharacterNew", (app, html, data) => { - addFavorites(app, html, data); - addSubTabs(app, html, data); -}); \ No newline at end of file + addFavorites(app, html, data); + addSubTabs(app, html, data); +}); diff --git a/module/actor/sheets/newSheet/npc.js b/module/actor/sheets/newSheet/npc.js index 59a94334..699005ea 100644 --- a/module/actor/sheets/newSheet/npc.js +++ b/module/actor/sheets/newSheet/npc.js @@ -6,143 +6,154 @@ import ActorSheet5e from "./base.js"; * @extends {ActorSheet5e} */ export default class ActorSheet5eNPCNew extends ActorSheet5e { - - /** @override */ - get template() { - if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/newActor/limited-sheet.html"; - return `systems/sw5e/templates/actors/newActor/npc-sheet.html`; - } - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["sw5e", "sheet", "actor", "npc"], - width: 800, - tabs: [{ - navSelector: ".root-tabs", - contentSelector: ".sheet-body", - initial: "attributes" - }], - }); - } - - /* -------------------------------------------- */ - - /** @override */ - static unsupportedItemTypes = new Set(["class"]); - - /* -------------------------------------------- */ - - /** - * Organize Owned Items for rendering the NPC sheet - * @private - */ - _prepareItems(data) { - - // Categorize Items as Features and Powers - const features = { - weapons: { label: game.i18n.localize("SW5E.AttackPl"), items: [] , hasActions: true, dataset: {type: "weapon", "weapon-type": "natural"} }, - actions: { label: game.i18n.localize("SW5E.ActionPl"), items: [] , hasActions: true, dataset: {type: "feat", "activation.type": "action"} }, - passive: { label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"} }, - equipment: { label: game.i18n.localize("SW5E.Inventory"), items: [], dataset: {type: "loot"}} - }; - - // Start by classifying items into groups for rendering - let [forcepowers, techpowers, other] = data.items.reduce((arr, item) => { - item.img = item.img || CONST.DEFAULT_TOKEN; - item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); - item.hasUses = item.data.uses && (item.data.uses.max > 0); - item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); - item.isDepleted = item.isOnCooldown && (item.data.uses.per && (item.data.uses.value > 0)); - item.hasTarget = !!item.data.target && !(["none",""].includes(item.data.target.type)); - if ( item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school) ) arr[0].push(item); - else if ( item.type === "power" && ["tec"].includes(item.data.school) ) arr[1].push(item); - else arr[2].push(item); - return arr; - }, [[], [], []]); - - // Apply item filters - forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook); - techpowers = this._filterItems(techpowers, this._filters.techPowerbook); - other = this._filterItems(other, this._filters.features); - - // Organize Powerbook - const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni"); - const techPowerbook = this._preparePowerbook(data, techpowers, "tec"); - - // Organize Features - for ( let item of other ) { - if ( item.type === "weapon" ) features.weapons.items.push(item); - else if ( item.type === "feat" ) { - if ( item.data.activation.type ) features.actions.items.push(item); - else features.passive.items.push(item); - } - else features.equipment.items.push(item); + /** @override */ + get template() { + if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/newActor/limited-sheet.html"; + return `systems/sw5e/templates/actors/newActor/npc-sheet.html`; + } + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["sw5e", "sheet", "actor", "npc"], + width: 800, + tabs: [ + { + navSelector: ".root-tabs", + contentSelector: ".sheet-body", + initial: "attributes" + } + ] + }); } - // Assign and return - data.features = Object.values(features); - data.forcePowerbook = forcePowerbook; - data.techPowerbook = techPowerbook; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); - /** @inheritdoc */ - getData(options) { - const data = super.getData(options); + /* -------------------------------------------- */ - // Challenge Rating - const cr = parseFloat(data.data.details.cr || 0); - const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; - data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; + /** + * Organize Owned Items for rendering the NPC sheet + * @private + */ + _prepareItems(data) { + // Categorize Items as Features and Powers + const features = { + weapons: { + label: game.i18n.localize("SW5E.AttackPl"), + items: [], + hasActions: true, + dataset: {"type": "weapon", "weapon-type": "natural"} + }, + actions: { + label: game.i18n.localize("SW5E.ActionPl"), + items: [], + hasActions: true, + dataset: {"type": "feat", "activation.type": "action"} + }, + passive: {label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"}}, + equipment: {label: game.i18n.localize("SW5E.Inventory"), items: [], dataset: {type: "loot"}} + }; - // Creature Type - data.labels["type"] = this.actor.labels.creatureType; - return data; - } + // Start by classifying items into groups for rendering + let [forcepowers, techpowers, other] = data.items.reduce( + (arr, item) => { + item.img = item.img || CONST.DEFAULT_TOKEN; + item.isStack = Number.isNumeric(item.data.quantity) && item.data.quantity !== 1; + item.hasUses = item.data.uses && item.data.uses.max > 0; + item.isOnCooldown = + item.data.recharge && !!item.data.recharge.value && item.data.recharge.charged === false; + item.isDepleted = item.isOnCooldown && item.data.uses.per && item.data.uses.value > 0; + item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type); + if (item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school)) arr[0].push(item); + else if (item.type === "power" && ["tec"].includes(item.data.school)) arr[1].push(item); + else arr[2].push(item); + return arr; + }, + [[], [], []] + ); - /* -------------------------------------------- */ - /* Object Updates */ - /* -------------------------------------------- */ + // Apply item filters + forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook); + techpowers = this._filterItems(techpowers, this._filters.techPowerbook); + other = this._filterItems(other, this._filters.features); - /** @override */ - async _updateObject(event, formData) { + // Organize Powerbook + const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni"); + const techPowerbook = this._preparePowerbook(data, techpowers, "tec"); - // Format NPC Challenge Rating - const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; - let crv = "data.details.cr"; - let cr = formData[crv]; - cr = crs[cr] || parseFloat(cr); - if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); + // Organize Features + for (let item of other) { + if (item.type === "weapon") features.weapons.items.push(item); + else if (item.type === "feat") { + if (item.data.activation.type) features.actions.items.push(item); + else features.passive.items.push(item); + } else features.equipment.items.push(item); + } - // Parent ActorSheet update steps - return super._updateObject(event, formData); - } + // Assign and return + data.features = Object.values(features); + data.forcePowerbook = forcePowerbook; + data.techPowerbook = techPowerbook; + } - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - activateListeners(html) { - super.activateListeners(html); - html.find(".health .rollable").click(this._onRollHPFormula.bind(this)); - } + /** @inheritdoc */ + getData(options) { + const data = super.getData(options); - /* -------------------------------------------- */ + // Challenge Rating + const cr = parseFloat(data.data.details.cr || 0); + const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; + data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; - /** - * Handle rolling NPC health values using the provided formula - * @param {Event} event The original click event - * @private - */ - _onRollHPFormula(event) { - event.preventDefault(); - const formula = this.actor.data.data.attributes.hp.formula; - if ( !formula ) return; - const hp = new Roll(formula).roll().total; - AudioHelper.play({src: CONFIG.sounds.dice}); - this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); - } + // Creature Type + data.labels["type"] = this.actor.labels.creatureType; + return data; + } + + /* -------------------------------------------- */ + /* Object Updates */ + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(event, formData) { + // Format NPC Challenge Rating + const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; + let crv = "data.details.cr"; + let cr = formData[crv]; + cr = crs[cr] || parseFloat(cr); + if (cr) formData[crv] = cr < 1 ? cr : parseInt(cr); + + // Parent ActorSheet update steps + return super._updateObject(event, formData); + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + html.find(".health .rollable").click(this._onRollHPFormula.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling NPC health values using the provided formula + * @param {Event} event The original click event + * @private + */ + _onRollHPFormula(event) { + event.preventDefault(); + const formula = this.actor.data.data.attributes.hp.formula; + if (!formula) return; + const hp = new Roll(formula).roll().total; + AudioHelper.play({src: CONFIG.sounds.dice}); + this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); + } } - diff --git a/module/actor/sheets/newSheet/starship.js b/module/actor/sheets/newSheet/starship.js index e27354d6..bf7e397e 100644 --- a/module/actor/sheets/newSheet/starship.js +++ b/module/actor/sheets/newSheet/starship.js @@ -6,150 +6,164 @@ import ActorSheet5e from "./base.js"; * @extends {ActorSheet5e} */ export default class ActorSheet5eStarship extends ActorSheet5e { - - /** @override */ - get template() { - if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/newActor/limited-sheet.html"; - return `systems/sw5e/templates/actors/newActor/starship.html`; - } - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["sw5e", "sheet", "actor", "starship"], - width: 800, - tabs: [{ - navSelector: ".root-tabs", - contentSelector: ".sheet-body", - initial: "attributes" - }], - }); - } - - /* -------------------------------------------- */ - - /** - * Organize Owned Items for rendering the starship sheet - * @private - */ - _prepareItems(data) { - - // Categorize Items as Features and Powers - const features = { - weapons: { label: game.i18n.localize("SW5E.ItemTypeWeaponPl"), items: [], hasActions: true, dataset: {type: "weapon", "weapon-type": "natural"} }, - passive: { label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"} }, - equipment: { label: game.i18n.localize("SW5E.StarshipEquipment"), items: [], dataset: {type: "equipment"}}, - starshipfeatures: { label: game.i18n.localize("SW5E.StarshipfeaturePl"), items: [], hasActions: true, dataset: {type: "starshipfeature"} }, - starshipmods: { label: game.i18n.localize("SW5E.StarshipmodPl"), items: [], hasActions: false, dataset: {type: "starshipmod"} } - }; - - // Start by classifying items into groups for rendering - let [forcepowers, techpowers, other] = data.items.reduce((arr, item) => { - item.img = item.img || CONST.DEFAULT_TOKEN; - item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); - item.hasUses = item.data.uses && (item.data.uses.max > 0); - item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); - item.isDepleted = item.isOnCooldown && (item.data.uses.per && (item.data.uses.value > 0)); - item.hasTarget = !!item.data.target && !(["none",""].includes(item.data.target.type)); - if ( item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school) ) arr[0].push(item); - else if ( item.type === "power" && ["tec"].includes(item.data.school) ) arr[1].push(item); - else arr[2].push(item); - return arr; - }, [[], [], []]); - - // Apply item filters - forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook); - techpowers = this._filterItems(techpowers, this._filters.techPowerbook); - other = this._filterItems(other, this._filters.features); - - // Organize Powerbook -// const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni"); -// const techPowerbook = this._preparePowerbook(data, techpowers, "tec"); - - // Organize Features - for ( let item of other ) { - if ( item.type === "weapon" ) features.weapons.items.push(item); - else if ( item.type === "feat" ) { - if ( item.data.activation.type ) features.actions.items.push(item); - else features.passive.items.push(item); - } - else if ( item.type === "starshipfeature" ) { - features.starshipfeatures.items.push(item); - } - else if ( item.type === "starshipmod" ) { - features.starshipmods.items.push(item); - } - else features.equipment.items.push(item); + /** @override */ + get template() { + if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/newActor/limited-sheet.html"; + return `systems/sw5e/templates/actors/newActor/starship.html`; + } + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["sw5e", "sheet", "actor", "starship"], + width: 800, + tabs: [ + { + navSelector: ".root-tabs", + contentSelector: ".sheet-body", + initial: "attributes" + } + ] + }); } - // Assign and return - data.features = Object.values(features); -// data.forcePowerbook = forcePowerbook; -// data.techPowerbook = techPowerbook; - } + /* -------------------------------------------- */ + /** + * Organize Owned Items for rendering the starship sheet + * @private + */ + _prepareItems(data) { + // Categorize Items as Features and Powers + const features = { + weapons: { + label: game.i18n.localize("SW5E.ItemTypeWeaponPl"), + items: [], + hasActions: true, + dataset: {"type": "weapon", "weapon-type": "natural"} + }, + passive: {label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"}}, + equipment: {label: game.i18n.localize("SW5E.StarshipEquipment"), items: [], dataset: {type: "equipment"}}, + starshipfeatures: { + label: game.i18n.localize("SW5E.StarshipfeaturePl"), + items: [], + hasActions: true, + dataset: {type: "starshipfeature"} + }, + starshipmods: { + label: game.i18n.localize("SW5E.StarshipmodPl"), + items: [], + hasActions: false, + dataset: {type: "starshipmod"} + } + }; - /* -------------------------------------------- */ + // Start by classifying items into groups for rendering + let [forcepowers, techpowers, other] = data.items.reduce( + (arr, item) => { + item.img = item.img || CONST.DEFAULT_TOKEN; + item.isStack = Number.isNumeric(item.data.quantity) && item.data.quantity !== 1; + item.hasUses = item.data.uses && item.data.uses.max > 0; + item.isOnCooldown = + item.data.recharge && !!item.data.recharge.value && item.data.recharge.charged === false; + item.isDepleted = item.isOnCooldown && item.data.uses.per && item.data.uses.value > 0; + item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type); + if (item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school)) arr[0].push(item); + else if (item.type === "power" && ["tec"].includes(item.data.school)) arr[1].push(item); + else arr[2].push(item); + return arr; + }, + [[], [], []] + ); - /** @override */ - getData(options) { - const data = super.getData(options); + // Apply item filters + forcepowers = this._filterItems(forcepowers, this._filters.forcePowerbook); + techpowers = this._filterItems(techpowers, this._filters.techPowerbook); + other = this._filterItems(other, this._filters.features); - // Add Size info - data.isTiny = data.actor.data.traits.size === "tiny"; - data.isSmall = data.actor.data.traits.size === "sm"; - data.isMedium = data.actor.data.traits.size === "med"; - data.isLarge = data.actor.data.traits.size === "lg"; - data.isHuge = data.actor.data.traits.size === "huge"; - data.isGargantuan = data.actor.data.traits.size === "grg"; + // Organize Powerbook + // const forcePowerbook = this._preparePowerbook(data, forcepowers, "uni"); + // const techPowerbook = this._preparePowerbook(data, techpowers, "tec"); - // Challenge Rating - const cr = parseFloat(data.data.details.cr || 0); - const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; - data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; - return data; - } + // Organize Features + for (let item of other) { + if (item.type === "weapon") features.weapons.items.push(item); + else if (item.type === "feat") { + if (item.data.activation.type) features.actions.items.push(item); + else features.passive.items.push(item); + } else if (item.type === "starshipfeature") { + features.starshipfeatures.items.push(item); + } else if (item.type === "starshipmod") { + features.starshipmods.items.push(item); + } else features.equipment.items.push(item); + } - /* -------------------------------------------- */ - /* Object Updates */ - /* -------------------------------------------- */ + // Assign and return + data.features = Object.values(features); + // data.forcePowerbook = forcePowerbook; + // data.techPowerbook = techPowerbook; + } - /** @override */ - async _updateObject(event, formData) { + /* -------------------------------------------- */ - // Format NPC Challenge Rating - const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; - let crv = "data.details.cr"; - let cr = formData[crv]; - cr = crs[cr] || parseFloat(cr); - if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); + /** @override */ + getData(options) { + const data = super.getData(options); - // Parent ActorSheet update steps - return super._updateObject(event, formData); - } + // Add Size info + data.isTiny = data.actor.data.traits.size === "tiny"; + data.isSmall = data.actor.data.traits.size === "sm"; + data.isMedium = data.actor.data.traits.size === "med"; + data.isLarge = data.actor.data.traits.size === "lg"; + data.isHuge = data.actor.data.traits.size === "huge"; + data.isGargantuan = data.actor.data.traits.size === "grg"; - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ + // Challenge Rating + const cr = parseFloat(data.data.details.cr || 0); + const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; + data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; + return data; + } - /** @override */ - activateListeners(html) { - super.activateListeners(html); - html.find(".health .rollable").click(this._onRollHPFormula.bind(this)); - } + /* -------------------------------------------- */ + /* Object Updates */ + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + async _updateObject(event, formData) { + // Format NPC Challenge Rating + const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; + let crv = "data.details.cr"; + let cr = formData[crv]; + cr = crs[cr] || parseFloat(cr); + if (cr) formData[crv] = cr < 1 ? cr : parseInt(cr); - /** - * Handle rolling NPC health values using the provided formula - * @param {Event} event The original click event - * @private - */ - _onRollHPFormula(event) { - event.preventDefault(); - const formula = this.actor.data.data.attributes.hp.formula; - if ( !formula ) return; - const hp = new Roll(formula).roll().total; - AudioHelper.play({src: CONFIG.sounds.dice}); - this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); - } + // Parent ActorSheet update steps + return super._updateObject(event, formData); + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + html.find(".health .rollable").click(this._onRollHPFormula.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling NPC health values using the provided formula + * @param {Event} event The original click event + * @private + */ + _onRollHPFormula(event) { + event.preventDefault(); + const formula = this.actor.data.data.attributes.hp.formula; + if (!formula) return; + const hp = new Roll(formula).roll().total; + AudioHelper.play({src: CONFIG.sounds.dice}); + this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); + } } diff --git a/module/actor/sheets/newSheet/vehicle.js b/module/actor/sheets/newSheet/vehicle.js index b5e28a1c..a5c6e2ea 100644 --- a/module/actor/sheets/newSheet/vehicle.js +++ b/module/actor/sheets/newSheet/vehicle.js @@ -6,411 +6,427 @@ import ActorSheet5e from "./base.js"; * @type {ActorSheet5e} */ export default class ActorSheet5eVehicle extends ActorSheet5e { - /** - * Define default rendering options for the Vehicle sheet. - * @returns {Object} - */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["sw5e", "sheet", "actor", "vehicle"], - width: 605, - height: 680 - }); - } - - /* -------------------------------------------- */ - - /** @override */ - static unsupportedItemTypes = new Set(["class"]); - - /* -------------------------------------------- */ - - - /** - * Creates a new cargo entry for a vehicle Actor. - */ - static get newCargo() { - return { - name: '', - quantity: 1 - }; - } - - /* -------------------------------------------- */ - - /** - * Compute the total weight of the vehicle's cargo. - * @param {Number} totalWeight The cumulative item weight from inventory items - * @param {Object} actorData The data object for the Actor being rendered - * @returns {{max: number, value: number, pct: number}} - * @private - */ - _computeEncumbrance(totalWeight, actorData) { - - // Compute currency weight - const totalCoins = Object.values(actorData.data.currency).reduce((acc, denom) => acc + denom, 0); - totalWeight += totalCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; - - // Vehicle weights are an order of magnitude greater. - totalWeight /= CONFIG.SW5E.encumbrance.vehicleWeightMultiplier; - - // Compute overall encumbrance - const max = actorData.data.attributes.capacity.cargo; - const pct = Math.clamped((totalWeight * 100) / max, 0, 100); - return {value: totalWeight.toNearest(0.1), max, pct}; - } - - /* -------------------------------------------- */ - - /** @override */ - _getMovementSpeed(actorData, largestPrimary=true) { - return super._getMovementSpeed(actorData, largestPrimary); - } - - /* -------------------------------------------- */ - - /** - * Prepare items that are mounted to a vehicle and require one or more crew - * to operate. - * @private - */ - _prepareCrewedItem(item) { - - // Determine crewed status - const isCrewed = item.data.crewed; - item.toggleClass = isCrewed ? 'active' : ''; - item.toggleTitle = game.i18n.localize(`SW5E.${isCrewed ? 'Crewed' : 'Uncrewed'}`); - - // Handle crew actions - if (item.type === 'feat' && item.data.activation.type === 'crew') { - item.crew = item.data.activation.cost; - item.cover = game.i18n.localize(`SW5E.${item.data.cover ? 'CoverTotal' : 'None'}`); - if (item.data.cover === .5) item.cover = '½'; - else if (item.data.cover === .75) item.cover = '¾'; - else if (item.data.cover === null) item.cover = '—'; - if (item.crew < 1 || item.crew === null) item.crew = '—'; + /** + * Define default rendering options for the Vehicle sheet. + * @returns {Object} + */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["sw5e", "sheet", "actor", "vehicle"], + width: 605, + height: 680 + }); } - // Prepare vehicle weapons - if (item.type === 'equipment' || item.type === 'weapon') { - item.threshold = item.data.hp.dt ? item.data.hp.dt : '—'; - } - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); - /** - * Organize Owned Items for rendering the Vehicle sheet. - * @private - */ - _prepareItems(data) { - const cargoColumns = [{ - label: game.i18n.localize('SW5E.Quantity'), - css: 'item-qty', - property: 'quantity', - editable: 'Number' - }]; + /* -------------------------------------------- */ - const equipmentColumns = [{ - label: game.i18n.localize('SW5E.Quantity'), - css: 'item-qty', - property: 'data.quantity' - }, { - label: game.i18n.localize('SW5E.AC'), - css: 'item-ac', - property: 'data.armor.value' - }, { - label: game.i18n.localize('SW5E.HP'), - css: 'item-hp', - property: 'data.hp.value', - editable: 'Number' - }, { - label: game.i18n.localize('SW5E.Threshold'), - css: 'item-threshold', - property: 'threshold' - }]; - - const features = { - actions: { - label: game.i18n.localize('SW5E.ActionPl'), - items: [], - crewable: true, - dataset: {type: 'feat', 'activation.type': 'crew'}, - columns: [{ - label: game.i18n.localize('SW5E.VehicleCrew'), - css: 'item-crew', - property: 'crew' - }, { - label: game.i18n.localize('SW5E.Cover'), - css: 'item-cover', - property: 'cover' - }] - }, - equipment: { - label: game.i18n.localize('SW5E.ItemTypeEquipment'), - items: [], - crewable: true, - dataset: {type: 'equipment', 'armor.type': 'vehicle'}, - columns: equipmentColumns - }, - passive: { - label: game.i18n.localize('SW5E.Features'), - items: [], - dataset: {type: 'feat'} - }, - reactions: { - label: game.i18n.localize('SW5E.ReactionPl'), - items: [], - dataset: {type: 'feat', 'activation.type': 'reaction'} - }, - weapons: { - label: game.i18n.localize('SW5E.ItemTypeWeaponPl'), - items: [], - crewable: true, - dataset: {type: 'weapon', 'weapon-type': 'siege'}, - columns: equipmentColumns - } - }; - - const cargo = { - crew: { - label: game.i18n.localize('SW5E.VehicleCrew'), - items: data.data.cargo.crew, - css: 'cargo-row crew', - editableName: true, - dataset: {type: 'crew'}, - columns: cargoColumns - }, - passengers: { - label: game.i18n.localize('SW5E.VehiclePassengers'), - items: data.data.cargo.passengers, - css: 'cargo-row passengers', - editableName: true, - dataset: {type: 'passengers'}, - columns: cargoColumns - }, - cargo: { - label: game.i18n.localize('SW5E.VehicleCargo'), - items: [], - dataset: {type: 'loot'}, - columns: [{ - label: game.i18n.localize('SW5E.Quantity'), - css: 'item-qty', - property: 'data.quantity', - editable: 'Number' - }, { - label: game.i18n.localize('SW5E.Price'), - css: 'item-price', - property: 'data.price', - editable: 'Number' - }, { - label: game.i18n.localize('SW5E.Weight'), - css: 'item-weight', - property: 'data.weight', - editable: 'Number' - }] - } - }; - - // Classify items owned by the vehicle and compute total cargo weight - let totalWeight = 0; - for (const item of data.items) { - this._prepareCrewedItem(item); - - // Handle cargo explicitly - const isCargo = item.flags.sw5e?.vehicleCargo === true; - if ( isCargo ) { - totalWeight += (item.data.weight || 0) * item.data.quantity; - cargo.cargo.items.push(item); - continue; - } - - // Handle non-cargo item types - switch ( item.type ) { - case "weapon": - features.weapons.items.push(item); - break; - case "equipment": - features.equipment.items.push(item); - break; - case "feat": - if ( !item.data.activation.type || (item.data.activation.type === "none") ) features.passive.items.push(item); - else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); - else features.actions.items.push(item); - break; - default: - totalWeight += (item.data.weight || 0) * item.data.quantity; - cargo.cargo.items.push(item); - } + /** + * Creates a new cargo entry for a vehicle Actor. + */ + static get newCargo() { + return { + name: "", + quantity: 1 + }; } - // Update the rendering context data - data.features = Object.values(features); - data.cargo = Object.values(cargo); - data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ + /** + * Compute the total weight of the vehicle's cargo. + * @param {Number} totalWeight The cumulative item weight from inventory items + * @param {Object} actorData The data object for the Actor being rendered + * @returns {{max: number, value: number, pct: number}} + * @private + */ + _computeEncumbrance(totalWeight, actorData) { + // Compute currency weight + const totalCoins = Object.values(actorData.data.currency).reduce((acc, denom) => acc + denom, 0); + totalWeight += totalCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; - /** @override */ - activateListeners(html) { - super.activateListeners(html); - if (!this.isEditable) return; + // Vehicle weights are an order of magnitude greater. + totalWeight /= CONFIG.SW5E.encumbrance.vehicleWeightMultiplier; - html.find('.item-toggle').click(this._onToggleItem.bind(this)); - html.find('.item-hp input') - .click(evt => evt.target.select()) - .change(this._onHPChange.bind(this)); - - html.find('.item:not(.cargo-row) input[data-property]') - .click(evt => evt.target.select()) - .change(this._onEditInSheet.bind(this)); - - html.find('.cargo-row input') - .click(evt => evt.target.select()) - .change(this._onCargoRowChange.bind(this)); - - if (this.actor.data.data.attributes.actions.stations) { - html.find('.counter.actions, .counter.action-thresholds').hide(); - } - } - - /* -------------------------------------------- */ - - /** - * Handle saving a cargo row (i.e. crew or passenger) in-sheet. - * @param event {Event} - * @returns {Promise|null} - * @private - */ - _onCargoRowChange(event) { - event.preventDefault(); - const target = event.currentTarget; - const row = target.closest('.item'); - const idx = Number(row.dataset.itemId); - const property = row.classList.contains('crew') ? 'crew' : 'passengers'; - - // Get the cargo entry - const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); - const entry = cargo[idx]; - if (!entry) return null; - - // Update the cargo value - const key = target.dataset.property || 'name'; - const type = target.dataset.dtype; - let value = target.value; - if (type === 'Number') value = Number(value); - entry[key] = value; - - // Perform the Actor update - return this.actor.update({[`data.cargo.${property}`]: cargo}); - } - - /* -------------------------------------------- */ - - /** - * Handle editing certain values like quantity, price, and weight in-sheet. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onEditInSheet(event) { - event.preventDefault(); - const itemID = event.currentTarget.closest('.item').dataset.itemId; - const item = this.actor.items.get(itemID); - const property = event.currentTarget.dataset.property; - const type = event.currentTarget.dataset.dtype; - let value = event.currentTarget.value; - switch (type) { - case 'Number': value = parseInt(value); break; - case 'Boolean': value = value === 'true'; break; - } - return item.update({[`${property}`]: value}); - } - - /* -------------------------------------------- */ - - /** - * Handle creating a new crew or passenger row. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onItemCreate(event) { - event.preventDefault(); - const target = event.currentTarget; - const type = target.dataset.type; - if (type === 'crew' || type === 'passengers') { - const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); - cargo.push(this.constructor.newCargo); - return this.actor.update({[`data.cargo.${type}`]: cargo}); - } - return super._onItemCreate(event); - } - - /* -------------------------------------------- */ - - /** - * Handle deleting a crew or passenger row. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onItemDelete(event) { - event.preventDefault(); - const row = event.currentTarget.closest('.item'); - if (row.classList.contains('cargo-row')) { - const idx = Number(row.dataset.itemId); - const type = row.classList.contains('crew') ? 'crew' : 'passengers'; - const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); - return this.actor.update({[`data.cargo.${type}`]: cargo}); + // Compute overall encumbrance + const max = actorData.data.attributes.capacity.cargo; + const pct = Math.clamped((totalWeight * 100) / max, 0, 100); + return {value: totalWeight.toNearest(0.1), max, pct}; } - return super._onItemDelete(event); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + _getMovementSpeed(actorData, largestPrimary = true) { + return super._getMovementSpeed(actorData, largestPrimary); + } - /** @override */ - async _onDropItemCreate(itemData) { - const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"]; - const isCargo = cargoTypes.includes(itemData.type) && (this._tabs[0].active === "cargo"); - foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo); - return super._onDropItemCreate(itemData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Prepare items that are mounted to a vehicle and require one or more crew + * to operate. + * @private + */ + _prepareCrewedItem(item) { + // Determine crewed status + const isCrewed = item.data.crewed; + item.toggleClass = isCrewed ? "active" : ""; + item.toggleTitle = game.i18n.localize(`SW5E.${isCrewed ? "Crewed" : "Uncrewed"}`); - /** - * Special handling for editing HP to clamp it within appropriate range. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onHPChange(event) { - event.preventDefault(); - const itemID = event.currentTarget.closest('.item').dataset.itemId; - const item = this.actor.items.get(itemID); - const hp = Math.clamped(0, parseInt(event.currentTarget.value), item.data.data.hp.max); - event.currentTarget.value = hp; - return item.update({'data.hp.value': hp}); - } + // Handle crew actions + if (item.type === "feat" && item.data.activation.type === "crew") { + item.crew = item.data.activation.cost; + item.cover = game.i18n.localize(`SW5E.${item.data.cover ? "CoverTotal" : "None"}`); + if (item.data.cover === 0.5) item.cover = "½"; + else if (item.data.cover === 0.75) item.cover = "¾"; + else if (item.data.cover === null) item.cover = "—"; + if (item.crew < 1 || item.crew === null) item.crew = "—"; + } - /* -------------------------------------------- */ + // Prepare vehicle weapons + if (item.type === "equipment" || item.type === "weapon") { + item.threshold = item.data.hp.dt ? item.data.hp.dt : "—"; + } + } - /** - * Handle toggling an item's crewed status. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onToggleItem(event) { - event.preventDefault(); - const itemID = event.currentTarget.closest('.item').dataset.itemId; - const item = this.actor.items.get(itemID); - const crewed = !!item.data.data.crewed; - return item.update({'data.crewed': !crewed}); - } -}; + /* -------------------------------------------- */ + + /** + * Organize Owned Items for rendering the Vehicle sheet. + * @private + */ + _prepareItems(data) { + const cargoColumns = [ + { + label: game.i18n.localize("SW5E.Quantity"), + css: "item-qty", + property: "quantity", + editable: "Number" + } + ]; + + const equipmentColumns = [ + { + label: game.i18n.localize("SW5E.Quantity"), + css: "item-qty", + property: "data.quantity" + }, + { + label: game.i18n.localize("SW5E.AC"), + css: "item-ac", + property: "data.armor.value" + }, + { + label: game.i18n.localize("SW5E.HP"), + css: "item-hp", + property: "data.hp.value", + editable: "Number" + }, + { + label: game.i18n.localize("SW5E.Threshold"), + css: "item-threshold", + property: "threshold" + } + ]; + + const features = { + actions: { + label: game.i18n.localize("SW5E.ActionPl"), + items: [], + crewable: true, + dataset: {"type": "feat", "activation.type": "crew"}, + columns: [ + { + label: game.i18n.localize("SW5E.VehicleCrew"), + css: "item-crew", + property: "crew" + }, + { + label: game.i18n.localize("SW5E.Cover"), + css: "item-cover", + property: "cover" + } + ] + }, + equipment: { + label: game.i18n.localize("SW5E.ItemTypeEquipment"), + items: [], + crewable: true, + dataset: {"type": "equipment", "armor.type": "vehicle"}, + columns: equipmentColumns + }, + passive: { + label: game.i18n.localize("SW5E.Features"), + items: [], + dataset: {type: "feat"} + }, + reactions: { + label: game.i18n.localize("SW5E.ReactionPl"), + items: [], + dataset: {"type": "feat", "activation.type": "reaction"} + }, + weapons: { + label: game.i18n.localize("SW5E.ItemTypeWeaponPl"), + items: [], + crewable: true, + dataset: {"type": "weapon", "weapon-type": "siege"}, + columns: equipmentColumns + } + }; + + const cargo = { + crew: { + label: game.i18n.localize("SW5E.VehicleCrew"), + items: data.data.cargo.crew, + css: "cargo-row crew", + editableName: true, + dataset: {type: "crew"}, + columns: cargoColumns + }, + passengers: { + label: game.i18n.localize("SW5E.VehiclePassengers"), + items: data.data.cargo.passengers, + css: "cargo-row passengers", + editableName: true, + dataset: {type: "passengers"}, + columns: cargoColumns + }, + cargo: { + label: game.i18n.localize("SW5E.VehicleCargo"), + items: [], + dataset: {type: "loot"}, + columns: [ + { + label: game.i18n.localize("SW5E.Quantity"), + css: "item-qty", + property: "data.quantity", + editable: "Number" + }, + { + label: game.i18n.localize("SW5E.Price"), + css: "item-price", + property: "data.price", + editable: "Number" + }, + { + label: game.i18n.localize("SW5E.Weight"), + css: "item-weight", + property: "data.weight", + editable: "Number" + } + ] + } + }; + + // Classify items owned by the vehicle and compute total cargo weight + let totalWeight = 0; + for (const item of data.items) { + this._prepareCrewedItem(item); + + // Handle cargo explicitly + const isCargo = item.flags.sw5e?.vehicleCargo === true; + if (isCargo) { + totalWeight += (item.data.weight || 0) * item.data.quantity; + cargo.cargo.items.push(item); + continue; + } + + // Handle non-cargo item types + switch (item.type) { + case "weapon": + features.weapons.items.push(item); + break; + case "equipment": + features.equipment.items.push(item); + break; + case "feat": + if (!item.data.activation.type || item.data.activation.type === "none") + features.passive.items.push(item); + else if (item.data.activation.type === "reaction") features.reactions.items.push(item); + else features.actions.items.push(item); + break; + default: + totalWeight += (item.data.weight || 0) * item.data.quantity; + cargo.cargo.items.push(item); + } + } + + // Update the rendering context data + data.features = Object.values(features); + data.cargo = Object.values(cargo); + data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data); + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + if (!this.isEditable) return; + + html.find(".item-toggle").click(this._onToggleItem.bind(this)); + html.find(".item-hp input") + .click((evt) => evt.target.select()) + .change(this._onHPChange.bind(this)); + + html.find(".item:not(.cargo-row) input[data-property]") + .click((evt) => evt.target.select()) + .change(this._onEditInSheet.bind(this)); + + html.find(".cargo-row input") + .click((evt) => evt.target.select()) + .change(this._onCargoRowChange.bind(this)); + + if (this.actor.data.data.attributes.actions.stations) { + html.find(".counter.actions, .counter.action-thresholds").hide(); + } + } + + /* -------------------------------------------- */ + + /** + * Handle saving a cargo row (i.e. crew or passenger) in-sheet. + * @param event {Event} + * @returns {Promise|null} + * @private + */ + _onCargoRowChange(event) { + event.preventDefault(); + const target = event.currentTarget; + const row = target.closest(".item"); + const idx = Number(row.dataset.itemId); + const property = row.classList.contains("crew") ? "crew" : "passengers"; + + // Get the cargo entry + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); + const entry = cargo[idx]; + if (!entry) return null; + + // Update the cargo value + const key = target.dataset.property || "name"; + const type = target.dataset.dtype; + let value = target.value; + if (type === "Number") value = Number(value); + entry[key] = value; + + // Perform the Actor update + return this.actor.update({[`data.cargo.${property}`]: cargo}); + } + + /* -------------------------------------------- */ + + /** + * Handle editing certain values like quantity, price, and weight in-sheet. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onEditInSheet(event) { + event.preventDefault(); + const itemID = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemID); + const property = event.currentTarget.dataset.property; + const type = event.currentTarget.dataset.dtype; + let value = event.currentTarget.value; + switch (type) { + case "Number": + value = parseInt(value); + break; + case "Boolean": + value = value === "true"; + break; + } + return item.update({[`${property}`]: value}); + } + + /* -------------------------------------------- */ + + /** + * Handle creating a new crew or passenger row. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onItemCreate(event) { + event.preventDefault(); + const target = event.currentTarget; + const type = target.dataset.type; + if (type === "crew" || type === "passengers") { + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); + cargo.push(this.constructor.newCargo); + return this.actor.update({[`data.cargo.${type}`]: cargo}); + } + return super._onItemCreate(event); + } + + /* -------------------------------------------- */ + + /** + * Handle deleting a crew or passenger row. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onItemDelete(event) { + event.preventDefault(); + const row = event.currentTarget.closest(".item"); + if (row.classList.contains("cargo-row")) { + const idx = Number(row.dataset.itemId); + const type = row.classList.contains("crew") ? "crew" : "passengers"; + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); + return this.actor.update({[`data.cargo.${type}`]: cargo}); + } + + return super._onItemDelete(event); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropItemCreate(itemData) { + const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"]; + const isCargo = cargoTypes.includes(itemData.type) && this._tabs[0].active === "cargo"; + foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo); + return super._onDropItemCreate(itemData); + } + + /* -------------------------------------------- */ + + /** + * Special handling for editing HP to clamp it within appropriate range. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onHPChange(event) { + event.preventDefault(); + const itemID = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemID); + const hp = Math.clamped(0, parseInt(event.currentTarget.value), item.data.data.hp.max); + event.currentTarget.value = hp; + return item.update({"data.hp.value": hp}); + } + + /* -------------------------------------------- */ + + /** + * Handle toggling an item's crewed status. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onToggleItem(event) { + event.preventDefault(); + const itemID = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemID); + const crewed = !!item.data.data.crewed; + return item.update({"data.crewed": !crewed}); + } +} diff --git a/module/actor/sheets/oldSheets/base.js b/module/actor/sheets/oldSheets/base.js index f0a7a83b..acab8b2a 100644 --- a/module/actor/sheets/oldSheets/base.js +++ b/module/actor/sheets/oldSheets/base.js @@ -5,7 +5,7 @@ import ActorHitDiceConfig from "../../../apps/hit-dice-config.js"; import ActorMovementConfig from "../../../apps/movement-config.js"; import ActorSensesConfig from "../../../apps/senses-config.js"; import ActorTypeConfig from "../../../apps/actor-type.js"; -import {SW5E} from '../../../config.js'; +import {SW5E} from "../../../config.js"; import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effects.js"; /** @@ -14,902 +14,907 @@ import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effe * @extends {ActorSheet} */ export default class ActorSheet5e extends ActorSheet { - constructor(...args) { - super(...args); + constructor(...args) { + super(...args); + + /** + * Track the set of item filters which are applied + * @type {Set} + */ + this._filters = { + inventory: new Set(), + powerbook: new Set(), + features: new Set(), + effects: new Set() + }; + } + + /* -------------------------------------------- */ + + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + scrollY: [ + ".inventory .inventory-list", + ".features .inventory-list", + ".powerbook .inventory-list", + ".effects .inventory-list" + ], + tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] + }); + } + + /* -------------------------------------------- */ /** - * Track the set of item filters which are applied - * @type {Set} + * A set of item types that should be prevented from being dropped on this type of actor sheet. + * @type {Set} */ - this._filters = { - inventory: new Set(), - powerbook: new Set(), - features: new Set(), - effects: new Set() - }; - } + static unsupportedItemTypes = new Set(); - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - scrollY: [ - ".inventory .inventory-list", - ".features .inventory-list", - ".powerbook .inventory-list", - ".effects .inventory-list" - ], - tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] - }); - } - - /* -------------------------------------------- */ - - /** - * A set of item types that should be prevented from being dropped on this type of actor sheet. - * @type {Set} - */ - static unsupportedItemTypes = new Set(); - - /* -------------------------------------------- */ - - - /** @override */ - get template() { - if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/oldActor/limited-sheet.html"; - return `systems/sw5e/templates/actors/oldActor/${this.actor.data.type}-sheet.html`; - } - - /* -------------------------------------------- */ - - /** @override */ - getData(options) { - - // Basic data - let isOwner = this.actor.isOwner; - const data = { - owner: isOwner, - limited: this.actor.limited, - options: this.options, - editable: this.isEditable, - cssClass: isOwner ? "editable" : "locked", - isCharacter: this.actor.type === "character", - isNPC: this.actor.type === "npc", - isStarship: this.actor.type === "starship", - isVehicle: this.actor.type === 'vehicle', - config: CONFIG.SW5E, - rollData: this.actor.getRollData.bind(this.actor) - }; - - // The Actor's data - const actorData = this.actor.data.toObject(false); - data.actor = actorData; - data.data = actorData.data; - - // Owned Items - data.items = actorData.items; - for ( let i of data.items ) { - const item = this.actor.items.get(i._id); - i.labels = item.labels; - } - data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); - - // Labels and filters - data.labels = this.actor.labels || {}; - data.filters = this._filters; - - // Ability Scores - for ( let [a, abl] of Object.entries(actorData.data.abilities)) { - abl.icon = this._getProficiencyIcon(abl.proficient); - abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient]; - abl.label = CONFIG.SW5E.abilities[a]; + /** @override */ + get template() { + if (!game.user.isGM && this.actor.limited) return "systems/sw5e/templates/actors/oldActor/limited-sheet.html"; + return `systems/sw5e/templates/actors/oldActor/${this.actor.data.type}-sheet.html`; } - // Skills - if (actorData.data.skills) { - for ( let [s, skl] of Object.entries(actorData.data.skills)) { - skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability]; - skl.icon = this._getProficiencyIcon(skl.value); - skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; - skl.label = CONFIG.SW5E.skills[s]; - } - } + /* -------------------------------------------- */ - // Movement speeds - data.movement = this._getMovementSpeed(actorData); + /** @override */ + getData(options) { + // Basic data + let isOwner = this.actor.isOwner; + const data = { + owner: isOwner, + limited: this.actor.limited, + options: this.options, + editable: this.isEditable, + cssClass: isOwner ? "editable" : "locked", + isCharacter: this.actor.type === "character", + isNPC: this.actor.type === "npc", + isStarship: this.actor.type === "starship", + isVehicle: this.actor.type === "vehicle", + config: CONFIG.SW5E, + rollData: this.actor.getRollData.bind(this.actor) + }; - // Senses - data.senses = this._getSenses(actorData); + // The Actor's data + const actorData = this.actor.data.toObject(false); + data.actor = actorData; + data.data = actorData.data; - // Update traits - this._prepareTraits(actorData.data.traits); - - // Prepare owned items - this._prepareItems(data); - - // Prepare active effects - data.effects = prepareActiveEffectCategories(this.actor.effects); - - // Return data to the sheet - return data - } - - /* -------------------------------------------- */ - - /** - * Prepare the display of movement speed data for the Actor* - * @param {object} actorData The Actor data being prepared. - * @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk" - * @returns {{primary: string, special: string}} - * @private - */ - _getMovementSpeed(actorData, largestPrimary=false) { - const movement = actorData.data.attributes.movement || {}; - - // Prepare an array of available movement speeds - let speeds = [ - [movement.burrow, `${game.i18n.localize("SW5E.MovementBurrow")} ${movement.burrow}`], - [movement.climb, `${game.i18n.localize("SW5E.MovementClimb")} ${movement.climb}`], - [movement.fly, `${game.i18n.localize("SW5E.MovementFly")} ${movement.fly}` + (movement.hover ? ` (${game.i18n.localize("SW5E.MovementHover")})` : "")], - [movement.swim, `${game.i18n.localize("SW5E.MovementSwim")} ${movement.swim}`] - ] - if ( largestPrimary ) { - speeds.push([movement.walk, `${game.i18n.localize("SW5E.MovementWalk")} ${movement.walk}`]); - } - - // Filter and sort speeds on their values - speeds = speeds.filter(s => !!s[0]).sort((a, b) => b[0] - a[0]); - - // Case 1: Largest as primary - if ( largestPrimary ) { - let primary = speeds.shift(); - return { - primary: `${primary ? primary[1] : "0"} ${movement.units}`, - special: speeds.map(s => s[1]).join(", ") - } - } - - // Case 2: Walk as primary - else { - return { - primary: `${movement.walk || 0} ${movement.units}`, - special: speeds.length ? speeds.map(s => s[1]).join(", ") : "" - } - } - } - - /* -------------------------------------------- */ - - _getSenses(actorData) { - const senses = actorData.data.attributes.senses || {}; - const tags = {}; - for ( let [k, label] of Object.entries(CONFIG.SW5E.senses) ) { - const v = senses[k] ?? 0 - if ( v === 0 ) continue; - tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`; - } - if ( !!senses.special ) tags["special"] = senses.special; - return tags; - } - - /* -------------------------------------------- */ - - /** - * Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies - * @param {object} traits The raw traits data object from the actor data - * @private - */ - _prepareTraits(traits) { - const map = { - "dr": CONFIG.SW5E.damageResistanceTypes, - "di": CONFIG.SW5E.damageResistanceTypes, - "dv": CONFIG.SW5E.damageResistanceTypes, - "ci": CONFIG.SW5E.conditionTypes, - "languages": CONFIG.SW5E.languages, - "armorProf": CONFIG.SW5E.armorProficiencies, - "weaponProf": CONFIG.SW5E.weaponProficiencies, - "toolProf": CONFIG.SW5E.toolProficiencies - }; - for ( let [t, choices] of Object.entries(map) ) { - const trait = traits[t]; - if ( !trait ) continue; - let values = []; - if ( trait.value ) { - values = trait.value instanceof Array ? trait.value : [trait.value]; - } - trait.selected = values.reduce((obj, t) => { - obj[t] = choices[t]; - return obj; - }, {}); - - // Add custom entry - if ( trait.custom ) { - trait.custom.split(";").forEach((c, i) => trait.selected[`custom${i+1}`] = c.trim()); - } - trait.cssClass = !isObjectEmpty(trait.selected) ? "" : "inactive"; - } - } - - /* -------------------------------------------- */ - - /** - * Insert a power into the powerbook object when rendering the character sheet - * @param {Object} data The Actor data being prepared - * @param {Array} powers The power data being prepared - * @private - */ - _preparePowerbook(data, powers) { - const owner = this.actor.isOwner; - const levels = data.data.powers; - const powerbook = {}; - - // Define some mappings - const sections = { - "atwill": -20, - "innate": -10, - "pact": 0.5 - }; - - // Label power slot uses headers - const useLabels = { - "-20": "-", - "-10": "-", - "0": "∞" - }; - - // Format a powerbook entry for a certain indexed level - const registerSection = (sl, i, label, {prepMode="prepared", value, max, override}={}) => { - powerbook[i] = { - order: i, - label: label, - usesSlots: i > 0, - canCreate: owner, - canPrepare: (data.actor.type === "character") && (i >= 1), - powers: [], - uses: useLabels[i] || value || 0, - slots: useLabels[i] || max || 0, - override: override || 0, - dataset: {"type": "power", "level": prepMode in sections ? 1 : i, "preparation.mode": prepMode}, - prop: sl - }; - }; - - // Determine the maximum power level which has a slot - const maxLevel = Array.fromRange(10).reduce((max, i) => { - if ( i === 0 ) return max; - const level = levels[`power${i}`]; - if ( (level.max || level.override ) && ( i > max ) ) max = i; - return max; - }, 0); - - // Level-based powercasters have cantrips and leveled slots - if ( maxLevel > 0 ) { - registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); - for (let lvl = 1; lvl <= maxLevel; lvl++) { - const sl = `power${lvl}`; - registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]); - } - } - - // Pact magic users have cantrips and a pact magic section - // TODO: Check if this is needed, we've removed pacts everywhere else - if ( levels.pact && levels.pact.max ) { - if ( !powerbook["0"] ) registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); - const l = levels.pact; - const config = CONFIG.SW5E.powerPreparationModes.pact; - const level = game.i18n.localize(`SW5E.PowerLevel${levels.pact.level}`); - const label = `${config} — ${level}`; - registerSection("pact", sections.pact, label, { - prepMode: "pact", - value: l.value, - max: l.max, - override: l.override - }); - } - - // Iterate over every power item, adding powers to the powerbook by section - powers.forEach(power => { - const mode = power.data.preparation.mode || "prepared"; - let s = power.data.level || 0; - const sl = `power${s}`; - - // Specialized powercasting modes (if they exist) - if ( mode in sections ) { - s = sections[mode]; - if ( !powerbook[s] ){ - const l = levels[mode] || {}; - const config = CONFIG.SW5E.powerPreparationModes[mode]; - registerSection(mode, s, config, { - prepMode: mode, - value: l.value, - max: l.max, - override: l.override - }); + // Owned Items + data.items = actorData.items; + for (let i of data.items) { + const item = this.actor.items.get(i._id); + i.labels = item.labels; } - } + data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); - // Sections for higher-level powers which the caster "should not" have, but power items exist for - else if ( !powerbook[s] ) { - registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]}); - } + // Labels and filters + data.labels = this.actor.labels || {}; + data.filters = this._filters; - // Add the power to the relevant heading - powerbook[s].powers.push(power); - }); - - // Sort the powerbook by section level - const sorted = Object.values(powerbook); - sorted.sort((a, b) => a.order - b.order); - return sorted; - } - - /* -------------------------------------------- */ - - /** - * Determine whether an Owned Item will be shown based on the current set of filters - * @return {boolean} - * @private - */ - _filterItems(items, filters) { - return items.filter(item => { - const data = item.data; - - // Action usage - for ( let f of ["action", "bonus", "reaction"] ) { - if ( filters.has(f) ) { - if ((data.activation && (data.activation.type !== f))) return false; + // Ability Scores + for (let [a, abl] of Object.entries(actorData.data.abilities)) { + abl.icon = this._getProficiencyIcon(abl.proficient); + abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient]; + abl.label = CONFIG.SW5E.abilities[a]; } - } - // Power-specific filters - if ( filters.has("ritual") ) { - if (data.components.ritual !== true) return false; - } - if ( filters.has("concentration") ) { - if (data.components.concentration !== true) return false; - } - if ( filters.has("prepared") ) { - if ( data.level === 0 || ["innate", "always"].includes(data.preparation.mode) ) return true; - if ( this.actor.data.type === "npc" ) return true; - return data.preparation.prepared; - } - - // Equipment-specific filters - if ( filters.has("equipped") ) { - if ( data.equipped !== true ) return false; - } - return true; - }); - } - - /* -------------------------------------------- */ - - /** - * Get the font-awesome icon used to display a certain level of skill proficiency - * @private - */ - _getProficiencyIcon(level) { - const icons = { - 0: '', - 0.5: '', - 1: '', - 2: '' - }; - return icons[level] || icons[0]; - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers - /* -------------------------------------------- */ - - /** @inheritdoc */ - activateListeners(html) { - - // Activate Item Filters - const filterLists = html.find(".filter-list"); - filterLists.each(this._initializeFilterItemList.bind(this)); - filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this)); - - // Item summaries - html.find('.item .item-name.rollable h4').click(event => this._onItemSummary(event)); - - // View Item Sheets - html.find('.item-edit').click(this._onItemEdit.bind(this)); - - // Editable Only Listeners - if ( this.isEditable ) { - - // Input focus and update - const inputs = html.find("input"); - inputs.focus(ev => ev.currentTarget.select()); - inputs.addBack().find('[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this)); - - // Ability Proficiency - html.find('.ability-proficiency').click(this._onToggleAbilityProficiency.bind(this)); - - // Toggle Skill Proficiency - html.find('.skill-proficiency').on("click contextmenu", this._onCycleSkillProficiency.bind(this)); - - // Trait Selector - html.find('.trait-selector').click(this._onTraitSelector.bind(this)); - - // Configure Special Flags - html.find('.config-button').click(this._onConfigMenu.bind(this)); - - // Owned Item management - html.find('.item-create').click(this._onItemCreate.bind(this)); - html.find('.item-delete').click(this._onItemDelete.bind(this)); - html.find('.item-uses input').click(ev => ev.target.select()).change(this._onUsesChange.bind(this)); - html.find('.slot-max-override').click(this._onPowerSlotOverride.bind(this)); - - // Active Effect management - html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.actor)); - } - - // Owner Only Listeners - if ( this.actor.isOwner ) { - - // Ability Checks - html.find('.ability-name').click(this._onRollAbilityTest.bind(this)); - - - // Roll Skill Checks - html.find('.skill-name').click(this._onRollSkillCheck.bind(this)); - - // Item Rolling - html.find('.item .item-image').click(event => this._onItemRoll(event)); - html.find('.item .item-recharge').click(event => this._onItemRecharge(event)); - } - - // Otherwise remove rollable classes - else { - html.find(".rollable").each((i, el) => el.classList.remove("rollable")); - } - - // Handle default listeners last so system listeners are triggered first - super.activateListeners(html); - } - - /* -------------------------------------------- */ - - /** - * Iinitialize Item list filters by activating the set of filters which are currently applied - * @private - */ - _initializeFilterItemList(i, ul) { - const set = this._filters[ul.dataset.filter]; - const filters = ul.querySelectorAll(".filter-item"); - for ( let li of filters ) { - if ( set.has(li.dataset.filter) ) li.classList.add("active"); - } - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ - - /** - * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs - * @param event - * @private - */ - _onChangeInputDelta(event) { - const input = event.target; - const value = input.value; - if ( ["+", "-"].includes(value[0]) ) { - let delta = parseFloat(value); - input.value = getProperty(this.actor.data, input.name) + delta; - } else if ( value[0] === "=" ) { - input.value = value.slice(1); - } - } - - /* -------------------------------------------- */ - - /** - * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options - * @param {Event} event The click event which originated the selection - * @private - */ - _onConfigMenu(event) { - event.preventDefault(); - const button = event.currentTarget; - let app; - switch ( button.dataset.action ) { - case "hit-dice": - app = new ActorHitDiceConfig(this.object); - break; - case "movement": - app = new ActorMovementConfig(this.object); - break; - case "flags": - app = new ActorSheetFlags(this.object); - break; - case "senses": - app = new ActorSensesConfig(this.object); - break; - case "type": - new ActorTypeConfig(this.object).render(true); - break; - } - app?.render(true); - } - - /* -------------------------------------------- */ - - /** - * Handle cycling proficiency in a Skill - * @param {Event} event A click or contextmenu event which triggered the handler - * @private - */ - _onCycleSkillProficiency(event) { - event.preventDefault(); - const field = $(event.currentTarget).siblings('input[type="hidden"]'); - - // Get the current level and the array of levels - const level = parseFloat(field.val()); - const levels = [0, 1, 0.5, 2]; - let idx = levels.indexOf(level); - - // Toggle next level - forward on click, backwards on right - if ( event.type === "click" ) { - field.val(levels[(idx === levels.length - 1) ? 0 : idx + 1]); - } else if ( event.type === "contextmenu" ) { - field.val(levels[(idx === 0) ? levels.length - 1 : idx - 1]); - } - - // Update the field value and save the form - this._onSubmit(event); - } - - /* -------------------------------------------- */ - - /** @override */ - async _onDropActor(event, data) { - const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get('sw5e', 'allowPolymorphing')); - if ( !canPolymorph ) return false; - - // Get the target actor - let sourceActor = null; - if (data.pack) { - const pack = game.packs.find(p => p.collection === data.pack); - sourceActor = await pack.getEntity(data.id); - } else { - sourceActor = game.actors.get(data.id); - } - if ( !sourceActor ) return; - - // Define a function to record polymorph settings for future use - const rememberOptions = html => { - const options = {}; - html.find('input').each((i, el) => { - options[el.name] = el.checked; - }); - const settings = mergeObject(game.settings.get('sw5e', 'polymorphSettings') || {}, options); - game.settings.set('sw5e', 'polymorphSettings', settings); - return settings; - }; - - // Create and render the Dialog - return new Dialog({ - title: game.i18n.localize('SW5E.PolymorphPromptTitle'), - content: { - options: game.settings.get('sw5e', 'polymorphSettings'), - i18n: SW5E.polymorphSettings, - isToken: this.actor.isToken - }, - default: 'accept', - buttons: { - accept: { - icon: '', - label: game.i18n.localize('SW5E.PolymorphAcceptSettings'), - callback: html => this.actor.transformInto(sourceActor, rememberOptions(html)) - }, - wildshape: { - icon: '', - label: game.i18n.localize('SW5E.PolymorphWildShape'), - callback: html => this.actor.transformInto(sourceActor, { - keepBio: true, - keepClass: true, - keepMental: true, - mergeSaves: true, - mergeSkills: true, - transformTokens: rememberOptions(html).transformTokens - }) - }, - polymorph: { - icon: '', - label: game.i18n.localize('SW5E.Polymorph'), - callback: html => this.actor.transformInto(sourceActor, { - transformTokens: rememberOptions(html).transformTokens - }) - }, - cancel: { - icon: '', - label: game.i18n.localize('Cancel') + // Skills + if (actorData.data.skills) { + for (let [s, skl] of Object.entries(actorData.data.skills)) { + skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability]; + skl.icon = this._getProficiencyIcon(skl.value); + skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; + skl.label = CONFIG.SW5E.skills[s]; + } } - } - }, { - classes: ['dialog', 'sw5e'], - width: 600, - template: 'systems/sw5e/templates/apps/polymorph-prompt.html' - }).render(true); - } - /* -------------------------------------------- */ + // Movement speeds + data.movement = this._getMovementSpeed(actorData); - /** @override */ - async _onDropItemCreate(itemData) { + // Senses + data.senses = this._getSenses(actorData); - // Check to make sure items of this type are allowed on this actor - if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) { - return ui.notifications.warn(game.i18n.format("SW5E.ActorWarningInvalidItem", { - itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]), - actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type]) - })); + // Update traits + this._prepareTraits(actorData.data.traits); + + // Prepare owned items + this._prepareItems(data); + + // Prepare active effects + data.effects = prepareActiveEffectCategories(this.actor.effects); + + // Return data to the sheet + return data; } - // Create a Consumable power scroll on the Inventory tab - // TODO: This is pretty non functional as the base items for the scrolls, and the powers, are not defined, maybe consider using holocrons - if ( (itemData.type === "power") && (this._tabs[0].active === "inventory") ) { - const scroll = await Item5e.createScrollFromPower(itemData); - itemData = scroll.data; + /* -------------------------------------------- */ + + /** + * Prepare the display of movement speed data for the Actor* + * @param {object} actorData The Actor data being prepared. + * @param {boolean} [largestPrimary=false] Show the largest movement speed as "primary", otherwise show "walk" + * @returns {{primary: string, special: string}} + * @private + */ + _getMovementSpeed(actorData, largestPrimary = false) { + const movement = actorData.data.attributes.movement || {}; + + // Prepare an array of available movement speeds + let speeds = [ + [movement.burrow, `${game.i18n.localize("SW5E.MovementBurrow")} ${movement.burrow}`], + [movement.climb, `${game.i18n.localize("SW5E.MovementClimb")} ${movement.climb}`], + [ + movement.fly, + `${game.i18n.localize("SW5E.MovementFly")} ${movement.fly}` + + (movement.hover ? ` (${game.i18n.localize("SW5E.MovementHover")})` : "") + ], + [movement.swim, `${game.i18n.localize("SW5E.MovementSwim")} ${movement.swim}`] + ]; + if (largestPrimary) { + speeds.push([movement.walk, `${game.i18n.localize("SW5E.MovementWalk")} ${movement.walk}`]); + } + + // Filter and sort speeds on their values + speeds = speeds.filter((s) => !!s[0]).sort((a, b) => b[0] - a[0]); + + // Case 1: Largest as primary + if (largestPrimary) { + let primary = speeds.shift(); + return { + primary: `${primary ? primary[1] : "0"} ${movement.units}`, + special: speeds.map((s) => s[1]).join(", ") + }; + } + + // Case 2: Walk as primary + else { + return { + primary: `${movement.walk || 0} ${movement.units}`, + special: speeds.length ? speeds.map((s) => s[1]).join(", ") : "" + }; + } } - if ( itemData.data ) { - // Ignore certain statuses - ["equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + /* -------------------------------------------- */ - // Downgrade ATTUNED to REQUIRED - itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED); + _getSenses(actorData) { + const senses = actorData.data.attributes.senses || {}; + const tags = {}; + for (let [k, label] of Object.entries(CONFIG.SW5E.senses)) { + const v = senses[k] ?? 0; + if (v === 0) continue; + tags[k] = `${game.i18n.localize(label)} ${v} ${senses.units}`; + } + if (!!senses.special) tags["special"] = senses.special; + return tags; } - // Stack identical consumables - if ( itemData.type === "consumable" && itemData.flags.core?.sourceId ) { - const similarItem = this.actor.items.find(i => { - const sourceId = i.getFlag("core", "sourceId"); - return sourceId && (sourceId === itemData.flags.core?.sourceId) && - (i.type === "consumable"); - }); - if ( similarItem ) { - return similarItem.update({ - 'data.quantity': similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1) + /* -------------------------------------------- */ + + /** + * Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies + * @param {object} traits The raw traits data object from the actor data + * @private + */ + _prepareTraits(traits) { + const map = { + dr: CONFIG.SW5E.damageResistanceTypes, + di: CONFIG.SW5E.damageResistanceTypes, + dv: CONFIG.SW5E.damageResistanceTypes, + ci: CONFIG.SW5E.conditionTypes, + languages: CONFIG.SW5E.languages, + armorProf: CONFIG.SW5E.armorProficiencies, + weaponProf: CONFIG.SW5E.weaponProficiencies, + toolProf: CONFIG.SW5E.toolProficiencies + }; + for (let [t, choices] of Object.entries(map)) { + const trait = traits[t]; + if (!trait) continue; + let values = []; + if (trait.value) { + values = trait.value instanceof Array ? trait.value : [trait.value]; + } + trait.selected = values.reduce((obj, t) => { + obj[t] = choices[t]; + return obj; + }, {}); + + // Add custom entry + if (trait.custom) { + trait.custom.split(";").forEach((c, i) => (trait.selected[`custom${i + 1}`] = c.trim())); + } + trait.cssClass = !isObjectEmpty(trait.selected) ? "" : "inactive"; + } + } + + /* -------------------------------------------- */ + + /** + * Insert a power into the powerbook object when rendering the character sheet + * @param {Object} data The Actor data being prepared + * @param {Array} powers The power data being prepared + * @private + */ + _preparePowerbook(data, powers) { + const owner = this.actor.isOwner; + const levels = data.data.powers; + const powerbook = {}; + + // Define some mappings + const sections = { + atwill: -20, + innate: -10, + pact: 0.5 + }; + + // Label power slot uses headers + const useLabels = { + "-20": "-", + "-10": "-", + "0": "∞" + }; + + // Format a powerbook entry for a certain indexed level + const registerSection = (sl, i, label, {prepMode = "prepared", value, max, override} = {}) => { + powerbook[i] = { + order: i, + label: label, + usesSlots: i > 0, + canCreate: owner, + canPrepare: data.actor.type === "character" && i >= 1, + powers: [], + uses: useLabels[i] || value || 0, + slots: useLabels[i] || max || 0, + override: override || 0, + dataset: {"type": "power", "level": prepMode in sections ? 1 : i, "preparation.mode": prepMode}, + prop: sl + }; + }; + + // Determine the maximum power level which has a slot + const maxLevel = Array.fromRange(10).reduce((max, i) => { + if (i === 0) return max; + const level = levels[`power${i}`]; + if ((level.max || level.override) && i > max) max = i; + return max; + }, 0); + + // Level-based powercasters have cantrips and leveled slots + if (maxLevel > 0) { + registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); + for (let lvl = 1; lvl <= maxLevel; lvl++) { + const sl = `power${lvl}`; + registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]); + } + } + + // Pact magic users have cantrips and a pact magic section + // TODO: Check if this is needed, we've removed pacts everywhere else + if (levels.pact && levels.pact.max) { + if (!powerbook["0"]) registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); + const l = levels.pact; + const config = CONFIG.SW5E.powerPreparationModes.pact; + const level = game.i18n.localize(`SW5E.PowerLevel${levels.pact.level}`); + const label = `${config} — ${level}`; + registerSection("pact", sections.pact, label, { + prepMode: "pact", + value: l.value, + max: l.max, + override: l.override + }); + } + + // Iterate over every power item, adding powers to the powerbook by section + powers.forEach((power) => { + const mode = power.data.preparation.mode || "prepared"; + let s = power.data.level || 0; + const sl = `power${s}`; + + // Specialized powercasting modes (if they exist) + if (mode in sections) { + s = sections[mode]; + if (!powerbook[s]) { + const l = levels[mode] || {}; + const config = CONFIG.SW5E.powerPreparationModes[mode]; + registerSection(mode, s, config, { + prepMode: mode, + value: l.value, + max: l.max, + override: l.override + }); + } + } + + // Sections for higher-level powers which the caster "should not" have, but power items exist for + else if (!powerbook[s]) { + registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]}); + } + + // Add the power to the relevant heading + powerbook[s].powers.push(power); }); - } + + // Sort the powerbook by section level + const sorted = Object.values(powerbook); + sorted.sort((a, b) => a.order - b.order); + return sorted; } - // Create the owned item as normal - return super._onDropItemCreate(itemData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Determine whether an Owned Item will be shown based on the current set of filters + * @return {boolean} + * @private + */ + _filterItems(items, filters) { + return items.filter((item) => { + const data = item.data; - /** - * Handle enabling editing for a power slot override value - * @param {MouseEvent} event The originating click event - * @private - */ - async _onPowerSlotOverride (event) { - const span = event.currentTarget.parentElement; - const level = span.dataset.level; - const override = this.actor.data.data.powers[level].override || span.dataset.slots; - const input = document.createElement("INPUT"); - input.type = "text"; - input.name = `data.powers.${level}.override`; - input.value = override; - input.placeholder = span.dataset.slots; - input.dataset.dtype = "Number"; + // Action usage + for (let f of ["action", "bonus", "reaction"]) { + if (filters.has(f)) { + if (data.activation && data.activation.type !== f) return false; + } + } - // Replace the HTML - const parent = span.parentElement; - parent.removeChild(span); - parent.appendChild(input); - } + // Power-specific filters + if (filters.has("ritual")) { + if (data.components.ritual !== true) return false; + } + if (filters.has("concentration")) { + if (data.components.concentration !== true) return false; + } + if (filters.has("prepared")) { + if (data.level === 0 || ["innate", "always"].includes(data.preparation.mode)) return true; + if (this.actor.data.type === "npc") return true; + return data.preparation.prepared; + } - /* -------------------------------------------- */ - - /** - * Change the uses amount of an Owned Item within the Actor - * @param {Event} event The triggering click event - * @private - */ - async _onUsesChange(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max); - event.target.value = uses; - return item.update({ 'data.uses.value': uses }); - } - - /* -------------------------------------------- */ - - /** - * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method - * @private - */ - _onItemRoll(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - return item.roll(); - } - - /* -------------------------------------------- */ - - /** - * Handle attempting to recharge an item usage by rolling a recharge check - * @param {Event} event The originating click event - * @private - */ - _onItemRecharge(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - return item.rollRecharge(); - }; - - /* -------------------------------------------- */ - - /** - * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method - * @private - */ - _onItemSummary(event) { - event.preventDefault(); - let li = $(event.currentTarget).parents(".item"), - item = this.actor.items.get(li.data("item-id")), - chatData = item.getChatData({secrets: this.actor.isOwner}); - - // Toggle summary - if ( li.hasClass("expanded") ) { - let summary = li.children(".item-summary"); - summary.slideUp(200, () => summary.remove()); - } else { - let div = $(`
${chatData.description.value}
`); - let props = $(`
`); - chatData.properties.forEach(p => props.append(`${p}`)); - div.append(props); - li.append(div.hide()); - div.slideDown(200); + // Equipment-specific filters + if (filters.has("equipped")) { + if (data.equipped !== true) return false; + } + return true; + }); } - li.toggleClass("expanded"); - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset - * @param {Event} event The originating click event - * @private - */ - _onItemCreate(event) { - event.preventDefault(); - const header = event.currentTarget; - const type = header.dataset.type; - const itemData = { - name: game.i18n.format("SW5E.ItemNew", {type: game.i18n.localize(`SW5E.ItemType${type.capitalize()}`)}), - type: type, - data: foundry.utils.deepClone(header.dataset) - }; - delete itemData.data["type"]; - return this.actor.createEmbeddedDocuments("Item", [itemData]); - } - - /* -------------------------------------------- */ - - /** - * Handle editing an existing Owned Item for the Actor - * @param {Event} event The originating click event - * @private - */ - _onItemEdit(event) { - event.preventDefault(); - const li = event.currentTarget.closest(".item"); - const item = this.actor.items.get(li.dataset.itemId); - return item.sheet.render(true); - } - - /* -------------------------------------------- */ - - /** - * Handle deleting an existing Owned Item for the Actor - * @param {Event} event The originating click event - * @private - */ - _onItemDelete(event) { - event.preventDefault(); - const li = event.currentTarget.closest(".item"); - const item = this.actor.items.get(li.dataset.itemId); - if ( item ) return item.delete(); - } - - /* -------------------------------------------- */ - - /** - * Handle rolling an Ability check, either a test or a saving throw - * @param {Event} event The originating click event - * @private - */ - _onRollAbilityTest(event) { - event.preventDefault(); - let ability = event.currentTarget.parentElement.dataset.ability; - return this.actor.rollAbility(ability, {event: event}); - } - - /* -------------------------------------------- */ - - /** - * Handle rolling a Skill check - * @param {Event} event The originating click event - * @private - */ - _onRollSkillCheck(event) { - event.preventDefault(); - const skill = event.currentTarget.parentElement.dataset.skill; - return this.actor.rollSkill(skill, {event: event}); - } - - /* -------------------------------------------- */ - - /** - * Handle toggling Ability score proficiency level - * @param {Event} event The originating click event - * @private - */ - _onToggleAbilityProficiency(event) { - event.preventDefault(); - const field = event.currentTarget.previousElementSibling; - return this.actor.update({[field.name]: 1 - parseInt(field.value)}); - } - - /* -------------------------------------------- */ - - /** - * Handle toggling of filters to display a different set of owned items - * @param {Event} event The click event which triggered the toggle - * @private - */ - _onToggleFilter(event) { - event.preventDefault(); - const li = event.currentTarget; - const set = this._filters[li.parentElement.dataset.filter]; - const filter = li.dataset.filter; - if ( set.has(filter) ) set.delete(filter); - else set.add(filter); - return this.render(); - } - - /* -------------------------------------------- */ - - /** - * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options - * @param {Event} event The click event which originated the selection - * @private - */ - _onTraitSelector(event) { - event.preventDefault(); - const a = event.currentTarget; - const label = a.parentElement.querySelector("label"); - const choices = CONFIG.SW5E[a.dataset.options]; - const options = { name: a.dataset.target, title: label.innerText, choices }; - return new TraitSelector(this.actor, options).render(true) - } - - /* -------------------------------------------- */ - - /** @override */ - _getHeaderButtons() { - let buttons = super._getHeaderButtons(); - if ( this.actor.isPolymorphed ) { - buttons.unshift({ - label: 'SW5E.PolymorphRestoreTransformation', - class: "restore-transformation", - icon: "fas fa-backward", - onclick: () => this.actor.revertOriginalForm() - }); + /** + * Get the font-awesome icon used to display a certain level of skill proficiency + * @private + */ + _getProficiencyIcon(level) { + const icons = { + 0: '', + 0.5: '', + 1: '', + 2: '' + }; + return icons[level] || icons[0]; } - return buttons; - } -} \ No newline at end of file + + /* -------------------------------------------- */ + /* Event Listeners and Handlers + /* -------------------------------------------- */ + + /** @inheritdoc */ + activateListeners(html) { + // Activate Item Filters + const filterLists = html.find(".filter-list"); + filterLists.each(this._initializeFilterItemList.bind(this)); + filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this)); + + // Item summaries + html.find(".item .item-name.rollable h4").click((event) => this._onItemSummary(event)); + + // View Item Sheets + html.find(".item-edit").click(this._onItemEdit.bind(this)); + + // Editable Only Listeners + if (this.isEditable) { + // Input focus and update + const inputs = html.find("input"); + inputs.focus((ev) => ev.currentTarget.select()); + inputs.addBack().find('[data-dtype="Number"]').change(this._onChangeInputDelta.bind(this)); + + // Ability Proficiency + html.find(".ability-proficiency").click(this._onToggleAbilityProficiency.bind(this)); + + // Toggle Skill Proficiency + html.find(".skill-proficiency").on("click contextmenu", this._onCycleSkillProficiency.bind(this)); + + // Trait Selector + html.find(".trait-selector").click(this._onTraitSelector.bind(this)); + + // Configure Special Flags + html.find(".config-button").click(this._onConfigMenu.bind(this)); + + // Owned Item management + html.find(".item-create").click(this._onItemCreate.bind(this)); + html.find(".item-delete").click(this._onItemDelete.bind(this)); + html.find(".item-uses input") + .click((ev) => ev.target.select()) + .change(this._onUsesChange.bind(this)); + html.find(".slot-max-override").click(this._onPowerSlotOverride.bind(this)); + + // Active Effect management + html.find(".effect-control").click((ev) => onManageActiveEffect(ev, this.actor)); + } + + // Owner Only Listeners + if (this.actor.isOwner) { + // Ability Checks + html.find(".ability-name").click(this._onRollAbilityTest.bind(this)); + + // Roll Skill Checks + html.find(".skill-name").click(this._onRollSkillCheck.bind(this)); + + // Item Rolling + html.find(".item .item-image").click((event) => this._onItemRoll(event)); + html.find(".item .item-recharge").click((event) => this._onItemRecharge(event)); + } + + // Otherwise remove rollable classes + else { + html.find(".rollable").each((i, el) => el.classList.remove("rollable")); + } + + // Handle default listeners last so system listeners are triggered first + super.activateListeners(html); + } + + /* -------------------------------------------- */ + + /** + * Iinitialize Item list filters by activating the set of filters which are currently applied + * @private + */ + _initializeFilterItemList(i, ul) { + const set = this._filters[ul.dataset.filter]; + const filters = ul.querySelectorAll(".filter-item"); + for (let li of filters) { + if (set.has(li.dataset.filter)) li.classList.add("active"); + } + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** + * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs + * @param event + * @private + */ + _onChangeInputDelta(event) { + const input = event.target; + const value = input.value; + if (["+", "-"].includes(value[0])) { + let delta = parseFloat(value); + input.value = getProperty(this.actor.data, input.name) + delta; + } else if (value[0] === "=") { + input.value = value.slice(1); + } + } + + /* -------------------------------------------- */ + + /** + * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options + * @param {Event} event The click event which originated the selection + * @private + */ + _onConfigMenu(event) { + event.preventDefault(); + const button = event.currentTarget; + let app; + switch (button.dataset.action) { + case "hit-dice": + app = new ActorHitDiceConfig(this.object); + break; + case "movement": + app = new ActorMovementConfig(this.object); + break; + case "flags": + app = new ActorSheetFlags(this.object); + break; + case "senses": + app = new ActorSensesConfig(this.object); + break; + case "type": + new ActorTypeConfig(this.object).render(true); + break; + } + app?.render(true); + } + + /* -------------------------------------------- */ + + /** + * Handle cycling proficiency in a Skill + * @param {Event} event A click or contextmenu event which triggered the handler + * @private + */ + _onCycleSkillProficiency(event) { + event.preventDefault(); + const field = $(event.currentTarget).siblings('input[type="hidden"]'); + + // Get the current level and the array of levels + const level = parseFloat(field.val()); + const levels = [0, 1, 0.5, 2]; + let idx = levels.indexOf(level); + + // Toggle next level - forward on click, backwards on right + if (event.type === "click") { + field.val(levels[idx === levels.length - 1 ? 0 : idx + 1]); + } else if (event.type === "contextmenu") { + field.val(levels[idx === 0 ? levels.length - 1 : idx - 1]); + } + + // Update the field value and save the form + this._onSubmit(event); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropActor(event, data) { + const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get("sw5e", "allowPolymorphing")); + if (!canPolymorph) return false; + + // Get the target actor + let sourceActor = null; + if (data.pack) { + const pack = game.packs.find((p) => p.collection === data.pack); + sourceActor = await pack.getEntity(data.id); + } else { + sourceActor = game.actors.get(data.id); + } + if (!sourceActor) return; + + // Define a function to record polymorph settings for future use + const rememberOptions = (html) => { + const options = {}; + html.find("input").each((i, el) => { + options[el.name] = el.checked; + }); + const settings = mergeObject(game.settings.get("sw5e", "polymorphSettings") || {}, options); + game.settings.set("sw5e", "polymorphSettings", settings); + return settings; + }; + + // Create and render the Dialog + return new Dialog( + { + title: game.i18n.localize("SW5E.PolymorphPromptTitle"), + content: { + options: game.settings.get("sw5e", "polymorphSettings"), + i18n: SW5E.polymorphSettings, + isToken: this.actor.isToken + }, + default: "accept", + buttons: { + accept: { + icon: '', + label: game.i18n.localize("SW5E.PolymorphAcceptSettings"), + callback: (html) => this.actor.transformInto(sourceActor, rememberOptions(html)) + }, + wildshape: { + icon: '', + label: game.i18n.localize("SW5E.PolymorphWildShape"), + callback: (html) => + this.actor.transformInto(sourceActor, { + keepBio: true, + keepClass: true, + keepMental: true, + mergeSaves: true, + mergeSkills: true, + transformTokens: rememberOptions(html).transformTokens + }) + }, + polymorph: { + icon: '', + label: game.i18n.localize("SW5E.Polymorph"), + callback: (html) => + this.actor.transformInto(sourceActor, { + transformTokens: rememberOptions(html).transformTokens + }) + }, + cancel: { + icon: '', + label: game.i18n.localize("Cancel") + } + } + }, + { + classes: ["dialog", "sw5e"], + width: 600, + template: "systems/sw5e/templates/apps/polymorph-prompt.html" + } + ).render(true); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropItemCreate(itemData) { + // Check to make sure items of this type are allowed on this actor + if (this.constructor.unsupportedItemTypes.has(itemData.type)) { + return ui.notifications.warn( + game.i18n.format("SW5E.ActorWarningInvalidItem", { + itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]), + actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type]) + }) + ); + } + + // Create a Consumable power scroll on the Inventory tab + // TODO: This is pretty non functional as the base items for the scrolls, and the powers, are not defined, maybe consider using holocrons + if (itemData.type === "power" && this._tabs[0].active === "inventory") { + const scroll = await Item5e.createScrollFromPower(itemData); + itemData = scroll.data; + } + + if (itemData.data) { + // Ignore certain statuses + ["equipped", "proficient", "prepared"].forEach((k) => delete itemData.data[k]); + + // Downgrade ATTUNED to REQUIRED + itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED); + } + + // Stack identical consumables + if (itemData.type === "consumable" && itemData.flags.core?.sourceId) { + const similarItem = this.actor.items.find((i) => { + const sourceId = i.getFlag("core", "sourceId"); + return sourceId && sourceId === itemData.flags.core?.sourceId && i.type === "consumable"; + }); + if (similarItem) { + return similarItem.update({ + "data.quantity": similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1) + }); + } + } + + // Create the owned item as normal + return super._onDropItemCreate(itemData); + } + + /* -------------------------------------------- */ + + /** + * Handle enabling editing for a power slot override value + * @param {MouseEvent} event The originating click event + * @private + */ + async _onPowerSlotOverride(event) { + const span = event.currentTarget.parentElement; + const level = span.dataset.level; + const override = this.actor.data.data.powers[level].override || span.dataset.slots; + const input = document.createElement("INPUT"); + input.type = "text"; + input.name = `data.powers.${level}.override`; + input.value = override; + input.placeholder = span.dataset.slots; + input.dataset.dtype = "Number"; + + // Replace the HTML + const parent = span.parentElement; + parent.removeChild(span); + parent.appendChild(input); + } + + /* -------------------------------------------- */ + + /** + * Change the uses amount of an Owned Item within the Actor + * @param {Event} event The triggering click event + * @private + */ + async _onUsesChange(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max); + event.target.value = uses; + return item.update({"data.uses.value": uses}); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method + * @private + */ + _onItemRoll(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + return item.roll(); + } + + /* -------------------------------------------- */ + + /** + * Handle attempting to recharge an item usage by rolling a recharge check + * @param {Event} event The originating click event + * @private + */ + _onItemRecharge(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + return item.rollRecharge(); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling of an item from the Actor sheet, obtaining the Item instance and dispatching to it's roll method + * @private + */ + _onItemSummary(event) { + event.preventDefault(); + let li = $(event.currentTarget).parents(".item"), + item = this.actor.items.get(li.data("item-id")), + chatData = item.getChatData({secrets: this.actor.isOwner}); + + // Toggle summary + if (li.hasClass("expanded")) { + let summary = li.children(".item-summary"); + summary.slideUp(200, () => summary.remove()); + } else { + let div = $(`
${chatData.description.value}
`); + let props = $(`
`); + chatData.properties.forEach((p) => props.append(`${p}`)); + div.append(props); + li.append(div.hide()); + div.slideDown(200); + } + li.toggleClass("expanded"); + } + + /* -------------------------------------------- */ + + /** + * Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset + * @param {Event} event The originating click event + * @private + */ + _onItemCreate(event) { + event.preventDefault(); + const header = event.currentTarget; + const type = header.dataset.type; + const itemData = { + name: game.i18n.format("SW5E.ItemNew", {type: game.i18n.localize(`SW5E.ItemType${type.capitalize()}`)}), + type: type, + data: foundry.utils.deepClone(header.dataset) + }; + delete itemData.data["type"]; + return this.actor.createEmbeddedDocuments("Item", [itemData]); + } + + /* -------------------------------------------- */ + + /** + * Handle editing an existing Owned Item for the Actor + * @param {Event} event The originating click event + * @private + */ + _onItemEdit(event) { + event.preventDefault(); + const li = event.currentTarget.closest(".item"); + const item = this.actor.items.get(li.dataset.itemId); + return item.sheet.render(true); + } + + /* -------------------------------------------- */ + + /** + * Handle deleting an existing Owned Item for the Actor + * @param {Event} event The originating click event + * @private + */ + _onItemDelete(event) { + event.preventDefault(); + const li = event.currentTarget.closest(".item"); + const item = this.actor.items.get(li.dataset.itemId); + if (item) return item.delete(); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling an Ability check, either a test or a saving throw + * @param {Event} event The originating click event + * @private + */ + _onRollAbilityTest(event) { + event.preventDefault(); + let ability = event.currentTarget.parentElement.dataset.ability; + return this.actor.rollAbility(ability, {event: event}); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling a Skill check + * @param {Event} event The originating click event + * @private + */ + _onRollSkillCheck(event) { + event.preventDefault(); + const skill = event.currentTarget.parentElement.dataset.skill; + return this.actor.rollSkill(skill, {event: event}); + } + + /* -------------------------------------------- */ + + /** + * Handle toggling Ability score proficiency level + * @param {Event} event The originating click event + * @private + */ + _onToggleAbilityProficiency(event) { + event.preventDefault(); + const field = event.currentTarget.previousElementSibling; + return this.actor.update({[field.name]: 1 - parseInt(field.value)}); + } + + /* -------------------------------------------- */ + + /** + * Handle toggling of filters to display a different set of owned items + * @param {Event} event The click event which triggered the toggle + * @private + */ + _onToggleFilter(event) { + event.preventDefault(); + const li = event.currentTarget; + const set = this._filters[li.parentElement.dataset.filter]; + const filter = li.dataset.filter; + if (set.has(filter)) set.delete(filter); + else set.add(filter); + return this.render(); + } + + /* -------------------------------------------- */ + + /** + * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options + * @param {Event} event The click event which originated the selection + * @private + */ + _onTraitSelector(event) { + event.preventDefault(); + const a = event.currentTarget; + const label = a.parentElement.querySelector("label"); + const choices = CONFIG.SW5E[a.dataset.options]; + const options = {name: a.dataset.target, title: label.innerText, choices}; + return new TraitSelector(this.actor, options).render(true); + } + + /* -------------------------------------------- */ + + /** @override */ + _getHeaderButtons() { + let buttons = super._getHeaderButtons(); + if (this.actor.isPolymorphed) { + buttons.unshift({ + label: "SW5E.PolymorphRestoreTransformation", + class: "restore-transformation", + icon: "fas fa-backward", + onclick: () => this.actor.revertOriginalForm() + }); + } + return buttons; + } +} diff --git a/module/actor/sheets/oldSheets/character.js b/module/actor/sheets/oldSheets/character.js index dd82ecfe..038a28b4 100644 --- a/module/actor/sheets/oldSheets/character.js +++ b/module/actor/sheets/oldSheets/character.js @@ -7,295 +7,362 @@ import Actor5e from "../../entity.js"; * @type {ActorSheet5e} */ export default class ActorSheet5eCharacter extends ActorSheet5e { + /** + * Define default rendering options for the NPC sheet + * @return {Object} + */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["sw5e", "sheet", "actor", "character"], + width: 720, + height: 736 + }); + } - /** - * Define default rendering options for the NPC sheet - * @return {Object} - */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["sw5e", "sheet", "actor", "character"], - width: 720, - height: 736 - }); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Add some extra data when rendering the sheet to reduce the amount of logic required within the template. + */ + getData() { + const sheetData = super.getData(); - /** - * Add some extra data when rendering the sheet to reduce the amount of logic required within the template. - */ - getData() { - const sheetData = super.getData(); + // Temporary HP + let hp = sheetData.data.attributes.hp; + if (hp.temp === 0) delete hp.temp; + if (hp.tempmax === 0) delete hp.tempmax; - // Temporary HP - let hp = sheetData.data.attributes.hp; - if (hp.temp === 0) delete hp.temp; - if (hp.tempmax === 0) delete hp.tempmax; + // Resources + sheetData["resources"] = ["primary", "secondary", "tertiary"].reduce((arr, r) => { + const res = sheetData.data.resources[r] || {}; + res.name = r; + res.placeholder = game.i18n.localize("SW5E.Resource" + r.titleCase()); + if (res && res.value === 0) delete res.value; + if (res && res.max === 0) delete res.max; + return arr.concat([res]); + }, []); - // Resources - sheetData["resources"] = ["primary", "secondary", "tertiary"].reduce((arr, r) => { - const res = sheetData.data.resources[r] || {}; - res.name = r; - res.placeholder = game.i18n.localize("SW5E.Resource"+r.titleCase()); - if (res && res.value === 0) delete res.value; - if (res && res.max === 0) delete res.max; - return arr.concat([res]); - }, []); + // Experience Tracking + sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking"); + sheetData["classLabels"] = this.actor.itemTypes.class.map((c) => c.name).join(", "); + sheetData["multiclassLabels"] = this.actor.itemTypes.class + .map((c) => { + return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(" "); + }) + .join(", "); - // Experience Tracking - sheetData["disableExperience"] = game.settings.get("sw5e", "disableExperienceTracking"); - sheetData["classLabels"] = this.actor.itemTypes.class.map(c => c.name).join(", "); - sheetData["multiclassLabels"] = this.actor.itemTypes.class.map(c => { - return [c.data.data.archetype, c.name, c.data.data.levels].filterJoin(' ') - }).join(', '); + // Return data for rendering + return sheetData; + } - // Return data for rendering - return sheetData; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Organize and classify Owned Items for Character sheets + * @private + */ + _prepareItems(data) { + // Categorize items as inventory, powerbook, features, and classes + const inventory = { + weapon: {label: "SW5E.ItemTypeWeaponPl", items: [], dataset: {type: "weapon"}}, + equipment: {label: "SW5E.ItemTypeEquipmentPl", items: [], dataset: {type: "equipment"}}, + consumable: {label: "SW5E.ItemTypeConsumablePl", items: [], dataset: {type: "consumable"}}, + tool: {label: "SW5E.ItemTypeToolPl", items: [], dataset: {type: "tool"}}, + backpack: {label: "SW5E.ItemTypeContainerPl", items: [], dataset: {type: "backpack"}}, + loot: {label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"}} + }; - /** - * Organize and classify Owned Items for Character sheets - * @private - */ - _prepareItems(data) { + // Partition items by category + let [ + items, + powers, + feats, + classes, + species, + archetypes, + classfeatures, + backgrounds, + fightingstyles, + fightingmasteries, + lightsaberforms + ] = data.items.reduce( + (arr, item) => { + // Item details + item.img = item.img || CONST.DEFAULT_TOKEN; + item.isStack = Number.isNumeric(item.data.quantity) && item.data.quantity !== 1; + item.attunement = { + [CONFIG.SW5E.attunementTypes.REQUIRED]: { + icon: "fa-sun", + cls: "not-attuned", + title: "SW5E.AttunementRequired" + }, + [CONFIG.SW5E.attunementTypes.ATTUNED]: { + icon: "fa-sun", + cls: "attuned", + title: "SW5E.AttunementAttuned" + } + }[item.data.attunement]; - // Categorize items as inventory, powerbook, features, and classes - const inventory = { - weapon: { label: "SW5E.ItemTypeWeaponPl", items: [], dataset: {type: "weapon"} }, - equipment: { label: "SW5E.ItemTypeEquipmentPl", items: [], dataset: {type: "equipment"} }, - consumable: { label: "SW5E.ItemTypeConsumablePl", items: [], dataset: {type: "consumable"} }, - tool: { label: "SW5E.ItemTypeToolPl", items: [], dataset: {type: "tool"} }, - backpack: { label: "SW5E.ItemTypeContainerPl", items: [], dataset: {type: "backpack"} }, - loot: { label: "SW5E.ItemTypeLootPl", items: [], dataset: {type: "loot"} } - }; + // Item usage + item.hasUses = item.data.uses && item.data.uses.max > 0; + item.isOnCooldown = + item.data.recharge && !!item.data.recharge.value && item.data.recharge.charged === false; + item.isDepleted = item.isOnCooldown && item.data.uses.per && item.data.uses.value > 0; + item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type); - // Partition items by category - let [items, powers, feats, classes, species, archetypes, classfeatures, backgrounds, fightingstyles, fightingmasteries, lightsaberforms] = data.items.reduce((arr, item) => { + // Item toggle state + this._prepareItemToggleState(item); - // Item details - item.img = item.img || CONST.DEFAULT_TOKEN; - item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); - item.attunement = { - [CONFIG.SW5E.attunementTypes.REQUIRED]: { - icon: "fa-sun", - cls: "not-attuned", - title: "SW5E.AttunementRequired" - }, - [CONFIG.SW5E.attunementTypes.ATTUNED]: { - icon: "fa-sun", - cls: "attuned", - title: "SW5E.AttunementAttuned" + // Primary Class + if (item.type === "class") + item.isOriginalClass = item._id === this.actor.data.data.details.originalClass; + + // Classify items into types + if (item.type === "power") arr[1].push(item); + else if (item.type === "feat") arr[2].push(item); + else if (item.type === "class") arr[3].push(item); + else if (item.type === "species") arr[4].push(item); + else if (item.type === "archetype") arr[5].push(item); + else if (item.type === "classfeature") arr[6].push(item); + else if (item.type === "background") arr[7].push(item); + else if (item.type === "fightingstyle") arr[8].push(item); + else if (item.type === "fightingmastery") arr[9].push(item); + else if (item.type === "lightsaberform") arr[10].push(item); + else if (Object.keys(inventory).includes(item.type)) arr[0].push(item); + return arr; + }, + [[], [], [], [], [], [], [], [], [], [], []] + ); + + // Apply active item filters + items = this._filterItems(items, this._filters.inventory); + powers = this._filterItems(powers, this._filters.powerbook); + feats = this._filterItems(feats, this._filters.features); + + // Organize items + for (let i of items) { + i.data.quantity = i.data.quantity || 0; + i.data.weight = i.data.weight || 0; + i.totalWeight = (i.data.quantity * i.data.weight).toNearest(0.1); + inventory[i.type].items.push(i); } - }[item.data.attunement]; - // Item usage - item.hasUses = item.data.uses && (item.data.uses.max > 0); - item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); - item.isDepleted = item.isOnCooldown && (item.data.uses.per && (item.data.uses.value > 0)); - item.hasTarget = !!item.data.target && !(["none",""].includes(item.data.target.type)); + // Organize Powerbook and count the number of prepared powers (excluding always, at will, etc...) + const powerbook = this._preparePowerbook(data, powers); + const nPrepared = powers.filter((s) => { + return s.data.level > 0 && s.data.preparation.mode === "prepared" && s.data.preparation.prepared; + }).length; - // Item toggle state - this._prepareItemToggleState(item); - - // Primary Class - if ( item.type === "class" ) item.isOriginalClass = ( item._id === this.actor.data.data.details.originalClass ); - - // Classify items into types - if ( item.type === "power" ) arr[1].push(item); - else if ( item.type === "feat" ) arr[2].push(item); - else if ( item.type === "class" ) arr[3].push(item); - else if ( item.type === "species" ) arr[4].push(item); - else if ( item.type === "archetype" ) arr[5].push(item); - else if ( item.type === "classfeature" ) arr[6].push(item); - else if ( item.type === "background" ) arr[7].push(item); - else if ( item.type === "fightingstyle" ) arr[8].push(item); - else if ( item.type === "fightingmastery" ) arr[9].push(item); - else if ( item.type === "lightsaberform" ) arr[10].push(item); - else if ( Object.keys(inventory).includes(item.type ) ) arr[0].push(item); - return arr; - }, [[], [], [], [], [], [], [], [], [], [], []]); - - // Apply active item filters - items = this._filterItems(items, this._filters.inventory); - powers = this._filterItems(powers, this._filters.powerbook); - feats = this._filterItems(feats, this._filters.features); - - // Organize items - for ( let i of items ) { - i.data.quantity = i.data.quantity || 0; - i.data.weight = i.data.weight || 0; - i.totalWeight = (i.data.quantity * i.data.weight).toNearest(0.1); - inventory[i.type].items.push(i); - } - - // Organize Powerbook and count the number of prepared powers (excluding always, at will, etc...) - const powerbook = this._preparePowerbook(data, powers); - const nPrepared = powers.filter(s => { - return (s.data.level > 0) && (s.data.preparation.mode === "prepared") && s.data.preparation.prepared; - }).length; - - // Organize Features - const features = { - classes: { label: "SW5E.ItemTypeClassPl", items: [], hasActions: false, dataset: {type: "class"}, isClass: true }, - classfeatures: { label: "SW5E.ItemTypeClassFeats", items: [], hasActions: true, dataset: {type: "classfeature"}, isClassfeature: true }, - archetype: { label: "SW5E.ItemTypeArchetype", items: [], hasActions: false, dataset: {type: "archetype"}, isArchetype: true }, - species: { label: "SW5E.ItemTypeSpecies", items: [], hasActions: false, dataset: {type: "species"}, isSpecies: true }, - background: { label: "SW5E.ItemTypeBackground", items: [], hasActions: false, dataset: {type: "background"}, isBackground: true }, - fightingstyles: { label: "SW5E.ItemTypeFightingStylePl", items: [], hasActions: false, dataset: {type: "fightingstyle"}, isFightingstyle: true }, - fightingmasteries: { label: "SW5E.ItemTypeFightingMasteryPl", items: [], hasActions: false, dataset: {type: "fightingmastery"}, isFightingmastery: true }, - lightsaberforms: { label: "SW5E.ItemTypeLightsaberFormPl", items: [], hasActions: false, dataset: {type: "lightsaberform"}, isLightsaberform: true }, - active: { label: "SW5E.FeatureActive", items: [], hasActions: true, dataset: {type: "feat", "activation.type": "action"} }, - passive: { label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"} } - }; - for ( let f of feats ) { - if ( f.data.activation.type ) features.active.items.push(f); - else features.passive.items.push(f); - } - classes.sort((a, b) => b.data.levels - a.data.levels); - features.classes.items = classes; - features.classfeatures.items = classfeatures; - features.archetype.items = archetypes; - features.species.items = species; - features.background.items = backgrounds; - features.fightingstyles.items = fightingstyles; - features.fightingmasteries.items = fightingmasteries; - features.lightsaberforms.items = lightsaberforms; - - // Assign and return - data.inventory = Object.values(inventory); - data.powerbook = powerbook; - data.preparedPowers = nPrepared; - data.features = Object.values(features); - } - - /* -------------------------------------------- */ - - /** - * A helper method to establish the displayed preparation state for an item - * @param {Item} item - * @private - */ - _prepareItemToggleState(item) { - if (item.type === "power") { - const isAlways = getProperty(item.data, "preparation.mode") === "always"; - const isPrepared = getProperty(item.data, "preparation.prepared"); - item.toggleClass = isPrepared ? "active" : ""; - if ( isAlways ) item.toggleClass = "fixed"; - if ( isAlways ) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.always; - else if ( isPrepared ) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.prepared; - else item.toggleTitle = game.i18n.localize("SW5E.PowerUnprepared"); - } - else { - const isActive = getProperty(item.data, "equipped"); - item.toggleClass = isActive ? "active" : ""; - item.toggleTitle = game.i18n.localize(isActive ? "SW5E.Equipped" : "SW5E.Unequipped"); - } - } - - /* -------------------------------------------- */ - /* Event Listeners and Handlers - /* -------------------------------------------- */ - - /** - * Activate event listeners using the prepared sheet HTML - * @param html {jQuery} The prepared HTML object ready to be rendered into the DOM - */ - activateListeners(html) { - super.activateListeners(html); - if ( !this.isEditable ) return; - - // Item State Toggling - html.find('.item-toggle').click(this._onToggleItem.bind(this)); - - // Short and Long Rest - html.find('.short-rest').click(this._onShortRest.bind(this)); - html.find('.long-rest').click(this._onLongRest.bind(this)); - - // Rollable sheet actions - html.find(".rollable[data-action]").click(this._onSheetAction.bind(this)); - } - - /* -------------------------------------------- */ - - /** - * Handle mouse click events for character sheet actions - * @param {MouseEvent} event The originating click event - * @private - */ - _onSheetAction(event) { - event.preventDefault(); - const button = event.currentTarget; - switch( button.dataset.action ) { - case "rollDeathSave": - return this.actor.rollDeathSave({event: event}); - case "rollInitiative": - return this.actor.rollInitiative({createCombatants: true}); - } - } - - /* -------------------------------------------- */ - - /** - * Handle toggling the state of an Owned Item within the Actor - * @param {Event} event The triggering click event - * @private - */ - _onToggleItem(event) { - event.preventDefault(); - const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.items.get(itemId); - const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped"; - return item.update({[attr]: !getProperty(item.data, attr)}); - } - - /* -------------------------------------------- */ - - /** - * Take a short rest, calling the relevant function on the Actor instance - * @param {Event} event The triggering click event - * @private - */ - async _onShortRest(event) { - event.preventDefault(); - await this._onSubmit(event); - return this.actor.shortRest(); - } - - /* -------------------------------------------- */ - - /** - * Take a long rest, calling the relevant function on the Actor instance - * @param {Event} event The triggering click event - * @private - */ - async _onLongRest(event) { - event.preventDefault(); - await this._onSubmit(event); - return this.actor.longRest(); - } - - /* -------------------------------------------- */ - - /** @override */ - async _onDropItemCreate(itemData) { - - // Increment the number of class levels a character instead of creating a new item - if ( itemData.type === "class" ) { - const cls = this.actor.itemTypes.class.find(c => c.name === itemData.name); - let priorLevel = cls?.data.data.levels ?? 0; - if ( !!cls ) { - const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level); - if ( next > priorLevel ) { - itemData.levels = next; - return cls.update({"data.levels": next}); + // Organize Features + const features = { + classes: { + label: "SW5E.ItemTypeClassPl", + items: [], + hasActions: false, + dataset: {type: "class"}, + isClass: true + }, + classfeatures: { + label: "SW5E.ItemTypeClassFeats", + items: [], + hasActions: true, + dataset: {type: "classfeature"}, + isClassfeature: true + }, + archetype: { + label: "SW5E.ItemTypeArchetype", + items: [], + hasActions: false, + dataset: {type: "archetype"}, + isArchetype: true + }, + species: { + label: "SW5E.ItemTypeSpecies", + items: [], + hasActions: false, + dataset: {type: "species"}, + isSpecies: true + }, + background: { + label: "SW5E.ItemTypeBackground", + items: [], + hasActions: false, + dataset: {type: "background"}, + isBackground: true + }, + fightingstyles: { + label: "SW5E.ItemTypeFightingStylePl", + items: [], + hasActions: false, + dataset: {type: "fightingstyle"}, + isFightingstyle: true + }, + fightingmasteries: { + label: "SW5E.ItemTypeFightingMasteryPl", + items: [], + hasActions: false, + dataset: {type: "fightingmastery"}, + isFightingmastery: true + }, + lightsaberforms: { + label: "SW5E.ItemTypeLightsaberFormPl", + items: [], + hasActions: false, + dataset: {type: "lightsaberform"}, + isLightsaberform: true + }, + active: { + label: "SW5E.FeatureActive", + items: [], + hasActions: true, + dataset: {"type": "feat", "activation.type": "action"} + }, + passive: {label: "SW5E.FeaturePassive", items: [], hasActions: false, dataset: {type: "feat"}} + }; + for (let f of feats) { + if (f.data.activation.type) features.active.items.push(f); + else features.passive.items.push(f); } - } + classes.sort((a, b) => b.data.levels - a.data.levels); + features.classes.items = classes; + features.classfeatures.items = classfeatures; + features.archetype.items = archetypes; + features.species.items = species; + features.background.items = backgrounds; + features.fightingstyles.items = fightingstyles; + features.fightingmasteries.items = fightingmasteries; + features.lightsaberforms.items = lightsaberforms; + + // Assign and return + data.inventory = Object.values(inventory); + data.powerbook = powerbook; + data.preparedPowers = nPrepared; + data.features = Object.values(features); } - // Default drop handling if levels were not added - return super._onDropItemCreate(itemData); - } + /* -------------------------------------------- */ + + /** + * A helper method to establish the displayed preparation state for an item + * @param {Item} item + * @private + */ + _prepareItemToggleState(item) { + if (item.type === "power") { + const isAlways = getProperty(item.data, "preparation.mode") === "always"; + const isPrepared = getProperty(item.data, "preparation.prepared"); + item.toggleClass = isPrepared ? "active" : ""; + if (isAlways) item.toggleClass = "fixed"; + if (isAlways) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.always; + else if (isPrepared) item.toggleTitle = CONFIG.SW5E.powerPreparationModes.prepared; + else item.toggleTitle = game.i18n.localize("SW5E.PowerUnprepared"); + } else { + const isActive = getProperty(item.data, "equipped"); + item.toggleClass = isActive ? "active" : ""; + item.toggleTitle = game.i18n.localize(isActive ? "SW5E.Equipped" : "SW5E.Unequipped"); + } + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers + /* -------------------------------------------- */ + + /** + * Activate event listeners using the prepared sheet HTML + * @param html {jQuery} The prepared HTML object ready to be rendered into the DOM + */ + activateListeners(html) { + super.activateListeners(html); + if (!this.isEditable) return; + + // Item State Toggling + html.find(".item-toggle").click(this._onToggleItem.bind(this)); + + // Short and Long Rest + html.find(".short-rest").click(this._onShortRest.bind(this)); + html.find(".long-rest").click(this._onLongRest.bind(this)); + + // Rollable sheet actions + html.find(".rollable[data-action]").click(this._onSheetAction.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Handle mouse click events for character sheet actions + * @param {MouseEvent} event The originating click event + * @private + */ + _onSheetAction(event) { + event.preventDefault(); + const button = event.currentTarget; + switch (button.dataset.action) { + case "rollDeathSave": + return this.actor.rollDeathSave({event: event}); + case "rollInitiative": + return this.actor.rollInitiative({createCombatants: true}); + } + } + + /* -------------------------------------------- */ + + /** + * Handle toggling the state of an Owned Item within the Actor + * @param {Event} event The triggering click event + * @private + */ + _onToggleItem(event) { + event.preventDefault(); + const itemId = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemId); + const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped"; + return item.update({[attr]: !getProperty(item.data, attr)}); + } + + /* -------------------------------------------- */ + + /** + * Take a short rest, calling the relevant function on the Actor instance + * @param {Event} event The triggering click event + * @private + */ + async _onShortRest(event) { + event.preventDefault(); + await this._onSubmit(event); + return this.actor.shortRest(); + } + + /* -------------------------------------------- */ + + /** + * Take a long rest, calling the relevant function on the Actor instance + * @param {Event} event The triggering click event + * @private + */ + async _onLongRest(event) { + event.preventDefault(); + await this._onSubmit(event); + return this.actor.longRest(); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropItemCreate(itemData) { + // Increment the number of class levels a character instead of creating a new item + if (itemData.type === "class") { + const cls = this.actor.itemTypes.class.find((c) => c.name === itemData.name); + let priorLevel = cls?.data.data.levels ?? 0; + if (!!cls) { + const next = Math.min(priorLevel + 1, 20 + priorLevel - this.actor.data.data.details.level); + if (next > priorLevel) { + itemData.levels = next; + return cls.update({"data.levels": next}); + } + } + } + + // Default drop handling if levels were not added + return super._onDropItemCreate(itemData); + } } diff --git a/module/actor/sheets/oldSheets/npc.js b/module/actor/sheets/oldSheets/npc.js index 12e85b1f..ce848b0b 100644 --- a/module/actor/sheets/oldSheets/npc.js +++ b/module/actor/sheets/oldSheets/npc.js @@ -6,130 +6,139 @@ import ActorSheet5e from "./base.js"; * @extends {ActorSheet5e} */ export default class ActorSheet5eNPC extends ActorSheet5e { - - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["sw5e", "sheet", "actor", "npc"], - width: 600, - height: 680 - }); - } - - /* -------------------------------------------- */ - - /** @override */ - static unsupportedItemTypes = new Set(["class"]); - - /* -------------------------------------------- */ - - /** - * Organize Owned Items for rendering the NPC sheet - * @private - */ - _prepareItems(data) { - - // Categorize Items as Features and Powers - const features = { - weapons: { label: game.i18n.localize("SW5E.AttackPl"), items: [] , hasActions: true, dataset: {type: "weapon", "weapon-type": "natural"} }, - actions: { label: game.i18n.localize("SW5E.ActionPl"), items: [] , hasActions: true, dataset: {type: "feat", "activation.type": "action"} }, - passive: { label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"} }, - equipment: { label: game.i18n.localize("SW5E.Inventory"), items: [], dataset: {type: "loot"}} - }; - - // Start by classifying items into groups for rendering - let [powers, other] = data.items.reduce((arr, item) => { - item.img = item.img || CONST.DEFAULT_TOKEN; - item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); - item.hasUses = item.data.uses && (item.data.uses.max > 0); - item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); - item.isDepleted = item.isOnCooldown && (item.data.uses.per && (item.data.uses.value > 0)); - item.hasTarget = !!item.data.target && !(["none",""].includes(item.data.target.type)); - if ( item.type === "power" ) arr[0].push(item); - else arr[1].push(item); - return arr; - }, [[], []]); - - // Apply item filters - powers = this._filterItems(powers, this._filters.powerbook); - other = this._filterItems(other, this._filters.features); - - // Organize Powerbook - const powerbook = this._preparePowerbook(data, powers); - - // Organize Features - for ( let item of other ) { - if ( item.type === "weapon" ) features.weapons.items.push(item); - else if ( item.type === "feat" ) { - if ( item.data.activation.type ) features.actions.items.push(item); - else features.passive.items.push(item); - } - else features.equipment.items.push(item); + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["sw5e", "sheet", "actor", "npc"], + width: 600, + height: 680 + }); } - // Assign and return - data.features = Object.values(features); - data.powerbook = powerbook; - } + /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @inheritdoc */ - getData(options) { - const data = super.getData(options); + /** + * Organize Owned Items for rendering the NPC sheet + * @private + */ + _prepareItems(data) { + // Categorize Items as Features and Powers + const features = { + weapons: { + label: game.i18n.localize("SW5E.AttackPl"), + items: [], + hasActions: true, + dataset: {"type": "weapon", "weapon-type": "natural"} + }, + actions: { + label: game.i18n.localize("SW5E.ActionPl"), + items: [], + hasActions: true, + dataset: {"type": "feat", "activation.type": "action"} + }, + passive: {label: game.i18n.localize("SW5E.Features"), items: [], dataset: {type: "feat"}}, + equipment: {label: game.i18n.localize("SW5E.Inventory"), items: [], dataset: {type: "loot"}} + }; - // Challenge Rating - const cr = parseFloat(data.data.details.cr || 0); - const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; - data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; + // Start by classifying items into groups for rendering + let [powers, other] = data.items.reduce( + (arr, item) => { + item.img = item.img || CONST.DEFAULT_TOKEN; + item.isStack = Number.isNumeric(item.data.quantity) && item.data.quantity !== 1; + item.hasUses = item.data.uses && item.data.uses.max > 0; + item.isOnCooldown = + item.data.recharge && !!item.data.recharge.value && item.data.recharge.charged === false; + item.isDepleted = item.isOnCooldown && item.data.uses.per && item.data.uses.value > 0; + item.hasTarget = !!item.data.target && !["none", ""].includes(item.data.target.type); + if (item.type === "power") arr[0].push(item); + else arr[1].push(item); + return arr; + }, + [[], []] + ); - // Creature Type - data.labels["type"] = this.actor.labels.creatureType; - return data; - } + // Apply item filters + powers = this._filterItems(powers, this._filters.powerbook); + other = this._filterItems(other, this._filters.features); - /* -------------------------------------------- */ - /* Object Updates */ - /* -------------------------------------------- */ + // Organize Powerbook + const powerbook = this._preparePowerbook(data, powers); - /** @override */ - async _updateObject(event, formData) { + // Organize Features + for (let item of other) { + if (item.type === "weapon") features.weapons.items.push(item); + else if (item.type === "feat") { + if (item.data.activation.type) features.actions.items.push(item); + else features.passive.items.push(item); + } else features.equipment.items.push(item); + } - // Format NPC Challenge Rating - const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; - let crv = "data.details.cr"; - let cr = formData[crv]; - cr = crs[cr] || parseFloat(cr); - if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); + // Assign and return + data.features = Object.values(features); + data.powerbook = powerbook; + } - // Parent ActorSheet update steps - return super._updateObject(event, formData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ + /** @inheritdoc */ + getData(options) { + const data = super.getData(options); - /** @override */ - activateListeners(html) { - super.activateListeners(html); - html.find(".health .rollable").click(this._onRollHPFormula.bind(this)); - } + // Challenge Rating + const cr = parseFloat(data.data.details.cr || 0); + const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; + data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; - /* -------------------------------------------- */ + // Creature Type + data.labels["type"] = this.actor.labels.creatureType; + return data; + } - /** - * Handle rolling NPC health values using the provided formula - * @param {Event} event The original click event - * @private - */ - _onRollHPFormula(event) { - event.preventDefault(); - const formula = this.actor.data.data.attributes.hp.formula; - if ( !formula ) return; - const hp = new Roll(formula).roll().total; - AudioHelper.play({src: CONFIG.sounds.dice}); - this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); - } + /* -------------------------------------------- */ + /* Object Updates */ + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(event, formData) { + // Format NPC Challenge Rating + const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; + let crv = "data.details.cr"; + let cr = formData[crv]; + cr = crs[cr] || parseFloat(cr); + if (cr) formData[crv] = cr < 1 ? cr : parseInt(cr); + + // Parent ActorSheet update steps + return super._updateObject(event, formData); + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + html.find(".health .rollable").click(this._onRollHPFormula.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling NPC health values using the provided formula + * @param {Event} event The original click event + * @private + */ + _onRollHPFormula(event) { + event.preventDefault(); + const formula = this.actor.data.data.attributes.hp.formula; + if (!formula) return; + const hp = new Roll(formula).roll().total; + AudioHelper.play({src: CONFIG.sounds.dice}); + this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); + } } diff --git a/module/actor/sheets/oldSheets/vehicle.js b/module/actor/sheets/oldSheets/vehicle.js index b6ec2fc8..a5c6e2ea 100644 --- a/module/actor/sheets/oldSheets/vehicle.js +++ b/module/actor/sheets/oldSheets/vehicle.js @@ -6,411 +6,427 @@ import ActorSheet5e from "./base.js"; * @type {ActorSheet5e} */ export default class ActorSheet5eVehicle extends ActorSheet5e { - /** - * Define default rendering options for the Vehicle sheet. - * @returns {Object} - */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["sw5e", "sheet", "actor", "vehicle"], - width: 605, - height: 680 - }); - } - - /* -------------------------------------------- */ - - /** @override */ - static unsupportedItemTypes = new Set(["class"]); - - /* -------------------------------------------- */ - - - /** - * Creates a new cargo entry for a vehicle Actor. - */ - static get newCargo() { - return { - name: '', - quantity: 1 - }; - } - - /* -------------------------------------------- */ - - /** - * Compute the total weight of the vehicle's cargo. - * @param {Number} totalWeight The cumulative item weight from inventory items - * @param {Object} actorData The data object for the Actor being rendered - * @returns {{max: number, value: number, pct: number}} - * @private - */ - _computeEncumbrance(totalWeight, actorData) { - - // Compute currency weight - const totalCoins = Object.values(actorData.data.currency).reduce((acc, denom) => acc + denom, 0); - totalWeight += totalCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; - - // Vehicle weights are an order of magnitude greater. - totalWeight /= CONFIG.SW5E.encumbrance.vehicleWeightMultiplier; - - // Compute overall encumbrance - const max = actorData.data.attributes.capacity.cargo; - const pct = Math.clamped((totalWeight * 100) / max, 0, 100); - return {value: totalWeight.toNearest(0.1), max, pct}; - } - - /* -------------------------------------------- */ - - /** @override */ - _getMovementSpeed(actorData, largestPrimary=true) { - return super._getMovementSpeed(actorData, largestPrimary); - } - - /* -------------------------------------------- */ - - /** - * Prepare items that are mounted to a vehicle and require one or more crew - * to operate. - * @private - */ - _prepareCrewedItem(item) { - - // Determine crewed status - const isCrewed = item.data.crewed; - item.toggleClass = isCrewed ? 'active' : ''; - item.toggleTitle = game.i18n.localize(`SW5E.${isCrewed ? 'Crewed' : 'Uncrewed'}`); - - // Handle crew actions - if (item.type === 'feat' && item.data.activation.type === 'crew') { - item.crew = item.data.activation.cost; - item.cover = game.i18n.localize(`SW5E.${item.data.cover ? 'CoverTotal' : 'None'}`); - if (item.data.cover === .5) item.cover = '½'; - else if (item.data.cover === .75) item.cover = '¾'; - else if (item.data.cover === null) item.cover = '—'; - if (item.crew < 1 || item.crew === null) item.crew = '—'; + /** + * Define default rendering options for the Vehicle sheet. + * @returns {Object} + */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["sw5e", "sheet", "actor", "vehicle"], + width: 605, + height: 680 + }); } - // Prepare vehicle weapons - if (item.type === 'equipment' || item.type === 'weapon') { - item.threshold = item.data.hp.dt ? item.data.hp.dt : '—'; - } - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); - /** - * Organize Owned Items for rendering the Vehicle sheet. - * @private - */ - _prepareItems(data) { - const cargoColumns = [{ - label: game.i18n.localize('SW5E.Quantity'), - css: 'item-qty', - property: 'quantity', - editable: 'Number' - }]; + /* -------------------------------------------- */ - const equipmentColumns = [{ - label: game.i18n.localize('SW5E.Quantity'), - css: 'item-qty', - property: 'data.quantity' - }, { - label: game.i18n.localize('SW5E.AC'), - css: 'item-ac', - property: 'data.armor.value' - }, { - label: game.i18n.localize('SW5E.HP'), - css: 'item-hp', - property: 'data.hp.value', - editable: 'Number' - }, { - label: game.i18n.localize('SW5E.Threshold'), - css: 'item-threshold', - property: 'threshold' - }]; - - const features = { - actions: { - label: game.i18n.localize('SW5E.ActionPl'), - items: [], - crewable: true, - dataset: {type: 'feat', 'activation.type': 'crew'}, - columns: [{ - label: game.i18n.localize('SW5E.VehicleCrew'), - css: 'item-crew', - property: 'crew' - }, { - label: game.i18n.localize('SW5E.Cover'), - css: 'item-cover', - property: 'cover' - }] - }, - equipment: { - label: game.i18n.localize('SW5E.ItemTypeEquipment'), - items: [], - crewable: true, - dataset: {type: 'equipment', 'armor.type': 'vehicle'}, - columns: equipmentColumns - }, - passive: { - label: game.i18n.localize('SW5E.Features'), - items: [], - dataset: {type: 'feat'} - }, - reactions: { - label: game.i18n.localize('SW5E.ReactionPl'), - items: [], - dataset: {type: 'feat', 'activation.type': 'reaction'} - }, - weapons: { - label: game.i18n.localize('SW5E.ItemTypeWeaponPl'), - items: [], - crewable: true, - dataset: {type: 'weapon', 'weapon-type': 'siege'}, - columns: equipmentColumns - } - }; - - const cargo = { - crew: { - label: game.i18n.localize('SW5E.VehicleCrew'), - items: data.data.cargo.crew, - css: 'cargo-row crew', - editableName: true, - dataset: {type: 'crew'}, - columns: cargoColumns - }, - passengers: { - label: game.i18n.localize('SW5E.VehiclePassengers'), - items: data.data.cargo.passengers, - css: 'cargo-row passengers', - editableName: true, - dataset: {type: 'passengers'}, - columns: cargoColumns - }, - cargo: { - label: game.i18n.localize('SW5E.VehicleCargo'), - items: [], - dataset: {type: 'loot'}, - columns: [{ - label: game.i18n.localize('SW5E.Quantity'), - css: 'item-qty', - property: 'data.quantity', - editable: 'Number' - }, { - label: game.i18n.localize('SW5E.Price'), - css: 'item-price', - property: 'data.price', - editable: 'Number' - }, { - label: game.i18n.localize('SW5E.Weight'), - css: 'item-weight', - property: 'data.weight', - editable: 'Number' - }] - } - }; - - // Classify items owned by the vehicle and compute total cargo weight - let totalWeight = 0; - for (const item of data.items) { - this._prepareCrewedItem(item); - - // Handle cargo explicitly - const isCargo = item.flags.sw5e?.vehicleCargo === true; - if ( isCargo ) { - totalWeight += (item.data.weight || 0) * item.data.quantity; - cargo.cargo.items.push(item); - continue; - } - - // Handle non-cargo item types - switch ( item.type ) { - case "weapon": - features.weapons.items.push(item); - break; - case "equipment": - features.equipment.items.push(item); - break; - case "feat": - if (!item.data.activation.type || (item.data.activation.type === "none")) features.passive.items.push(item); - else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); - else features.actions.items.push(item); - break; - default: - totalWeight += (item.data.weight || 0) * item.data.quantity; - cargo.cargo.items.push(item); - } + /** + * Creates a new cargo entry for a vehicle Actor. + */ + static get newCargo() { + return { + name: "", + quantity: 1 + }; } - // Update the rendering context data - data.features = Object.values(features); - data.cargo = Object.values(cargo); - data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - /* Event Listeners and Handlers */ - /* -------------------------------------------- */ + /** + * Compute the total weight of the vehicle's cargo. + * @param {Number} totalWeight The cumulative item weight from inventory items + * @param {Object} actorData The data object for the Actor being rendered + * @returns {{max: number, value: number, pct: number}} + * @private + */ + _computeEncumbrance(totalWeight, actorData) { + // Compute currency weight + const totalCoins = Object.values(actorData.data.currency).reduce((acc, denom) => acc + denom, 0); + totalWeight += totalCoins / CONFIG.SW5E.encumbrance.currencyPerWeight; - /** @override */ - activateListeners(html) { - super.activateListeners(html); - if (!this.isEditable) return; + // Vehicle weights are an order of magnitude greater. + totalWeight /= CONFIG.SW5E.encumbrance.vehicleWeightMultiplier; - html.find('.item-toggle').click(this._onToggleItem.bind(this)); - html.find('.item-hp input') - .click(evt => evt.target.select()) - .change(this._onHPChange.bind(this)); - - html.find('.item:not(.cargo-row) input[data-property]') - .click(evt => evt.target.select()) - .change(this._onEditInSheet.bind(this)); - - html.find('.cargo-row input') - .click(evt => evt.target.select()) - .change(this._onCargoRowChange.bind(this)); - - if (this.actor.data.data.attributes.actions.stations) { - html.find('.counter.actions, .counter.action-thresholds').hide(); - } - } - - /* -------------------------------------------- */ - - /** - * Handle saving a cargo row (i.e. crew or passenger) in-sheet. - * @param event {Event} - * @returns {Promise|null} - * @private - */ - _onCargoRowChange(event) { - event.preventDefault(); - const target = event.currentTarget; - const row = target.closest('.item'); - const idx = Number(row.dataset.itemId); - const property = row.classList.contains('crew') ? 'crew' : 'passengers'; - - // Get the cargo entry - const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); - const entry = cargo[idx]; - if (!entry) return null; - - // Update the cargo value - const key = target.dataset.property || 'name'; - const type = target.dataset.dtype; - let value = target.value; - if (type === 'Number') value = Number(value); - entry[key] = value; - - // Perform the Actor update - return this.actor.update({[`data.cargo.${property}`]: cargo}); - } - - /* -------------------------------------------- */ - - /** - * Handle editing certain values like quantity, price, and weight in-sheet. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onEditInSheet(event) { - event.preventDefault(); - const itemID = event.currentTarget.closest('.item').dataset.itemId; - const item = this.actor.items.get(itemID); - const property = event.currentTarget.dataset.property; - const type = event.currentTarget.dataset.dtype; - let value = event.currentTarget.value; - switch (type) { - case 'Number': value = parseInt(value); break; - case 'Boolean': value = value === 'true'; break; - } - return item.update({[`${property}`]: value}); - } - - /* -------------------------------------------- */ - - /** - * Handle creating a new crew or passenger row. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onItemCreate(event) { - event.preventDefault(); - const target = event.currentTarget; - const type = target.dataset.type; - if (type === 'crew' || type === 'passengers') { - const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); - cargo.push(this.constructor.newCargo); - return this.actor.update({[`data.cargo.${type}`]: cargo}); - } - return super._onItemCreate(event); - } - - /* -------------------------------------------- */ - - /** - * Handle deleting a crew or passenger row. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onItemDelete(event) { - event.preventDefault(); - const row = event.currentTarget.closest('.item'); - if (row.classList.contains('cargo-row')) { - const idx = Number(row.dataset.itemId); - const type = row.classList.contains('crew') ? 'crew' : 'passengers'; - const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); - return this.actor.update({[`data.cargo.${type}`]: cargo}); + // Compute overall encumbrance + const max = actorData.data.attributes.capacity.cargo; + const pct = Math.clamped((totalWeight * 100) / max, 0, 100); + return {value: totalWeight.toNearest(0.1), max, pct}; } - return super._onItemDelete(event); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** @override */ + _getMovementSpeed(actorData, largestPrimary = true) { + return super._getMovementSpeed(actorData, largestPrimary); + } - /** @override */ - async _onDropItemCreate(itemData) { - const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"]; - const isCargo = cargoTypes.includes(itemData.type) && (this._tabs[0].active === "cargo"); - foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo); - return super._onDropItemCreate(itemData); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Prepare items that are mounted to a vehicle and require one or more crew + * to operate. + * @private + */ + _prepareCrewedItem(item) { + // Determine crewed status + const isCrewed = item.data.crewed; + item.toggleClass = isCrewed ? "active" : ""; + item.toggleTitle = game.i18n.localize(`SW5E.${isCrewed ? "Crewed" : "Uncrewed"}`); - /** - * Special handling for editing HP to clamp it within appropriate range. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onHPChange(event) { - event.preventDefault(); - const itemID = event.currentTarget.closest('.item').dataset.itemId; - const item = this.actor.items.get(itemID); - const hp = Math.clamped(0, parseInt(event.currentTarget.value), item.data.data.hp.max); - event.currentTarget.value = hp; - return item.update({'data.hp.value': hp}); - } + // Handle crew actions + if (item.type === "feat" && item.data.activation.type === "crew") { + item.crew = item.data.activation.cost; + item.cover = game.i18n.localize(`SW5E.${item.data.cover ? "CoverTotal" : "None"}`); + if (item.data.cover === 0.5) item.cover = "½"; + else if (item.data.cover === 0.75) item.cover = "¾"; + else if (item.data.cover === null) item.cover = "—"; + if (item.crew < 1 || item.crew === null) item.crew = "—"; + } - /* -------------------------------------------- */ + // Prepare vehicle weapons + if (item.type === "equipment" || item.type === "weapon") { + item.threshold = item.data.hp.dt ? item.data.hp.dt : "—"; + } + } - /** - * Handle toggling an item's crewed status. - * @param event {Event} - * @returns {Promise} - * @private - */ - _onToggleItem(event) { - event.preventDefault(); - const itemID = event.currentTarget.closest('.item').dataset.itemId; - const item = this.actor.items.get(itemID); - const crewed = !!item.data.data.crewed; - return item.update({'data.crewed': !crewed}); - } -}; + /* -------------------------------------------- */ + + /** + * Organize Owned Items for rendering the Vehicle sheet. + * @private + */ + _prepareItems(data) { + const cargoColumns = [ + { + label: game.i18n.localize("SW5E.Quantity"), + css: "item-qty", + property: "quantity", + editable: "Number" + } + ]; + + const equipmentColumns = [ + { + label: game.i18n.localize("SW5E.Quantity"), + css: "item-qty", + property: "data.quantity" + }, + { + label: game.i18n.localize("SW5E.AC"), + css: "item-ac", + property: "data.armor.value" + }, + { + label: game.i18n.localize("SW5E.HP"), + css: "item-hp", + property: "data.hp.value", + editable: "Number" + }, + { + label: game.i18n.localize("SW5E.Threshold"), + css: "item-threshold", + property: "threshold" + } + ]; + + const features = { + actions: { + label: game.i18n.localize("SW5E.ActionPl"), + items: [], + crewable: true, + dataset: {"type": "feat", "activation.type": "crew"}, + columns: [ + { + label: game.i18n.localize("SW5E.VehicleCrew"), + css: "item-crew", + property: "crew" + }, + { + label: game.i18n.localize("SW5E.Cover"), + css: "item-cover", + property: "cover" + } + ] + }, + equipment: { + label: game.i18n.localize("SW5E.ItemTypeEquipment"), + items: [], + crewable: true, + dataset: {"type": "equipment", "armor.type": "vehicle"}, + columns: equipmentColumns + }, + passive: { + label: game.i18n.localize("SW5E.Features"), + items: [], + dataset: {type: "feat"} + }, + reactions: { + label: game.i18n.localize("SW5E.ReactionPl"), + items: [], + dataset: {"type": "feat", "activation.type": "reaction"} + }, + weapons: { + label: game.i18n.localize("SW5E.ItemTypeWeaponPl"), + items: [], + crewable: true, + dataset: {"type": "weapon", "weapon-type": "siege"}, + columns: equipmentColumns + } + }; + + const cargo = { + crew: { + label: game.i18n.localize("SW5E.VehicleCrew"), + items: data.data.cargo.crew, + css: "cargo-row crew", + editableName: true, + dataset: {type: "crew"}, + columns: cargoColumns + }, + passengers: { + label: game.i18n.localize("SW5E.VehiclePassengers"), + items: data.data.cargo.passengers, + css: "cargo-row passengers", + editableName: true, + dataset: {type: "passengers"}, + columns: cargoColumns + }, + cargo: { + label: game.i18n.localize("SW5E.VehicleCargo"), + items: [], + dataset: {type: "loot"}, + columns: [ + { + label: game.i18n.localize("SW5E.Quantity"), + css: "item-qty", + property: "data.quantity", + editable: "Number" + }, + { + label: game.i18n.localize("SW5E.Price"), + css: "item-price", + property: "data.price", + editable: "Number" + }, + { + label: game.i18n.localize("SW5E.Weight"), + css: "item-weight", + property: "data.weight", + editable: "Number" + } + ] + } + }; + + // Classify items owned by the vehicle and compute total cargo weight + let totalWeight = 0; + for (const item of data.items) { + this._prepareCrewedItem(item); + + // Handle cargo explicitly + const isCargo = item.flags.sw5e?.vehicleCargo === true; + if (isCargo) { + totalWeight += (item.data.weight || 0) * item.data.quantity; + cargo.cargo.items.push(item); + continue; + } + + // Handle non-cargo item types + switch (item.type) { + case "weapon": + features.weapons.items.push(item); + break; + case "equipment": + features.equipment.items.push(item); + break; + case "feat": + if (!item.data.activation.type || item.data.activation.type === "none") + features.passive.items.push(item); + else if (item.data.activation.type === "reaction") features.reactions.items.push(item); + else features.actions.items.push(item); + break; + default: + totalWeight += (item.data.weight || 0) * item.data.quantity; + cargo.cargo.items.push(item); + } + } + + // Update the rendering context data + data.features = Object.values(features); + data.cargo = Object.values(cargo); + data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data); + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + if (!this.isEditable) return; + + html.find(".item-toggle").click(this._onToggleItem.bind(this)); + html.find(".item-hp input") + .click((evt) => evt.target.select()) + .change(this._onHPChange.bind(this)); + + html.find(".item:not(.cargo-row) input[data-property]") + .click((evt) => evt.target.select()) + .change(this._onEditInSheet.bind(this)); + + html.find(".cargo-row input") + .click((evt) => evt.target.select()) + .change(this._onCargoRowChange.bind(this)); + + if (this.actor.data.data.attributes.actions.stations) { + html.find(".counter.actions, .counter.action-thresholds").hide(); + } + } + + /* -------------------------------------------- */ + + /** + * Handle saving a cargo row (i.e. crew or passenger) in-sheet. + * @param event {Event} + * @returns {Promise|null} + * @private + */ + _onCargoRowChange(event) { + event.preventDefault(); + const target = event.currentTarget; + const row = target.closest(".item"); + const idx = Number(row.dataset.itemId); + const property = row.classList.contains("crew") ? "crew" : "passengers"; + + // Get the cargo entry + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); + const entry = cargo[idx]; + if (!entry) return null; + + // Update the cargo value + const key = target.dataset.property || "name"; + const type = target.dataset.dtype; + let value = target.value; + if (type === "Number") value = Number(value); + entry[key] = value; + + // Perform the Actor update + return this.actor.update({[`data.cargo.${property}`]: cargo}); + } + + /* -------------------------------------------- */ + + /** + * Handle editing certain values like quantity, price, and weight in-sheet. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onEditInSheet(event) { + event.preventDefault(); + const itemID = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemID); + const property = event.currentTarget.dataset.property; + const type = event.currentTarget.dataset.dtype; + let value = event.currentTarget.value; + switch (type) { + case "Number": + value = parseInt(value); + break; + case "Boolean": + value = value === "true"; + break; + } + return item.update({[`${property}`]: value}); + } + + /* -------------------------------------------- */ + + /** + * Handle creating a new crew or passenger row. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onItemCreate(event) { + event.preventDefault(); + const target = event.currentTarget; + const type = target.dataset.type; + if (type === "crew" || type === "passengers") { + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); + cargo.push(this.constructor.newCargo); + return this.actor.update({[`data.cargo.${type}`]: cargo}); + } + return super._onItemCreate(event); + } + + /* -------------------------------------------- */ + + /** + * Handle deleting a crew or passenger row. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onItemDelete(event) { + event.preventDefault(); + const row = event.currentTarget.closest(".item"); + if (row.classList.contains("cargo-row")) { + const idx = Number(row.dataset.itemId); + const type = row.classList.contains("crew") ? "crew" : "passengers"; + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); + return this.actor.update({[`data.cargo.${type}`]: cargo}); + } + + return super._onItemDelete(event); + } + + /* -------------------------------------------- */ + + /** @override */ + async _onDropItemCreate(itemData) { + const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"]; + const isCargo = cargoTypes.includes(itemData.type) && this._tabs[0].active === "cargo"; + foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo); + return super._onDropItemCreate(itemData); + } + + /* -------------------------------------------- */ + + /** + * Special handling for editing HP to clamp it within appropriate range. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onHPChange(event) { + event.preventDefault(); + const itemID = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemID); + const hp = Math.clamped(0, parseInt(event.currentTarget.value), item.data.data.hp.max); + event.currentTarget.value = hp; + return item.update({"data.hp.value": hp}); + } + + /* -------------------------------------------- */ + + /** + * Handle toggling an item's crewed status. + * @param event {Event} + * @returns {Promise} + * @private + */ + _onToggleItem(event) { + event.preventDefault(); + const itemID = event.currentTarget.closest(".item").dataset.itemId; + const item = this.actor.items.get(itemID); + const crewed = !!item.data.data.crewed; + return item.update({"data.crewed": !crewed}); + } +} diff --git a/module/apps/ability-use-dialog.js b/module/apps/ability-use-dialog.js index 4b92b14d..6c43775d 100644 --- a/module/apps/ability-use-dialog.js +++ b/module/apps/ability-use-dialog.js @@ -3,220 +3,225 @@ * @type {Dialog} */ export default class AbilityUseDialog extends Dialog { - constructor(item, dialogData={}, options={}) { - super(dialogData, options); - this.options.classes = ["sw5e", "dialog"]; + constructor(item, dialogData = {}, options = {}) { + super(dialogData, options); + this.options.classes = ["sw5e", "dialog"]; + + /** + * Store a reference to the Item entity being used + * @type {Item5e} + */ + this.item = item; + } + + /* -------------------------------------------- */ + /* Rendering */ + /* -------------------------------------------- */ /** - * Store a reference to the Item entity being used - * @type {Item5e} + * A constructor function which displays the Power Cast Dialog app for a given Actor and Item. + * Returns a Promise which resolves to the dialog FormData once the workflow has been completed. + * @param {Item5e} item + * @return {Promise} */ - this.item = item; - } + static async create(item) { + if (!item.isOwned) throw new Error("You cannot display an ability usage dialog for an unowned item"); - /* -------------------------------------------- */ - /* Rendering */ - /* -------------------------------------------- */ + // Prepare data + const actorData = item.actor.data.data; + const itemData = item.data.data; + const uses = itemData.uses || {}; + const quantity = itemData.quantity || 0; + const recharge = itemData.recharge || {}; + const recharges = !!recharge.value; + const sufficientUses = (quantity > 0 && !uses.value) || uses.value > 0; - /** - * A constructor function which displays the Power Cast Dialog app for a given Actor and Item. - * Returns a Promise which resolves to the dialog FormData once the workflow has been completed. - * @param {Item5e} item - * @return {Promise} - */ - static async create(item) { - if ( !item.isOwned ) throw new Error("You cannot display an ability usage dialog for an unowned item"); + // Prepare dialog form data + const data = { + item: item.data, + title: game.i18n.format("SW5E.AbilityUseHint", { + type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`), + name: item.name + }), + note: this._getAbilityUseNote(item.data, uses, recharge), + consumePowerSlot: false, + consumeRecharge: recharges, + consumeResource: !!itemData.consume.target, + consumeUses: uses.per && uses.max > 0, + canUse: recharges ? recharge.charged : sufficientUses, + createTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget, + errors: [] + }; + if (item.data.type === "power") this._getPowerData(actorData, itemData, data); - // Prepare data - const actorData = item.actor.data.data; - const itemData = item.data.data; - const uses = itemData.uses || {}; - const quantity = itemData.quantity || 0; - const recharge = itemData.recharge || {}; - const recharges = !!recharge.value; - const sufficientUses = (quantity > 0 && !uses.value) || uses.value > 0; + // Render the ability usage template + const html = await renderTemplate("systems/sw5e/templates/apps/ability-use.html", data); - // Prepare dialog form data - const data = { - item: item.data, - title: game.i18n.format("SW5E.AbilityUseHint", {type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`), name: item.name}), - note: this._getAbilityUseNote(item.data, uses, recharge), - consumePowerSlot: false, - consumeRecharge: recharges, - consumeResource: !!itemData.consume.target, - consumeUses: uses.per && (uses.max > 0), - canUse: recharges ? recharge.charged : sufficientUses, - createTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget, - errors: [] - }; - if ( item.data.type === "power" ) this._getPowerData(actorData, itemData, data); + // Create the Dialog and return data as a Promise + const icon = data.isPower ? "fa-magic" : "fa-fist-raised"; + const label = game.i18n.localize("SW5E.AbilityUse" + (data.isPower ? "Cast" : "Use")); + return new Promise((resolve) => { + const dlg = new this(item, { + title: `${item.name}: ${game.i18n.localize("SW5E.AbilityUseConfig")}`, + content: html, + buttons: { + use: { + icon: ``, + label: label, + callback: (html) => { + const fd = new FormDataExtended(html[0].querySelector("form")); + resolve(fd.toObject()); + } + } + }, + default: "use", + close: () => resolve(null) + }); + dlg.render(true); + }); + } - // Render the ability usage template - const html = await renderTemplate("systems/sw5e/templates/apps/ability-use.html", data); + /* -------------------------------------------- */ + /* Helpers */ + /* -------------------------------------------- */ - // Create the Dialog and return data as a Promise - const icon = data.isPower ? "fa-magic" : "fa-fist-raised"; - const label = game.i18n.localize("SW5E.AbilityUse" + (data.isPower ? "Cast" : "Use")); - return new Promise((resolve) => { - const dlg = new this(item, { - title: `${item.name}: ${game.i18n.localize("SW5E.AbilityUseConfig")}`, - content: html, - buttons: { - use: { - icon: ``, - label: label, - callback: html => { - const fd = new FormDataExtended(html[0].querySelector("form")); - resolve(fd.toObject()); + /** + * Get dialog data related to limited power slots + * @private + */ + static _getPowerData(actorData, itemData, data) { + // Determine whether the power may be up-cast + const lvl = itemData.level; + const consumePowerSlot = lvl > 0 && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode); + + // If can't upcast, return early and don't bother calculating available power slots + if (!consumePowerSlot) { + mergeObject(data, {isPower: true, consumePowerSlot}); + return; + } + + // Determine the levels which are feasible + let lmax = 0; + let points; + let powerType; + switch (itemData.school) { + case "lgt": + case "uni": + case "drk": { + powerType = "force"; + points = actorData.attributes.force.points.value + actorData.attributes.force.points.temp; + break; + } + case "tec": { + powerType = "tech"; + points = actorData.attributes.tech.points.value + actorData.attributes.tech.points.temp; + break; } - } - }, - default: "use", - close: () => resolve(null) - }); - dlg.render(true); - }); - } - - /* -------------------------------------------- */ - /* Helpers */ - /* -------------------------------------------- */ - - /** - * Get dialog data related to limited power slots - * @private - */ - static _getPowerData(actorData, itemData, data) { - - // Determine whether the power may be up-cast - const lvl = itemData.level; - const consumePowerSlot = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode); - - // If can't upcast, return early and don't bother calculating available power slots - if (!consumePowerSlot) { - mergeObject(data, { isPower: true, consumePowerSlot }); - return; - } - - // Determine the levels which are feasible - let lmax = 0; - let points; - let powerType; - switch (itemData.school){ - case "lgt": - case "uni": - case "drk": { - powerType = "force" - points = actorData.attributes.force.points.value + actorData.attributes.force.points.temp; - break; - } - case "tec": { - powerType = "tech" - points = actorData.attributes.tech.points.value + actorData.attributes.tech.points.temp; - break; - } - } - - // eliminate point usage for innate casters - if (actorData.attributes.powercasting === 'innate') points = 999; - - - let powerLevels - if (powerType === "force"){ - powerLevels = Array.fromRange(10).reduce((arr, i) => { - if ( i < lvl ) return arr; - const label = CONFIG.SW5E.powerLevels[i]; - const l = actorData.powers["power"+i] || {fmax: 0, foverride: null}; - let max = parseInt(l.foverride || l.fmax || 0); - let slots = Math.clamped(parseInt(l.fvalue || 0), 0, max); - if ( max > 0 ) lmax = i; - if ((max > 0) && (slots > 0) && (points > i)){ - arr.push({ - level: i, - label: i > 0 ? game.i18n.format('SW5E.PowerLevelSlot', {level: label, n: slots}) : label, - canCast: max > 0, - hasSlots: slots > 0 - }); } - return arr; - }, []).filter(sl => sl.level <= lmax); - }else if (powerType === "tech"){ - powerLevels = Array.fromRange(10).reduce((arr, i) => { - if ( i < lvl ) return arr; - const label = CONFIG.SW5E.powerLevels[i]; - const l = actorData.powers["power"+i] || {tmax: 0, toverride: null}; - let max = parseInt(l.override || l.tmax || 0); - let slots = Math.clamped(parseInt(l.tvalue || 0), 0, max); - if ( max > 0 ) lmax = i; - if ((max > 0) && (slots > 0) && (points > i)){ - arr.push({ - level: i, - label: i > 0 ? game.i18n.format('SW5E.PowerLevelSlot', {level: label, n: slots}) : label, - canCast: max > 0, - hasSlots: slots > 0 - }); + + // eliminate point usage for innate casters + if (actorData.attributes.powercasting === "innate") points = 999; + + let powerLevels; + if (powerType === "force") { + powerLevels = Array.fromRange(10) + .reduce((arr, i) => { + if (i < lvl) return arr; + const label = CONFIG.SW5E.powerLevels[i]; + const l = actorData.powers["power" + i] || {fmax: 0, foverride: null}; + let max = parseInt(l.foverride || l.fmax || 0); + let slots = Math.clamped(parseInt(l.fvalue || 0), 0, max); + if (max > 0) lmax = i; + if (max > 0 && slots > 0 && points > i) { + arr.push({ + level: i, + label: i > 0 ? game.i18n.format("SW5E.PowerLevelSlot", {level: label, n: slots}) : label, + canCast: max > 0, + hasSlots: slots > 0 + }); + } + return arr; + }, []) + .filter((sl) => sl.level <= lmax); + } else if (powerType === "tech") { + powerLevels = Array.fromRange(10) + .reduce((arr, i) => { + if (i < lvl) return arr; + const label = CONFIG.SW5E.powerLevels[i]; + const l = actorData.powers["power" + i] || {tmax: 0, toverride: null}; + let max = parseInt(l.override || l.tmax || 0); + let slots = Math.clamped(parseInt(l.tvalue || 0), 0, max); + if (max > 0) lmax = i; + if (max > 0 && slots > 0 && points > i) { + arr.push({ + level: i, + label: i > 0 ? game.i18n.format("SW5E.PowerLevelSlot", {level: label, n: slots}) : label, + canCast: max > 0, + hasSlots: slots > 0 + }); + } + return arr; + }, []) + .filter((sl) => sl.level <= lmax); } - return arr; - }, []).filter(sl => sl.level <= lmax); - } - - - const canCast = powerLevels.some(l => l.hasSlots); - if ( !canCast ) data.errors.push(game.i18n.format("SW5E.PowerCastNoSlots", { - level: CONFIG.SW5E.powerLevels[lvl], - name: data.item.name - })); + const canCast = powerLevels.some((l) => l.hasSlots); + if (!canCast) + data.errors.push( + game.i18n.format("SW5E.PowerCastNoSlots", { + level: CONFIG.SW5E.powerLevels[lvl], + name: data.item.name + }) + ); - // Merge power casting data - return foundry.utils.mergeObject(data, { isPower: true, consumePowerSlot, powerLevels }); - } - - /* -------------------------------------------- */ - - /** - * Get the ability usage note that is displayed - * @private - */ - static _getAbilityUseNote(item, uses, recharge) { - - // Zero quantity - const quantity = item.data.quantity; - if ( quantity <= 0 ) return game.i18n.localize("SW5E.AbilityUseUnavailableHint"); - - // Abilities which use Recharge - if ( !!recharge.value ) { - return game.i18n.format(recharge.charged ? "SW5E.AbilityUseChargedHint" : "SW5E.AbilityUseRechargeHint", { - type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`), - }) + // Merge power casting data + return foundry.utils.mergeObject(data, {isPower: true, consumePowerSlot, powerLevels}); } - // Does not use any resource - if ( !uses.per || !uses.max ) return ""; + /* -------------------------------------------- */ - // Consumables - if ( item.type === "consumable" ) { - let str = "SW5E.AbilityUseNormalHint"; - if ( uses.value > 1 ) str = "SW5E.AbilityUseConsumableChargeHint"; - else if ( item.data.quantity === 1 && uses.autoDestroy ) str = "SW5E.AbilityUseConsumableDestroyHint"; - else if ( item.data.quantity > 1 ) str = "SW5E.AbilityUseConsumableQuantityHint"; - return game.i18n.format(str, { - type: game.i18n.localize(`SW5E.Consumable${item.data.consumableType.capitalize()}`), - value: uses.value, - quantity: item.data.quantity, - max: uses.max, - per: CONFIG.SW5E.limitedUsePeriods[uses.per] - }); - } + /** + * Get the ability usage note that is displayed + * @private + */ + static _getAbilityUseNote(item, uses, recharge) { + // Zero quantity + const quantity = item.data.quantity; + if (quantity <= 0) return game.i18n.localize("SW5E.AbilityUseUnavailableHint"); - // Other Items - else { - return game.i18n.format("SW5E.AbilityUseNormalHint", { - type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`), - value: uses.value, - max: uses.max, - per: CONFIG.SW5E.limitedUsePeriods[uses.per] - }); + // Abilities which use Recharge + if (!!recharge.value) { + return game.i18n.format(recharge.charged ? "SW5E.AbilityUseChargedHint" : "SW5E.AbilityUseRechargeHint", { + type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`) + }); + } + + // Does not use any resource + if (!uses.per || !uses.max) return ""; + + // Consumables + if (item.type === "consumable") { + let str = "SW5E.AbilityUseNormalHint"; + if (uses.value > 1) str = "SW5E.AbilityUseConsumableChargeHint"; + else if (item.data.quantity === 1 && uses.autoDestroy) str = "SW5E.AbilityUseConsumableDestroyHint"; + else if (item.data.quantity > 1) str = "SW5E.AbilityUseConsumableQuantityHint"; + return game.i18n.format(str, { + type: game.i18n.localize(`SW5E.Consumable${item.data.consumableType.capitalize()}`), + value: uses.value, + quantity: item.data.quantity, + max: uses.max, + per: CONFIG.SW5E.limitedUsePeriods[uses.per] + }); + } + + // Other Items + else { + return game.i18n.format("SW5E.AbilityUseNormalHint", { + type: game.i18n.localize(`SW5E.ItemType${item.type.capitalize()}`), + value: uses.value, + max: uses.max, + per: CONFIG.SW5E.limitedUsePeriods[uses.per] + }); + } } - } } diff --git a/module/apps/actor-flags.js b/module/apps/actor-flags.js index cb3a816c..8f5255a5 100644 --- a/module/apps/actor-flags.js +++ b/module/apps/actor-flags.js @@ -3,135 +3,137 @@ * @implements {DocumentSheet} */ export default class ActorSheetFlags extends DocumentSheet { - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - id: "actor-flags", - classes: ["sw5e"], - template: "systems/sw5e/templates/apps/actor-flags.html", - width: 500, - closeOnSubmit: true - }); - } - - /* -------------------------------------------- */ - - /** @override */ - get title() { - return `${game.i18n.localize('SW5E.FlagsTitle')}: ${this.object.name}`; - } - - /* -------------------------------------------- */ - - /** @override */ - getData() { - const data = {}; - data.actor = this.object; - data.classes = this._getClasses(); - data.flags = this._getFlags(); - data.bonuses = this._getBonuses(); - return data; - } - - /* -------------------------------------------- */ - - /** - * Prepare an object of sorted classes. - * @return {object} - * @private - */ - _getClasses() { - const classes = this.object.items.filter(i => i.type === "class"); - return classes.sort((a, b) => a.name.localeCompare(b.name)).reduce((obj, i) => { - obj[i.id] = i.name; - return obj; - }, {}); - } - - /* -------------------------------------------- */ - - /** - * Prepare an object of flags data which groups flags by section - * Add some additional data for rendering - * @return {object} - * @private - */ - _getFlags() { - const flags = {}; - const baseData = this.document.toJSON(); - for ( let [k, v] of Object.entries(CONFIG.SW5E.characterFlags) ) { - if ( !flags.hasOwnProperty(v.section) ) flags[v.section] = {}; - let flag = foundry.utils.deepClone(v); - flag.type = v.type.name; - flag.isCheckbox = v.type === Boolean; - flag.isSelect = v.hasOwnProperty('choices'); - flag.value = getProperty(baseData.flags, `sw5e.${k}`); - flags[v.section][`flags.sw5e.${k}`] = flag; + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "actor-flags", + classes: ["sw5e"], + template: "systems/sw5e/templates/apps/actor-flags.html", + width: 500, + closeOnSubmit: true + }); } - return flags; - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * Get the bonuses fields and their localization strings - * @return {Array} - * @private - */ - _getBonuses() { - const bonuses = [ - {name: "data.bonuses.mwak.attack", label: "SW5E.BonusMWAttack"}, - {name: "data.bonuses.mwak.damage", label: "SW5E.BonusMWDamage"}, - {name: "data.bonuses.rwak.attack", label: "SW5E.BonusRWAttack"}, - {name: "data.bonuses.rwak.damage", label: "SW5E.BonusRWDamage"}, - {name: "data.bonuses.mpak.attack", label: "SW5E.BonusMPAttack"}, - {name: "data.bonuses.mpak.damage", label: "SW5E.BonusMPDamage"}, - {name: "data.bonuses.rpak.attack", label: "SW5E.BonusRPAttack"}, - {name: "data.bonuses.rpak.damage", label: "SW5E.BonusRPDamage"}, - {name: "data.bonuses.abilities.check", label: "SW5E.BonusAbilityCheck"}, - {name: "data.bonuses.abilities.save", label: "SW5E.BonusAbilitySave"}, - {name: "data.bonuses.abilities.skill", label: "SW5E.BonusAbilitySkill"}, - {name: "data.bonuses.power.dc", label: "SW5E.BonusPowerDC"}, - {name: "data.bonuses.power.forceLightDC", label: "SW5E.BonusForceLightPowerDC"}, - {name: "data.bonuses.power.forceDarkDC", label: "SW5E.BonusForceDarkPowerDC"}, - {name: "data.bonuses.power.forceUnivDC", label: "SW5E.BonusForceUnivPowerDC"}, - {name: "data.bonuses.power.techDC", label: "SW5E.BonusTechPowerDC"} - ]; - for ( let b of bonuses ) { - b.value = getProperty(this.object._data, b.name) || ""; + /** @override */ + get title() { + return `${game.i18n.localize("SW5E.FlagsTitle")}: ${this.object.name}`; } - return bonuses; - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - async _updateObject(event, formData) { - const actor = this.object; - let updateData = expandObject(formData); + /** @override */ + getData() { + const data = {}; + data.actor = this.object; + data.classes = this._getClasses(); + data.flags = this._getFlags(); + data.bonuses = this._getBonuses(); + return data; + } - // Unset any flags which are "false" - let unset = false; - const flags = updateData.flags.sw5e; - //clone flags to dnd5e for module compatability - updateData.flags.dnd5e = updateData.flags.sw5e - for ( let [k, v] of Object.entries(flags) ) { - if ( [undefined, null, "", false, 0].includes(v) ) { - delete flags[k]; - if ( hasProperty(actor._data.flags, `sw5e.${k}`) ) { - unset = true; - flags[`-=${k}`] = null; + /* -------------------------------------------- */ + + /** + * Prepare an object of sorted classes. + * @return {object} + * @private + */ + _getClasses() { + const classes = this.object.items.filter((i) => i.type === "class"); + return classes + .sort((a, b) => a.name.localeCompare(b.name)) + .reduce((obj, i) => { + obj[i.id] = i.name; + return obj; + }, {}); + } + + /* -------------------------------------------- */ + + /** + * Prepare an object of flags data which groups flags by section + * Add some additional data for rendering + * @return {object} + * @private + */ + _getFlags() { + const flags = {}; + const baseData = this.document.toJSON(); + for (let [k, v] of Object.entries(CONFIG.SW5E.characterFlags)) { + if (!flags.hasOwnProperty(v.section)) flags[v.section] = {}; + let flag = foundry.utils.deepClone(v); + flag.type = v.type.name; + flag.isCheckbox = v.type === Boolean; + flag.isSelect = v.hasOwnProperty("choices"); + flag.value = getProperty(baseData.flags, `sw5e.${k}`); + flags[v.section][`flags.sw5e.${k}`] = flag; } - } + return flags; } - // Clear any bonuses which are whitespace only - for ( let b of Object.values(updateData.data.bonuses ) ) { - for ( let [k, v] of Object.entries(b) ) { - b[k] = v.trim(); - } + /* -------------------------------------------- */ + + /** + * Get the bonuses fields and their localization strings + * @return {Array} + * @private + */ + _getBonuses() { + const bonuses = [ + {name: "data.bonuses.mwak.attack", label: "SW5E.BonusMWAttack"}, + {name: "data.bonuses.mwak.damage", label: "SW5E.BonusMWDamage"}, + {name: "data.bonuses.rwak.attack", label: "SW5E.BonusRWAttack"}, + {name: "data.bonuses.rwak.damage", label: "SW5E.BonusRWDamage"}, + {name: "data.bonuses.mpak.attack", label: "SW5E.BonusMPAttack"}, + {name: "data.bonuses.mpak.damage", label: "SW5E.BonusMPDamage"}, + {name: "data.bonuses.rpak.attack", label: "SW5E.BonusRPAttack"}, + {name: "data.bonuses.rpak.damage", label: "SW5E.BonusRPDamage"}, + {name: "data.bonuses.abilities.check", label: "SW5E.BonusAbilityCheck"}, + {name: "data.bonuses.abilities.save", label: "SW5E.BonusAbilitySave"}, + {name: "data.bonuses.abilities.skill", label: "SW5E.BonusAbilitySkill"}, + {name: "data.bonuses.power.dc", label: "SW5E.BonusPowerDC"}, + {name: "data.bonuses.power.forceLightDC", label: "SW5E.BonusForceLightPowerDC"}, + {name: "data.bonuses.power.forceDarkDC", label: "SW5E.BonusForceDarkPowerDC"}, + {name: "data.bonuses.power.forceUnivDC", label: "SW5E.BonusForceUnivPowerDC"}, + {name: "data.bonuses.power.techDC", label: "SW5E.BonusTechPowerDC"} + ]; + for (let b of bonuses) { + b.value = getProperty(this.object._data, b.name) || ""; + } + return bonuses; } - // Diff the data against any applied overrides and apply - await actor.update(updateData, {diff: false}); - } + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(event, formData) { + const actor = this.object; + let updateData = expandObject(formData); + + // Unset any flags which are "false" + let unset = false; + const flags = updateData.flags.sw5e; + //clone flags to dnd5e for module compatability + updateData.flags.dnd5e = updateData.flags.sw5e; + for (let [k, v] of Object.entries(flags)) { + if ([undefined, null, "", false, 0].includes(v)) { + delete flags[k]; + if (hasProperty(actor._data.flags, `sw5e.${k}`)) { + unset = true; + flags[`-=${k}`] = null; + } + } + } + + // Clear any bonuses which are whitespace only + for (let b of Object.values(updateData.data.bonuses)) { + for (let [k, v] of Object.entries(b)) { + b[k] = v.trim(); + } + } + + // Diff the data against any applied overrides and apply + await actor.update(updateData, {diff: false}); + } } diff --git a/module/apps/actor-type.js b/module/apps/actor-type.js index ad56e72b..7ad1ffe9 100644 --- a/module/apps/actor-type.js +++ b/module/apps/actor-type.js @@ -5,7 +5,6 @@ import Actor5e from "../actor/entity.js"; * @extends {FormApplication} */ export default class ActorTypeConfig extends FormApplication { - /** @inheritdoc */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { @@ -32,23 +31,23 @@ export default class ActorTypeConfig extends FormApplication { /** @override */ getData(options) { - // Get current value or new default - let attr = foundry.utils.getProperty(this.object.data.data, 'details.type'); - if ( foundry.utils.getType(attr) !== "Object" ) attr = { - value: (attr in CONFIG.SW5E.creatureTypes) ? attr : "humanoid", - subtype: "", - swarm: "", - custom: "" - }; + let attr = foundry.utils.getProperty(this.object.data.data, "details.type"); + if (foundry.utils.getType(attr) !== "Object") + attr = { + value: attr in CONFIG.SW5E.creatureTypes ? attr : "humanoid", + subtype: "", + swarm: "", + custom: "" + }; // Populate choices const types = {}; - for ( let [k, v] of Object.entries(CONFIG.SW5E.creatureTypes) ) { + for (let [k, v] of Object.entries(CONFIG.SW5E.creatureTypes)) { types[k] = { label: game.i18n.localize(v), chosen: attr.value === k - } + }; } // Return data for rendering @@ -61,12 +60,14 @@ export default class ActorTypeConfig extends FormApplication { }, subtype: attr.subtype, swarm: attr.swarm, - sizes: Array.from(Object.entries(CONFIG.SW5E.actorSizes)).reverse().reduce((obj, e) => { - obj[e[0]] = e[1]; - return obj; - }, {}), + sizes: Array.from(Object.entries(CONFIG.SW5E.actorSizes)) + .reverse() + .reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {}), preview: Actor5e.formatCreatureType(attr) || "–" - } + }; } /* -------------------------------------------- */ @@ -74,7 +75,7 @@ export default class ActorTypeConfig extends FormApplication { /** @override */ async _updateObject(event, formData) { const typeObject = foundry.utils.expandObject(formData); - return this.object.update({ 'data.details.type': typeObject }); + return this.object.update({"data.details.type": typeObject}); } /* -------------------------------------------- */ diff --git a/module/apps/hit-dice-config.js b/module/apps/hit-dice-config.js index d36d6bc2..f4fdf276 100644 --- a/module/apps/hit-dice-config.js +++ b/module/apps/hit-dice-config.js @@ -3,7 +3,6 @@ * @implements {DocumentSheet} */ export default class ActorHitDiceConfig extends DocumentSheet { - /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { @@ -26,20 +25,22 @@ export default class ActorHitDiceConfig extends DocumentSheet { /** @override */ getData(options) { return { - classes: this.object.items.reduce((classes, item) => { - if (item.data.type === "class") { - // Add the appropriate data only if this item is a "class" - classes.push({ - classItemId: item.data._id, - name: item.data.name, - diceDenom: item.data.data.hitDice, - currentHitDice: item.data.data.levels - item.data.data.hitDiceUsed, - maxHitDice: item.data.data.levels, - canRoll: (item.data.data.levels - item.data.data.hitDiceUsed) > 0 - }); - } - return classes; - }, []).sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1))) + classes: this.object.items + .reduce((classes, item) => { + if (item.data.type === "class") { + // Add the appropriate data only if this item is a "class" + classes.push({ + classItemId: item.data._id, + name: item.data.name, + diceDenom: item.data.data.hitDice, + currentHitDice: item.data.data.levels - item.data.data.hitDiceUsed, + maxHitDice: item.data.data.levels, + canRoll: item.data.data.levels - item.data.data.hitDiceUsed > 0 + }); + } + return classes; + }, []) + .sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1))) }; } @@ -50,7 +51,7 @@ export default class ActorHitDiceConfig extends DocumentSheet { super.activateListeners(html); // Hook up -/+ buttons to adjust the current value in the form - html.find("button.increment,button.decrement").click(event => { + html.find("button.increment,button.decrement").click((event) => { const button = event.currentTarget; const current = button.parentElement.querySelector(".current"); const max = button.parentElement.querySelector(".max"); @@ -67,8 +68,8 @@ export default class ActorHitDiceConfig extends DocumentSheet { async _updateObject(event, formData) { const actorItems = this.object.items; const classUpdates = Object.entries(formData).map(([id, hd]) => ({ - _id: id, - "data.hitDiceUsed": actorItems.get(id).data.data.levels - hd, + "_id": id, + "data.hitDiceUsed": actorItems.get(id).data.data.levels - hd })); return this.object.updateEmbeddedDocuments("Item", classUpdates); } diff --git a/module/apps/long-rest.js b/module/apps/long-rest.js index 3e57cea7..a34e53c7 100644 --- a/module/apps/long-rest.js +++ b/module/apps/long-rest.js @@ -3,65 +3,65 @@ * @extends {Dialog} */ export default class LongRestDialog extends Dialog { - constructor(actor, dialogData = {}, options = {}) { - super(dialogData, options); - this.actor = actor; - } + constructor(actor, dialogData = {}, options = {}) { + super(dialogData, options); + this.actor = actor; + } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - template: "systems/sw5e/templates/apps/long-rest.html", - classes: ["sw5e", "dialog"] - }); - } + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + template: "systems/sw5e/templates/apps/long-rest.html", + classes: ["sw5e", "dialog"] + }); + } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - getData() { - const data = super.getData(); - const variant = game.settings.get("sw5e", "restVariant"); - data.promptNewDay = variant !== "gritty"; // It's always a new day when resting 1 week - data.newDay = variant === "normal"; // It's probably a new day when resting normally (8 hours) - return data; - } + /** @override */ + getData() { + const data = super.getData(); + const variant = game.settings.get("sw5e", "restVariant"); + data.promptNewDay = variant !== "gritty"; // It's always a new day when resting 1 week + data.newDay = variant === "normal"; // It's probably a new day when resting normally (8 hours) + return data; + } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once it's - * workflow has been resolved. - * @param {Actor5e} actor - * @return {Promise} - */ - static async longRestDialog({ actor } = {}) { - return new Promise((resolve, reject) => { - const dlg = new this(actor, { - title: game.i18n.localize("SW5E.LongRest"), - buttons: { - rest: { - icon: '', - label: game.i18n.localize("SW5E.Rest"), - callback: html => { - let newDay = true; - if (game.settings.get("sw5e", "restVariant") !== "gritty") - newDay = html.find('input[name="newDay"]')[0].checked; - resolve(newDay); - } - }, - cancel: { - icon: '', - label: game.i18n.localize("Cancel"), - callback: reject - } - }, - default: 'rest', - close: reject - }); - dlg.render(true); - }); - } -} \ No newline at end of file + /** + * A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once it's + * workflow has been resolved. + * @param {Actor5e} actor + * @return {Promise} + */ + static async longRestDialog({actor} = {}) { + return new Promise((resolve, reject) => { + const dlg = new this(actor, { + title: game.i18n.localize("SW5E.LongRest"), + buttons: { + rest: { + icon: '', + label: game.i18n.localize("SW5E.Rest"), + callback: (html) => { + let newDay = true; + if (game.settings.get("sw5e", "restVariant") !== "gritty") + newDay = html.find('input[name="newDay"]')[0].checked; + resolve(newDay); + } + }, + cancel: { + icon: '', + label: game.i18n.localize("Cancel"), + callback: reject + } + }, + default: "rest", + close: reject + }); + dlg.render(true); + }); + } +} diff --git a/module/apps/movement-config.js b/module/apps/movement-config.js index c507965d..3c43170e 100644 --- a/module/apps/movement-config.js +++ b/module/apps/movement-config.js @@ -3,37 +3,36 @@ * @extends {DocumentSheet} */ export default class ActorMovementConfig extends DocumentSheet { - - /** @override */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["sw5e"], - template: "systems/sw5e/templates/apps/movement-config.html", - width: 300, - height: "auto" - }); - } - - /* -------------------------------------------- */ - - /** @override */ - get title() { - return `${game.i18n.localize("SW5E.MovementConfig")}: ${this.document.name}`; - } - - /* -------------------------------------------- */ - - /** @override */ - getData(options) { - const sourceMovement = foundry.utils.getProperty(this.document.data._source, "data.attributes.movement") || {}; - const data = { - movement: foundry.utils.deepClone(sourceMovement), - units: CONFIG.SW5E.movementUnits - }; - for ( let [k, v] of Object.entries(data.movement) ) { - if ( ["units", "hover"].includes(k) ) continue; - data.movement[k] = Number.isNumeric(v) ? v.toNearest(0.1) : 0; + /** @override */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sw5e"], + template: "systems/sw5e/templates/apps/movement-config.html", + width: 300, + height: "auto" + }); + } + + /* -------------------------------------------- */ + + /** @override */ + get title() { + return `${game.i18n.localize("SW5E.MovementConfig")}: ${this.document.name}`; + } + + /* -------------------------------------------- */ + + /** @override */ + getData(options) { + const sourceMovement = foundry.utils.getProperty(this.document.data._source, "data.attributes.movement") || {}; + const data = { + movement: foundry.utils.deepClone(sourceMovement), + units: CONFIG.SW5E.movementUnits + }; + for (let [k, v] of Object.entries(data.movement)) { + if (["units", "hover"].includes(k)) continue; + data.movement[k] = Number.isNumeric(v) ? v.toNearest(0.1) : 0; + } + return data; } - return data; - } } diff --git a/module/apps/select-items-prompt.js b/module/apps/select-items-prompt.js index 0eac6497..cd99a278 100644 --- a/module/apps/select-items-prompt.js +++ b/module/apps/select-items-prompt.js @@ -3,7 +3,7 @@ * @type {Dialog} */ export default class SelectItemsPrompt extends Dialog { - constructor(items, dialogData={}, options={}) { + constructor(items, dialogData = {}, options = {}) { super(dialogData, options); this.options.classes = ["sw5e", "dialog", "select-items-prompt", "sheet"]; @@ -18,11 +18,11 @@ export default class SelectItemsPrompt extends Dialog { super.activateListeners(html); // render the item's sheet if its image is clicked - html.on('click', '.item-image', (event) => { + html.on("click", ".item-image", (event) => { const item = this.items.find((feature) => feature.id === event.currentTarget.dataset?.itemId); item?.sheet.render(true); - }) + }); } /** @@ -33,29 +33,27 @@ export default class SelectItemsPrompt extends Dialog { * @param {string} options.hint - Localized hint to display at the top of the prompt * @return {Promise} - list of item ids which the user has selected */ - static async create(items, { - hint - }) { + static async create(items, {hint}) { // Render the ability usage template const html = await renderTemplate("systems/sw5e/templates/apps/select-items-prompt.html", {items, hint}); return new Promise((resolve) => { const dlg = new this(items, { - title: game.i18n.localize('SW5E.SelectItemsPromptTitle'), + title: game.i18n.localize("SW5E.SelectItemsPromptTitle"), content: html, buttons: { apply: { icon: ``, - label: game.i18n.localize('SW5E.Apply'), - callback: html => { + label: game.i18n.localize("SW5E.Apply"), + callback: (html) => { const fd = new FormDataExtended(html[0].querySelector("form")).toObject(); - const selectedIds = Object.keys(fd).filter(itemId => fd[itemId]); + const selectedIds = Object.keys(fd).filter((itemId) => fd[itemId]); resolve(selectedIds); } }, cancel: { icon: '', - label: game.i18n.localize('SW5E.Skip'), + label: game.i18n.localize("SW5E.Skip"), callback: () => resolve([]) } }, diff --git a/module/apps/senses-config.js b/module/apps/senses-config.js index 707ca7fb..e12e5478 100644 --- a/module/apps/senses-config.js +++ b/module/apps/senses-config.js @@ -3,41 +3,41 @@ * @extends {DocumentSheet} */ export default class ActorSensesConfig extends DocumentSheet { - - /** @inheritdoc */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["sw5e"], - template: "systems/sw5e/templates/apps/senses-config.html", - width: 300, - height: "auto" - }); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get title() { - return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.document.name}`; - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - getData(options) { - const senses = foundry.utils.getProperty(this.document.data._source, "data.attributes.senses") || {}; - const data = { - senses: {}, - special: senses.special ?? "", - units: senses.units, movementUnits: CONFIG.SW5E.movementUnits - }; - for ( let [name, label] of Object.entries(CONFIG.SW5E.senses) ) { - const v = senses[name]; - data.senses[name] = { - label: game.i18n.localize(label), - value: Number.isNumeric(v) ? v.toNearest(0.1) : 0 - } + /** @inheritdoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sw5e"], + template: "systems/sw5e/templates/apps/senses-config.html", + width: 300, + height: "auto" + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + get title() { + return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.document.name}`; + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + getData(options) { + const senses = foundry.utils.getProperty(this.document.data._source, "data.attributes.senses") || {}; + const data = { + senses: {}, + special: senses.special ?? "", + units: senses.units, + movementUnits: CONFIG.SW5E.movementUnits + }; + for (let [name, label] of Object.entries(CONFIG.SW5E.senses)) { + const v = senses[name]; + data.senses[name] = { + label: game.i18n.localize(label), + value: Number.isNumeric(v) ? v.toNearest(0.1) : 0 + }; + } + return data; } - return data; - } } diff --git a/module/apps/short-rest.js b/module/apps/short-rest.js index 22a186ab..95e7c68a 100644 --- a/module/apps/short-rest.js +++ b/module/apps/short-rest.js @@ -5,129 +5,130 @@ import LongRestDialog from "./long-rest.js"; * @extends {Dialog} */ export default class ShortRestDialog extends Dialog { - constructor(actor, dialogData={}, options={}) { - super(dialogData, options); + constructor(actor, dialogData = {}, options = {}) { + super(dialogData, options); - /** - * Store a reference to the Actor entity which is resting - * @type {Actor} - */ - this.actor = actor; + /** + * Store a reference to the Actor entity which is resting + * @type {Actor} + */ + this.actor = actor; - /** - * Track the most recently used HD denomination for re-rendering the form - * @type {string} - */ - this._denom = null; - } + /** + * Track the most recently used HD denomination for re-rendering the form + * @type {string} + */ + this._denom = null; + } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - template: "systems/sw5e/templates/apps/short-rest.html", - classes: ["sw5e", "dialog"] - }); - } + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + template: "systems/sw5e/templates/apps/short-rest.html", + classes: ["sw5e", "dialog"] + }); + } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @override */ - getData() { - const data = super.getData(); + /** @override */ + getData() { + const data = super.getData(); - // Determine Hit Dice - data.availableHD = this.actor.data.items.reduce((hd, item) => { - if ( item.type === "class" ) { - const d = item.data.data; - const denom = d.hitDice || "d6"; - const available = parseInt(d.levels || 1) - parseInt(d.hitDiceUsed || 0); - hd[denom] = denom in hd ? hd[denom] + available : available; - } - return hd; - }, {}); - data.canRoll = this.actor.data.data.attributes.hd > 0; - data.denomination = this._denom; - - // Determine rest type - const variant = game.settings.get("sw5e", "restVariant"); - data.promptNewDay = variant !== "epic"; // It's never a new day when only resting 1 minute - data.newDay = false; // It may be a new day, but not by default - return data; - } - - /* -------------------------------------------- */ - - - /** @override */ - activateListeners(html) { - super.activateListeners(html); - let btn = html.find("#roll-hd"); - btn.click(this._onRollHitDie.bind(this)); - } - - /* -------------------------------------------- */ - - /** - * Handle rolling a Hit Die as part of a Short Rest action - * @param {Event} event The triggering click event - * @private - */ - async _onRollHitDie(event) { - event.preventDefault(); - const btn = event.currentTarget; - this._denom = btn.form.hd.value; - await this.actor.rollHitDie(this._denom); - this.render(); - } - - /* -------------------------------------------- */ - - /** - * A helper constructor function which displays the Short Rest dialog and returns a Promise once it's workflow has - * been resolved. - * @param {Actor5e} actor - * @return {Promise} - */ - static async shortRestDialog({actor}={}) { - return new Promise((resolve, reject) => { - const dlg = new this(actor, { - title: game.i18n.localize("SW5E.ShortRest"), - buttons: { - rest: { - icon: '', - label: game.i18n.localize("SW5E.Rest"), - callback: html => { - let newDay = false; - if (game.settings.get("sw5e", "restVariant") === "gritty") - newDay = html.find('input[name="newDay"]')[0].checked; - resolve(newDay); + // Determine Hit Dice + data.availableHD = this.actor.data.items.reduce((hd, item) => { + if (item.type === "class") { + const d = item.data.data; + const denom = d.hitDice || "d6"; + const available = parseInt(d.levels || 1) - parseInt(d.hitDiceUsed || 0); + hd[denom] = denom in hd ? hd[denom] + available : available; } - }, - cancel: { - icon: '', - label: game.i18n.localize("Cancel"), - callback: reject - } - }, - close: reject - }); - dlg.render(true); - }); - } + return hd; + }, {}); + data.canRoll = this.actor.data.data.attributes.hd > 0; + data.denomination = this._denom; - /* -------------------------------------------- */ + // Determine rest type + const variant = game.settings.get("sw5e", "restVariant"); + data.promptNewDay = variant !== "epic"; // It's never a new day when only resting 1 minute + data.newDay = false; // It may be a new day, but not by default + return data; + } - /** - * A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once it's - * workflow has been resolved. - * @deprecated - * @param {Actor5e} actor - * @return {Promise} - */ - static async longRestDialog({actor}={}) { - console.warn("WARNING! ShortRestDialog.longRestDialog has been deprecated, use LongRestDialog.longRestDialog instead."); - return LongRestDialog.longRestDialog(...arguments); - } + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + let btn = html.find("#roll-hd"); + btn.click(this._onRollHitDie.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Handle rolling a Hit Die as part of a Short Rest action + * @param {Event} event The triggering click event + * @private + */ + async _onRollHitDie(event) { + event.preventDefault(); + const btn = event.currentTarget; + this._denom = btn.form.hd.value; + await this.actor.rollHitDie(this._denom); + this.render(); + } + + /* -------------------------------------------- */ + + /** + * A helper constructor function which displays the Short Rest dialog and returns a Promise once it's workflow has + * been resolved. + * @param {Actor5e} actor + * @return {Promise} + */ + static async shortRestDialog({actor} = {}) { + return new Promise((resolve, reject) => { + const dlg = new this(actor, { + title: game.i18n.localize("SW5E.ShortRest"), + buttons: { + rest: { + icon: '', + label: game.i18n.localize("SW5E.Rest"), + callback: (html) => { + let newDay = false; + if (game.settings.get("sw5e", "restVariant") === "gritty") + newDay = html.find('input[name="newDay"]')[0].checked; + resolve(newDay); + } + }, + cancel: { + icon: '', + label: game.i18n.localize("Cancel"), + callback: reject + } + }, + close: reject + }); + dlg.render(true); + }); + } + + /* -------------------------------------------- */ + + /** + * A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once it's + * workflow has been resolved. + * @deprecated + * @param {Actor5e} actor + * @return {Promise} + */ + static async longRestDialog({actor} = {}) { + console.warn( + "WARNING! ShortRestDialog.longRestDialog has been deprecated, use LongRestDialog.longRestDialog instead." + ); + return LongRestDialog.longRestDialog(...arguments); + } } diff --git a/module/apps/trait-selector.js b/module/apps/trait-selector.js index ef3c82c1..6c454cf5 100644 --- a/module/apps/trait-selector.js +++ b/module/apps/trait-selector.js @@ -3,86 +3,85 @@ * @extends {DocumentSheet} */ export default class TraitSelector extends DocumentSheet { - - /** @inheritdoc */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - id: "trait-selector", - classes: ["sw5e", "trait-selector", "subconfig"], - title: "Actor Trait Selection", - template: "systems/sw5e/templates/apps/trait-selector.html", - width: 320, - height: "auto", - choices: {}, - allowCustom: true, - minimum: 0, - maximum: null, - valueKey: "value", - customKey: "custom" - }); - } - - /* -------------------------------------------- */ - - /** - * Return a reference to the target attribute - * @type {string} - */ - get attribute() { - return this.options.name; - } - - /* -------------------------------------------- */ - - /** @override */ - getData() { - const attr = foundry.utils.getProperty(this.object.data, this.attribute); - const o = this.options; - const value = (o.valueKey) ? attr[o.valueKey] ?? [] : attr; - const custom = (o.customKey) ? attr[o.customKey] ?? "" : ""; - - // Populate choices - const choices = Object.entries(o.choices).reduce((obj, e) => { - let [k, v] = e; - obj[k] = { label: v, chosen: attr ? value.includes(k) : false }; - return obj; - }, {}) - - // Return data - return { - allowCustom: o.allowCustom, - choices: choices, - custom: custom - } - } - - /* -------------------------------------------- */ - - /** @override */ - async _updateObject(event, formData) { - const o = this.options; - - // Obtain choices - const chosen = []; - for ( let [k, v] of Object.entries(formData) ) { - if ( (k !== "custom") && v ) chosen.push(k); + /** @inheritdoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "trait-selector", + classes: ["sw5e", "trait-selector", "subconfig"], + title: "Actor Trait Selection", + template: "systems/sw5e/templates/apps/trait-selector.html", + width: 320, + height: "auto", + choices: {}, + allowCustom: true, + minimum: 0, + maximum: null, + valueKey: "value", + customKey: "custom" + }); } - // Object including custom data - const updateData = {}; - if ( o.valueKey ) updateData[`${this.attribute}.${o.valueKey}`] = chosen; - else updateData[this.attribute] = chosen; - if ( o.allowCustom ) updateData[`${this.attribute}.${o.customKey}`] = formData.custom; + /* -------------------------------------------- */ - // Validate the number chosen - if ( o.minimum && (chosen.length < o.minimum) ) { - return ui.notifications.error(`You must choose at least ${o.minimum} options`); - } - if ( o.maximum && (chosen.length > o.maximum) ) { - return ui.notifications.error(`You may choose no more than ${o.maximum} options`); + /** + * Return a reference to the target attribute + * @type {string} + */ + get attribute() { + return this.options.name; } - // Update the object - this.object.update(updateData); - } + /* -------------------------------------------- */ + + /** @override */ + getData() { + const attr = foundry.utils.getProperty(this.object.data, this.attribute); + const o = this.options; + const value = o.valueKey ? attr[o.valueKey] ?? [] : attr; + const custom = o.customKey ? attr[o.customKey] ?? "" : ""; + + // Populate choices + const choices = Object.entries(o.choices).reduce((obj, e) => { + let [k, v] = e; + obj[k] = {label: v, chosen: attr ? value.includes(k) : false}; + return obj; + }, {}); + + // Return data + return { + allowCustom: o.allowCustom, + choices: choices, + custom: custom + }; + } + + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(event, formData) { + const o = this.options; + + // Obtain choices + const chosen = []; + for (let [k, v] of Object.entries(formData)) { + if (k !== "custom" && v) chosen.push(k); + } + + // Object including custom data + const updateData = {}; + if (o.valueKey) updateData[`${this.attribute}.${o.valueKey}`] = chosen; + else updateData[this.attribute] = chosen; + if (o.allowCustom) updateData[`${this.attribute}.${o.customKey}`] = formData.custom; + + // Validate the number chosen + if (o.minimum && chosen.length < o.minimum) { + return ui.notifications.error(`You must choose at least ${o.minimum} options`); + } + if (o.maximum && chosen.length > o.maximum) { + return ui.notifications.error(`You may choose no more than ${o.maximum} options`); + } + + // Update the object + this.object.update(updateData); + } } diff --git a/module/canvas.js b/module/canvas.js index 622e2a30..72c60c81 100644 --- a/module/canvas.js +++ b/module/canvas.js @@ -1,38 +1,38 @@ /** @override */ -export const measureDistances = function(segments, options={}) { - if ( !options.gridSpaces ) return BaseGrid.prototype.measureDistances.call(this, segments, options); +export const measureDistances = function (segments, options = {}) { + if (!options.gridSpaces) return BaseGrid.prototype.measureDistances.call(this, segments, options); - // Track the total number of diagonals - let nDiagonal = 0; - const rule = this.parent.diagonalRule; - const d = canvas.dimensions; + // Track the total number of diagonals + let nDiagonal = 0; + const rule = this.parent.diagonalRule; + const d = canvas.dimensions; - // Iterate over measured segments - return segments.map(s => { - let r = s.ray; + // Iterate over measured segments + return segments.map((s) => { + let r = s.ray; - // Determine the total distance traveled - let nx = Math.abs(Math.ceil(r.dx / d.size)); - let ny = Math.abs(Math.ceil(r.dy / d.size)); + // Determine the total distance traveled + let nx = Math.abs(Math.ceil(r.dx / d.size)); + let ny = Math.abs(Math.ceil(r.dy / d.size)); - // Determine the number of straight and diagonal moves - let nd = Math.min(nx, ny); - let ns = Math.abs(ny - nx); - nDiagonal += nd; + // Determine the number of straight and diagonal moves + let nd = Math.min(nx, ny); + let ns = Math.abs(ny - nx); + nDiagonal += nd; - // Alternative DMG Movement - if (rule === "5105") { - let nd10 = Math.floor(nDiagonal / 2) - Math.floor((nDiagonal - nd) / 2); - let spaces = (nd10 * 2) + (nd - nd10) + ns; - return spaces * canvas.dimensions.distance; - } + // Alternative DMG Movement + if (rule === "5105") { + let nd10 = Math.floor(nDiagonal / 2) - Math.floor((nDiagonal - nd) / 2); + let spaces = nd10 * 2 + (nd - nd10) + ns; + return spaces * canvas.dimensions.distance; + } - // Euclidean Measurement - else if (rule === "EUCL") { - return Math.round(Math.hypot(nx, ny) * canvas.scene.data.gridDistance); - } + // Euclidean Measurement + else if (rule === "EUCL") { + return Math.round(Math.hypot(nx, ny) * canvas.scene.data.gridDistance); + } - // Standard PHB Movement - else return (ns + nd) * canvas.scene.data.gridDistance; - }); -}; \ No newline at end of file + // Standard PHB Movement + else return (ns + nd) * canvas.scene.data.gridDistance; + }); +}; diff --git a/module/characterImporter.js b/module/characterImporter.js index 8c47aa20..178be216 100644 --- a/module/characterImporter.js +++ b/module/characterImporter.js @@ -1,51 +1,51 @@ export default class CharacterImporter { - // transform JSON from sw5e.com to Foundry friendly format - // and insert new actor - static async transform(rawCharacter) { - const sourceCharacter = JSON.parse(rawCharacter); //source character + // transform JSON from sw5e.com to Foundry friendly format + // and insert new actor + static async transform(rawCharacter) { + const sourceCharacter = JSON.parse(rawCharacter); //source character - const details = { - species: sourceCharacter.attribs.find((e) => e.name == "race").current, - background: sourceCharacter.attribs.find((e) => e.name == "background").current, - alignment: sourceCharacter.attribs.find((e) => e.name == "alignment").current - }; + const details = { + species: sourceCharacter.attribs.find((e) => e.name == "race").current, + background: sourceCharacter.attribs.find((e) => e.name == "background").current, + alignment: sourceCharacter.attribs.find((e) => e.name == "alignment").current + }; - const hp = { - value: sourceCharacter.attribs.find((e) => e.name == "hp").current, - min: 0, - max: sourceCharacter.attribs.find((e) => e.name == "hp").current, - temp: sourceCharacter.attribs.find((e) => e.name == "hp_temp").current - }; + const hp = { + value: sourceCharacter.attribs.find((e) => e.name == "hp").current, + min: 0, + max: sourceCharacter.attribs.find((e) => e.name == "hp").current, + temp: sourceCharacter.attribs.find((e) => e.name == "hp_temp").current + }; - const abilities = { - str: { - value: sourceCharacter.attribs.find((e) => e.name == "strength").current, - proficient: sourceCharacter.attribs.find((e) => e.name == "strength_save_prof").current ? 1 : 0 - }, - dex: { - value: sourceCharacter.attribs.find((e) => e.name == "dexterity").current, - proficient: sourceCharacter.attribs.find((e) => e.name == "dexterity_save_prof").current ? 1 : 0 - }, - con: { - value: sourceCharacter.attribs.find((e) => e.name == "constitution").current, - proficient: sourceCharacter.attribs.find((e) => e.name == "constitution_save_prof").current ? 1 : 0 - }, - int: { - value: sourceCharacter.attribs.find((e) => e.name == "intelligence").current, - proficient: sourceCharacter.attribs.find((e) => e.name == "intelligence_save_prof").current ? 1 : 0 - }, - wis: { - value: sourceCharacter.attribs.find((e) => e.name == "wisdom").current, - proficient: sourceCharacter.attribs.find((e) => e.name == "wisdom_save_prof").current ? 1 : 0 - }, - cha: { - value: sourceCharacter.attribs.find((e) => e.name == "charisma").current, - proficient: sourceCharacter.attribs.find((e) => e.name == "charisma_save_prof").current ? 1 : 0 - } - }; + const abilities = { + str: { + value: sourceCharacter.attribs.find((e) => e.name == "strength").current, + proficient: sourceCharacter.attribs.find((e) => e.name == "strength_save_prof").current ? 1 : 0 + }, + dex: { + value: sourceCharacter.attribs.find((e) => e.name == "dexterity").current, + proficient: sourceCharacter.attribs.find((e) => e.name == "dexterity_save_prof").current ? 1 : 0 + }, + con: { + value: sourceCharacter.attribs.find((e) => e.name == "constitution").current, + proficient: sourceCharacter.attribs.find((e) => e.name == "constitution_save_prof").current ? 1 : 0 + }, + int: { + value: sourceCharacter.attribs.find((e) => e.name == "intelligence").current, + proficient: sourceCharacter.attribs.find((e) => e.name == "intelligence_save_prof").current ? 1 : 0 + }, + wis: { + value: sourceCharacter.attribs.find((e) => e.name == "wisdom").current, + proficient: sourceCharacter.attribs.find((e) => e.name == "wisdom_save_prof").current ? 1 : 0 + }, + cha: { + value: sourceCharacter.attribs.find((e) => e.name == "charisma").current, + proficient: sourceCharacter.attribs.find((e) => e.name == "charisma_save_prof").current ? 1 : 0 + } + }; - /* ----------------------------------------------------------------- */ - /* character.data.skills..value is all that matters + /* ----------------------------------------------------------------- */ + /* character.data.skills..value is all that matters /* values can be 0, 0.5, 1 or 2 /* 0 = regular /* 0.5 = half-proficient @@ -53,272 +53,274 @@ export default class CharacterImporter { /* 2 = expertise /* foundry takes care of calculating the rest /* ----------------------------------------------------------------- */ - const skills = { - acr: { - value: sourceCharacter.attribs.find((e) => e.name == "acrobatics_type").current - }, - ani: { - value: sourceCharacter.attribs.find((e) => e.name == "animal_handling_type").current - }, - ath: { - value: sourceCharacter.attribs.find((e) => e.name == "athletics_type").current - }, - dec: { - value: sourceCharacter.attribs.find((e) => e.name == "deception_type").current - }, - ins: { - value: sourceCharacter.attribs.find((e) => e.name == "insight_type").current - }, - inv: { - value: sourceCharacter.attribs.find((e) => e.name == "investigation_type").current - }, - itm: { - value: sourceCharacter.attribs.find((e) => e.name == "intimidation_type").current - }, - lor: { - value: sourceCharacter.attribs.find((e) => e.name == "lore_type").current - }, - med: { - value: sourceCharacter.attribs.find((e) => e.name == "medicine_type").current - }, - nat: { - value: sourceCharacter.attribs.find((e) => e.name == "nature_type").current - }, - per: { - value: sourceCharacter.attribs.find((e) => e.name == "persuasion_type").current - }, - pil: { - value: sourceCharacter.attribs.find((e) => e.name == "piloting_type").current - }, - prc: { - value: sourceCharacter.attribs.find((e) => e.name == "perception_type").current - }, - prf: { - value: sourceCharacter.attribs.find((e) => e.name == "performance_type").current - }, - slt: { - value: sourceCharacter.attribs.find((e) => e.name == "sleight_of_hand_type").current - }, - ste: { - value: sourceCharacter.attribs.find((e) => e.name == "stealth_type").current - }, - sur: { - value: sourceCharacter.attribs.find((e) => e.name == "survival_type").current - }, - tec: { - value: sourceCharacter.attribs.find((e) => e.name == "technology_type").current - } - }; - - const targetCharacter = { - name: sourceCharacter.name, - type: "character", - data: { - abilities: abilities, - details: details, - skills: skills, - attributes: { - hp: hp - } - } - }; - - let actor = await Actor.create(targetCharacter); - CharacterImporter.addProfessions(sourceCharacter, actor); - } - - // Parse all classes and add them to already created actor. - // "class" is a reserved word, therefore I use profession where I can. - static async addProfessions(sourceCharacter, actor) { - let result = []; - - // parse all class and multiclassX items - // couldn't get Array.filter to work here for some reason - // result = array of objects. each object is a separate class - sourceCharacter.attribs.forEach((e) => { - if (CharacterImporter.classOrMulticlass(e.name)) { - var t = { - profession: CharacterImporter.capitalize(e.current), - type: CharacterImporter.baseOrMulti(e.name), - level: CharacterImporter.getLevel(e, sourceCharacter) + const skills = { + acr: { + value: sourceCharacter.attribs.find((e) => e.name == "acrobatics_type").current + }, + ani: { + value: sourceCharacter.attribs.find((e) => e.name == "animal_handling_type").current + }, + ath: { + value: sourceCharacter.attribs.find((e) => e.name == "athletics_type").current + }, + dec: { + value: sourceCharacter.attribs.find((e) => e.name == "deception_type").current + }, + ins: { + value: sourceCharacter.attribs.find((e) => e.name == "insight_type").current + }, + inv: { + value: sourceCharacter.attribs.find((e) => e.name == "investigation_type").current + }, + itm: { + value: sourceCharacter.attribs.find((e) => e.name == "intimidation_type").current + }, + lor: { + value: sourceCharacter.attribs.find((e) => e.name == "lore_type").current + }, + med: { + value: sourceCharacter.attribs.find((e) => e.name == "medicine_type").current + }, + nat: { + value: sourceCharacter.attribs.find((e) => e.name == "nature_type").current + }, + per: { + value: sourceCharacter.attribs.find((e) => e.name == "persuasion_type").current + }, + pil: { + value: sourceCharacter.attribs.find((e) => e.name == "piloting_type").current + }, + prc: { + value: sourceCharacter.attribs.find((e) => e.name == "perception_type").current + }, + prf: { + value: sourceCharacter.attribs.find((e) => e.name == "performance_type").current + }, + slt: { + value: sourceCharacter.attribs.find((e) => e.name == "sleight_of_hand_type").current + }, + ste: { + value: sourceCharacter.attribs.find((e) => e.name == "stealth_type").current + }, + sur: { + value: sourceCharacter.attribs.find((e) => e.name == "survival_type").current + }, + tec: { + value: sourceCharacter.attribs.find((e) => e.name == "technology_type").current + } }; - result.push(t); - } - }); - // pull classes directly from system compendium and add them to current actor - const professionsPack = await game.packs.get("sw5e.classes").getDocuments(); - result.forEach((prof) => { - let assignedProfession = professionsPack.find((o) => o.name === prof.profession); - assignedProfession.data.data.levels = prof.level; - actor.createEmbeddedDocuments("Item", [assignedProfession.data], { displaySheet: false }); - }); + const targetCharacter = { + name: sourceCharacter.name, + type: "character", + data: { + abilities: abilities, + details: details, + skills: skills, + attributes: { + hp: hp + } + } + }; - this.addSpecies(sourceCharacter.attribs.find((e) => e.name == "race").current, actor); - - this.addPowers( - sourceCharacter.attribs.filter((e) => e.name.search(/repeating_power.+_powername/g) != -1).map((e) => e.current), - actor - ); - - const discoveredItems = sourceCharacter.attribs.filter( - (e) => e.name.search(/repeating_inventory.+_itemname/g) != -1 - ); - const items = discoveredItems.map((item) => { - const id = item.name.match(/-\w{19}/g); - - return { - name: item.current, - quantity: sourceCharacter.attribs.find((e) => e.name === `repeating_inventory_${id}_itemcount`).current - }; - }); - - this.addItems(items, actor); - } - - static async addClasses(profession, level, actor) { - let classes = await game.packs.get("sw5e.classes").getDocuments(); - let assignedClass = classes.find((c) => c.name === profession); - assignedClass.data.data.levels = level; - await actor.createEmbeddedDocuments("Item", [assignedClass.data], { displaySheet: false }); - } - - static classOrMulticlass(name) { - return name === "class" || (name.includes("multiclass") && name.length <= 12); - } - - static baseOrMulti(name) { - if (name === "class") { - return "base_class"; - } else { - return "multi_class"; + let actor = await Actor.create(targetCharacter); + CharacterImporter.addProfessions(sourceCharacter, actor); } - } - static getLevel(item, sourceCharacter) { - if (item.name === "class") { - let result = sourceCharacter.attribs.find((e) => e.name === "base_level").current; - return parseInt(result); - } else { - let result = sourceCharacter.attribs.find((e) => e.name === `${item.name}_lvl`).current; - return parseInt(result); + // Parse all classes and add them to already created actor. + // "class" is a reserved word, therefore I use profession where I can. + static async addProfessions(sourceCharacter, actor) { + let result = []; + + // parse all class and multiclassX items + // couldn't get Array.filter to work here for some reason + // result = array of objects. each object is a separate class + sourceCharacter.attribs.forEach((e) => { + if (CharacterImporter.classOrMulticlass(e.name)) { + var t = { + profession: CharacterImporter.capitalize(e.current), + type: CharacterImporter.baseOrMulti(e.name), + level: CharacterImporter.getLevel(e, sourceCharacter) + }; + result.push(t); + } + }); + + // pull classes directly from system compendium and add them to current actor + const professionsPack = await game.packs.get("sw5e.classes").getDocuments(); + result.forEach((prof) => { + let assignedProfession = professionsPack.find((o) => o.name === prof.profession); + assignedProfession.data.data.levels = prof.level; + actor.createEmbeddedDocuments("Item", [assignedProfession.data], {displaySheet: false}); + }); + + this.addSpecies(sourceCharacter.attribs.find((e) => e.name == "race").current, actor); + + this.addPowers( + sourceCharacter.attribs + .filter((e) => e.name.search(/repeating_power.+_powername/g) != -1) + .map((e) => e.current), + actor + ); + + const discoveredItems = sourceCharacter.attribs.filter( + (e) => e.name.search(/repeating_inventory.+_itemname/g) != -1 + ); + const items = discoveredItems.map((item) => { + const id = item.name.match(/-\w{19}/g); + + return { + name: item.current, + quantity: sourceCharacter.attribs.find((e) => e.name === `repeating_inventory_${id}_itemcount`).current + }; + }); + + this.addItems(items, actor); } - } - static capitalize(str) { - return str.charAt(0).toUpperCase() + str.slice(1); - } - - static async addSpecies(race, actor) { - const species = await game.packs.get("sw5e.species").getDocuments(); - const assignedSpecies = species.find((c) => c.name === race); - const activeEffects = [...assignedSpecies.data.effects][0].data.changes; - const actorData = { data: { abilities: { ...actor.data.data.abilities } } }; - - activeEffects.map((effect) => { - switch (effect.key) { - case "data.abilities.str.value": - actorData.data.abilities.str.value -= effect.value; - break; - - case "data.abilities.dex.value": - actorData.data.abilities.dex.value -= effect.value; - break; - - case "data.abilities.con.value": - actorData.data.abilities.con.value -= effect.value; - break; - - case "data.abilities.int.value": - actorData.data.abilities.int.value -= effect.value; - break; - - case "data.abilities.wis.value": - actorData.data.abilities.wis.value -= effect.value; - break; - - case "data.abilities.cha.value": - actorData.data.abilities.cha.value -= effect.value; - break; - - default: - break; - } - }); - - actor.update(actorData); - - await actor.createEmbeddedDocuments("Item", [assignedSpecies.data], { displaySheet: false }); - } - - static async addPowers(powers, actor) { - const forcePowers = await game.packs.get("sw5e.forcepowers").getDocuments(); - const techPowers = await game.packs.get("sw5e.techpowers").getDocuments(); - - for (const power of powers) { - const createdPower = forcePowers.find((c) => c.name === power) || techPowers.find((c) => c.name === power); - - if (createdPower) { - await actor.createEmbeddedDocuments("Item", [createdPower.data], { displaySheet: false }); - } + static async addClasses(profession, level, actor) { + let classes = await game.packs.get("sw5e.classes").getDocuments(); + let assignedClass = classes.find((c) => c.name === profession); + assignedClass.data.data.levels = level; + await actor.createEmbeddedDocuments("Item", [assignedClass.data], {displaySheet: false}); } - } - static async addItems(items, actor) { - const weapons = await game.packs.get("sw5e.weapons").getDocuments(); - const armors = await game.packs.get("sw5e.armor").getDocuments(); - const adventuringGear = await game.packs.get("sw5e.adventuringgear").getDocuments(); + static classOrMulticlass(name) { + return name === "class" || (name.includes("multiclass") && name.length <= 12); + } - for (const item of items) { - const createdItem = - weapons.find((c) => c.name.toLowerCase() === item.name.toLowerCase()) || - armors.find((c) => c.name.toLowerCase() === item.name.toLowerCase()) || - adventuringGear.find((c) => c.name.toLowerCase() === item.name.toLowerCase()); - - if (createdItem) { - if (item.quantity != 1) { - createdItem.data.data.quantity = item.quantity; + static baseOrMulti(name) { + if (name === "class") { + return "base_class"; + } else { + return "multi_class"; } - - await actor.createEmbeddedDocuments("Item", [createdItem.data], { displaySheet: false }); - } } - } - static addImportButton(html) { - const actionButtons = html.find(".header-actions"); - actionButtons[0].insertAdjacentHTML( - "afterend", - `
` - ); + static getLevel(item, sourceCharacter) { + if (item.name === "class") { + let result = sourceCharacter.attribs.find((e) => e.name === "base_level").current; + return parseInt(result); + } else { + let result = sourceCharacter.attribs.find((e) => e.name === `${item.name}_lvl`).current; + return parseInt(result); + } + } - let characterImportButton = $(".cs-import-button"); - characterImportButton.click(() => { - let content = `

Saved Character JSON Import

+ static capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + static async addSpecies(race, actor) { + const species = await game.packs.get("sw5e.species").getDocuments(); + const assignedSpecies = species.find((c) => c.name === race); + const activeEffects = [...assignedSpecies.data.effects][0].data.changes; + const actorData = {data: {abilities: {...actor.data.data.abilities}}}; + + activeEffects.map((effect) => { + switch (effect.key) { + case "data.abilities.str.value": + actorData.data.abilities.str.value -= effect.value; + break; + + case "data.abilities.dex.value": + actorData.data.abilities.dex.value -= effect.value; + break; + + case "data.abilities.con.value": + actorData.data.abilities.con.value -= effect.value; + break; + + case "data.abilities.int.value": + actorData.data.abilities.int.value -= effect.value; + break; + + case "data.abilities.wis.value": + actorData.data.abilities.wis.value -= effect.value; + break; + + case "data.abilities.cha.value": + actorData.data.abilities.cha.value -= effect.value; + break; + + default: + break; + } + }); + + actor.update(actorData); + + await actor.createEmbeddedDocuments("Item", [assignedSpecies.data], {displaySheet: false}); + } + + static async addPowers(powers, actor) { + const forcePowers = await game.packs.get("sw5e.forcepowers").getDocuments(); + const techPowers = await game.packs.get("sw5e.techpowers").getDocuments(); + + for (const power of powers) { + const createdPower = forcePowers.find((c) => c.name === power) || techPowers.find((c) => c.name === power); + + if (createdPower) { + await actor.createEmbeddedDocuments("Item", [createdPower.data], {displaySheet: false}); + } + } + } + + static async addItems(items, actor) { + const weapons = await game.packs.get("sw5e.weapons").getDocuments(); + const armors = await game.packs.get("sw5e.armor").getDocuments(); + const adventuringGear = await game.packs.get("sw5e.adventuringgear").getDocuments(); + + for (const item of items) { + const createdItem = + weapons.find((c) => c.name.toLowerCase() === item.name.toLowerCase()) || + armors.find((c) => c.name.toLowerCase() === item.name.toLowerCase()) || + adventuringGear.find((c) => c.name.toLowerCase() === item.name.toLowerCase()); + + if (createdItem) { + if (item.quantity != 1) { + createdItem.data.data.quantity = item.quantity; + } + + await actor.createEmbeddedDocuments("Item", [createdItem.data], {displaySheet: false}); + } + } + } + + static addImportButton(html) { + const actionButtons = html.find(".header-actions"); + actionButtons[0].insertAdjacentHTML( + "afterend", + `
` + ); + + let characterImportButton = $(".cs-import-button"); + characterImportButton.click(() => { + let content = `

Saved Character JSON Import


`; - let importDialog = new Dialog({ - title: "Import Character from SW5e.com", - content: content, - buttons: { - Import: { - icon: ``, - label: "Import Character", - callback: () => { - let characterData = $("#character-json").val(); - console.log("Parsing Character JSON"); - CharacterImporter.transform(characterData); - } - }, - Cancel: { - icon: ``, - label: "Cancel", - callback: () => {} - } - } - }); - importDialog.render(true); - }); - } + let importDialog = new Dialog({ + title: "Import Character from SW5e.com", + content: content, + buttons: { + Import: { + icon: ``, + label: "Import Character", + callback: () => { + let characterData = $("#character-json").val(); + console.log("Parsing Character JSON"); + CharacterImporter.transform(characterData); + } + }, + Cancel: { + icon: ``, + label: "Cancel", + callback: () => {} + } + } + }); + importDialog.render(true); + }); + } } diff --git a/module/chat.js b/module/chat.js index d024d8aa..42d2bf4f 100644 --- a/module/chat.js +++ b/module/chat.js @@ -1,30 +1,29 @@ - /** * Highlight critical success or failure on d20 rolls */ -export const highlightCriticalSuccessFailure = function(message, html, data) { - if ( !message.isRoll || !message.isContentVisible ) return; +export const highlightCriticalSuccessFailure = function (message, html, data) { + if (!message.isRoll || !message.isContentVisible) return; - // Highlight rolls where the first part is a d20 roll - const roll = message.roll; - if ( !roll.dice.length ) return; - const d = roll.dice[0]; + // Highlight rolls where the first part is a d20 roll + const roll = message.roll; + if (!roll.dice.length) return; + const d = roll.dice[0]; - // Ensure it is an un-modified d20 roll - const isD20 = (d.faces === 20) && ( d.values.length === 1 ); - if ( !isD20 ) return; - const isModifiedRoll = ("success" in d.results[0]) || d.options.marginSuccess || d.options.marginFailure; - if ( isModifiedRoll ) return; + // Ensure it is an un-modified d20 roll + const isD20 = d.faces === 20 && d.values.length === 1; + if (!isD20) return; + const isModifiedRoll = "success" in d.results[0] || d.options.marginSuccess || d.options.marginFailure; + if (isModifiedRoll) return; - // Highlight successes and failures - const critical = d.options.critical || 20; - const fumble = d.options.fumble || 1; - if ( d.total >= critical ) html.find(".dice-total").addClass("critical"); - else if ( d.total <= fumble ) html.find(".dice-total").addClass("fumble"); - else if ( d.options.target ) { - if ( roll.total >= d.options.target ) html.find(".dice-total").addClass("success"); - else html.find(".dice-total").addClass("failure"); - } + // Highlight successes and failures + const critical = d.options.critical || 20; + const fumble = d.options.fumble || 1; + if (d.total >= critical) html.find(".dice-total").addClass("critical"); + else if (d.total <= fumble) html.find(".dice-total").addClass("fumble"); + else if (d.options.target) { + if (roll.total >= d.options.target) html.find(".dice-total").addClass("success"); + else html.find(".dice-total").addClass("failure"); + } }; /* -------------------------------------------- */ @@ -32,24 +31,24 @@ export const highlightCriticalSuccessFailure = function(message, html, data) { /** * Optionally hide the display of chat card action buttons which cannot be performed by the user */ -export const displayChatActionButtons = function(message, html, data) { - const chatCard = html.find(".sw5e.chat-card"); - if ( chatCard.length > 0 ) { - const flavor = html.find(".flavor-text"); - if ( flavor.text() === html.find(".item-name").text() ) flavor.remove(); +export const displayChatActionButtons = function (message, html, data) { + const chatCard = html.find(".sw5e.chat-card"); + if (chatCard.length > 0) { + const flavor = html.find(".flavor-text"); + if (flavor.text() === html.find(".item-name").text()) flavor.remove(); - // If the user is the message author or the actor owner, proceed - let actor = game.actors.get(data.message.speaker.actor); - if ( actor && actor.isOwner ) return; - else if ( game.user.isGM || (data.author.id === game.user.id)) return; + // If the user is the message author or the actor owner, proceed + let actor = game.actors.get(data.message.speaker.actor); + if (actor && actor.isOwner) return; + else if (game.user.isGM || data.author.id === game.user.id) return; - // Otherwise conceal action buttons except for saving throw - const buttons = chatCard.find("button[data-action]"); - buttons.each((i, btn) => { - if ( btn.dataset.action === "save" ) return; - btn.style.display = "none" - }); - } + // Otherwise conceal action buttons except for saving throw + const buttons = chatCard.find("button[data-action]"); + buttons.each((i, btn) => { + if (btn.dataset.action === "save") return; + btn.style.display = "none"; + }); + } }; /* -------------------------------------------- */ @@ -63,38 +62,38 @@ export const displayChatActionButtons = function(message, html, data) { * * @return {Array} The extended options Array including new context choices */ -export const addChatMessageContextOptions = function(html, options) { - let canApply = li => { - const message = game.messages.get(li.data("messageId")); - return message?.isRoll && message?.isContentVisible && canvas.tokens?.controlled.length; - }; - options.push( - { - name: game.i18n.localize("SW5E.ChatContextDamage"), - icon: '', - condition: canApply, - callback: li => applyChatCardDamage(li, 1) - }, - { - name: game.i18n.localize("SW5E.ChatContextHealing"), - icon: '', - condition: canApply, - callback: li => applyChatCardDamage(li, -1) - }, - { - name: game.i18n.localize("SW5E.ChatContextDoubleDamage"), - icon: '', - condition: canApply, - callback: li => applyChatCardDamage(li, 2) - }, - { - name: game.i18n.localize("SW5E.ChatContextHalfDamage"), - icon: '', - condition: canApply, - callback: li => applyChatCardDamage(li, 0.5) - } - ); - return options; +export const addChatMessageContextOptions = function (html, options) { + let canApply = (li) => { + const message = game.messages.get(li.data("messageId")); + return message?.isRoll && message?.isContentVisible && canvas.tokens?.controlled.length; + }; + options.push( + { + name: game.i18n.localize("SW5E.ChatContextDamage"), + icon: '', + condition: canApply, + callback: (li) => applyChatCardDamage(li, 1) + }, + { + name: game.i18n.localize("SW5E.ChatContextHealing"), + icon: '', + condition: canApply, + callback: (li) => applyChatCardDamage(li, -1) + }, + { + name: game.i18n.localize("SW5E.ChatContextDoubleDamage"), + icon: '', + condition: canApply, + callback: (li) => applyChatCardDamage(li, 2) + }, + { + name: game.i18n.localize("SW5E.ChatContextHalfDamage"), + icon: '', + condition: canApply, + callback: (li) => applyChatCardDamage(li, 0.5) + } + ); + return options; }; /* -------------------------------------------- */ @@ -108,12 +107,14 @@ export const addChatMessageContextOptions = function(html, options) { * @return {Promise} */ function applyChatCardDamage(li, multiplier) { - const message = game.messages.get(li.data("messageId")); - const roll = message.roll; - return Promise.all(canvas.tokens.controlled.map(t => { - const a = t.actor; - return a.applyDamage(roll.total, multiplier); - })); + const message = game.messages.get(li.data("messageId")); + const roll = message.roll; + return Promise.all( + canvas.tokens.controlled.map((t) => { + const a = t.actor; + return a.applyDamage(roll.total, multiplier); + }) + ); } /* -------------------------------------------- */ diff --git a/module/classFeatures.js b/module/classFeatures.js index 7946c252..17155d10 100644 --- a/module/classFeatures.js +++ b/module/classFeatures.js @@ -1,4 +1 @@ -export const ClassFeatures = { - -}; - +export const ClassFeatures = {}; diff --git a/module/combat.js b/module/combat.js index 58535411..ff7594a5 100644 --- a/module/combat.js +++ b/module/combat.js @@ -1,27 +1,31 @@ - /** * Override the default Initiative formula to customize special behaviors of the SW5e system. * Apply advantage, proficiency, or bonuses where appropriate * Apply the dexterity score as a decimal tiebreaker if requested * See Combat._getInitiativeFormula for more detail. */ -export const _getInitiativeFormula = function() { - const actor = this.actor; - if ( !actor ) return "1d20"; - const init = actor.data.data.attributes.init; +export const _getInitiativeFormula = function () { + const actor = this.actor; + if (!actor) return "1d20"; + const init = actor.data.data.attributes.init; - // Construct initiative formula parts - let nd = 1; - let mods = ""; - if (actor.getFlag("sw5e", "halflingLucky")) mods += "r1=1"; - if (actor.getFlag("sw5e", "initiativeAdv")) { - nd = 2; - mods += "kh"; - } - const parts = [`${nd}d20${mods}`, init.mod, (init.prof !== 0) ? init.prof : null, (init.bonus !== 0) ? init.bonus : null]; + // Construct initiative formula parts + let nd = 1; + let mods = ""; + if (actor.getFlag("sw5e", "halflingLucky")) mods += "r1=1"; + if (actor.getFlag("sw5e", "initiativeAdv")) { + nd = 2; + mods += "kh"; + } + const parts = [ + `${nd}d20${mods}`, + init.mod, + init.prof !== 0 ? init.prof : null, + init.bonus !== 0 ? init.bonus : null + ]; - // Optionally apply Dexterity tiebreaker - const tiebreaker = game.settings.get("sw5e", "initiativeDexTiebreaker"); - if ( tiebreaker ) parts.push(actor.data.data.abilities.dex.value / 100); - return parts.filter(p => p !== null).join(" + "); + // Optionally apply Dexterity tiebreaker + const tiebreaker = game.settings.get("sw5e", "initiativeDexTiebreaker"); + if (tiebreaker) parts.push(actor.data.data.abilities.dex.value / 100); + return parts.filter((p) => p !== null).join(" + "); }; diff --git a/module/config.js b/module/config.js index d0991cb5..1770bcea 100644 --- a/module/config.js +++ b/module/config.js @@ -1,4 +1,4 @@ -import {ClassFeatures} from "./classFeatures.js" +import {ClassFeatures} from "./classFeatures.js"; // Namespace SW5e Configuration Values export const SW5E = {}; @@ -12,27 +12,26 @@ SW5E.ASCII = ` \\______ / \\__/\\ //______ /\\__ > \\/ \\/ \\/ \\/ `; - /** * The set of Ability Scores used within the system * @type {Object} */ SW5E.abilities = { - "str": "SW5E.AbilityStr", - "dex": "SW5E.AbilityDex", - "con": "SW5E.AbilityCon", - "int": "SW5E.AbilityInt", - "wis": "SW5E.AbilityWis", - "cha": "SW5E.AbilityCha" + str: "SW5E.AbilityStr", + dex: "SW5E.AbilityDex", + con: "SW5E.AbilityCon", + int: "SW5E.AbilityInt", + wis: "SW5E.AbilityWis", + cha: "SW5E.AbilityCha" }; SW5E.abilityAbbreviations = { - "str": "SW5E.AbilityStrAbbr", - "dex": "SW5E.AbilityDexAbbr", - "con": "SW5E.AbilityConAbbr", - "int": "SW5E.AbilityIntAbbr", - "wis": "SW5E.AbilityWisAbbr", - "cha": "SW5E.AbilityChaAbbr" + str: "SW5E.AbilityStrAbbr", + dex: "SW5E.AbilityDexAbbr", + con: "SW5E.AbilityConAbbr", + int: "SW5E.AbilityIntAbbr", + wis: "SW5E.AbilityWisAbbr", + cha: "SW5E.AbilityChaAbbr" }; /* -------------------------------------------- */ @@ -42,15 +41,15 @@ SW5E.abilityAbbreviations = { * @type {Object} */ SW5E.alignments = { - 'll': "SW5E.AlignmentLL", - 'nl': "SW5E.AlignmentNL", - 'cl': "SW5E.AlignmentCL", - 'lb': "SW5E.AlignmentLB", - 'bn': "SW5E.AlignmentBN", - 'cb': "SW5E.AlignmentCB", - 'ld': "SW5E.AlignmentLD", - 'nd': "SW5E.AlignmentND", - 'cd': "SW5E.AlignmentCD" + ll: "SW5E.AlignmentLL", + nl: "SW5E.AlignmentNL", + cl: "SW5E.AlignmentCL", + lb: "SW5E.AlignmentLB", + bn: "SW5E.AlignmentBN", + cb: "SW5E.AlignmentCB", + ld: "SW5E.AlignmentLD", + nd: "SW5E.AlignmentND", + cd: "SW5E.AlignmentCD" }; /* -------------------------------------------- */ @@ -60,9 +59,9 @@ SW5E.alignments = { * @enum {number} */ SW5E.attunementTypes = { - NONE: 0, - REQUIRED: 1, - ATTUNED: 2, + NONE: 0, + REQUIRED: 1, + ATTUNED: 2 }; /** @@ -70,37 +69,37 @@ SW5E.attunementTypes = { * @type {{"0": string, "1": string, "2": string}} */ SW5E.attunements = { - 0: "SW5E.AttunementNone", - 1: "SW5E.AttunementRequired", - 2: "SW5E.AttunementAttuned" + 0: "SW5E.AttunementNone", + 1: "SW5E.AttunementRequired", + 2: "SW5E.AttunementAttuned" }; /* -------------------------------------------- */ SW5E.weaponProficiencies = { - "blp": "SW5E.WeaponBlasterPistolProficiency", - "chk": "SW5E.WeaponChakramProficiency", - "dbb": "SW5E.WeaponDoubleBladeProficiency", - "dbs": "SW5E.WeaponDoubleSaberProficiency", - "dsh": "SW5E.WeaponDoubleShotoProficiency", - "dsw": "SW5E.WeaponDoubleSwordProficiency", - "hid": "SW5E.WeaponHiddenBladeProficiency", - "imp": "SW5E.WeaponImprovisedProficiency", - "lfl": "SW5E.WeaponLightFoilProficiency", - "lrg": "SW5E.WeaponLightRingProficiency", - "mar": "SW5E.WeaponMartialProficiency", - "mrb": "SW5E.WeaponMartialBlasterProficiency", - "mlw": "SW5E.WeaponMartialLightweaponProficiency", - "mvb": "SW5E.WeaponMartialVibroweaponProficiency", - "ntl": "SW5E.WeaponNaturalProficiency", - "swh": "SW5E.WeaponSaberWhipProficiency", - "sim": "SW5E.WeaponSimpleProficiency", - "smb": "SW5E.WeaponSimpleBlasterProficiency", - "slw": "SW5E.WeaponSimpleLightweaponProficiency", - "svb": "SW5E.WeaponSimpleVibroweaponProficiency", - "tch": "SW5E.WeaponTechbladeProficiency", - "vbr": "SW5E.WeaponVibrorapierProficiency", - "vbw": "SW5E.WeaponVibrowhipProficiency" + blp: "SW5E.WeaponBlasterPistolProficiency", + chk: "SW5E.WeaponChakramProficiency", + dbb: "SW5E.WeaponDoubleBladeProficiency", + dbs: "SW5E.WeaponDoubleSaberProficiency", + dsh: "SW5E.WeaponDoubleShotoProficiency", + dsw: "SW5E.WeaponDoubleSwordProficiency", + hid: "SW5E.WeaponHiddenBladeProficiency", + imp: "SW5E.WeaponImprovisedProficiency", + lfl: "SW5E.WeaponLightFoilProficiency", + lrg: "SW5E.WeaponLightRingProficiency", + mar: "SW5E.WeaponMartialProficiency", + mrb: "SW5E.WeaponMartialBlasterProficiency", + mlw: "SW5E.WeaponMartialLightweaponProficiency", + mvb: "SW5E.WeaponMartialVibroweaponProficiency", + ntl: "SW5E.WeaponNaturalProficiency", + swh: "SW5E.WeaponSaberWhipProficiency", + sim: "SW5E.WeaponSimpleProficiency", + smb: "SW5E.WeaponSimpleBlasterProficiency", + slw: "SW5E.WeaponSimpleLightweaponProficiency", + svb: "SW5E.WeaponSimpleVibroweaponProficiency", + tch: "SW5E.WeaponTechbladeProficiency", + vbr: "SW5E.WeaponVibrorapierProficiency", + vbw: "SW5E.WeaponVibrowhipProficiency" }; /** @@ -108,14 +107,14 @@ SW5E.weaponProficiencies = { * Used when a new player owned item is created * @type {Object} */ - SW5E.weaponProficienciesMap = { - "natural": true, - "simpleVW": "sim", - "simpleB": "sim", - "simpleLW": "sim", - "martialVW": "mar", - "martialB": "mar", - "martialLW": "mar" +SW5E.weaponProficienciesMap = { + natural: true, + simpleVW: "sim", + simpleB: "sim", + simpleLW: "sim", + martialVW: "mar", + martialB: "mar", + martialLW: "mar" }; // TODO: Check to see if this can be used @@ -172,36 +171,36 @@ SW5E.weaponIds = { /* -------------------------------------------- */ SW5E.toolProficiencies = { - "armor": "SW5E.ToolArmormech", - "arms": "SW5E.ToolArmstech", - "arti": "SW5E.ToolArtificer", - "art": "SW5E.ToolArtist", - "astro": "SW5E.ToolAstrotech", - "bio": "SW5E.ToolBiotech", - "con": "SW5E.ToolConstructor", - "cyb": "SW5E.ToolCybertech", - "jew": "SW5E.ToolJeweler", - "sur": "SW5E.ToolSurveyor", - "syn": "SW5E.ToolSynthweaver", - "tin": "SW5E.ToolTinker", - "ant": "SW5E.ToolAntitoxkit", - "arc": "SW5E.ToolArchaeologistKit", - "aud": "SW5E.ToolAudiotechKit", - "bioa": "SW5E.ToolBioanalysisKit", - "brew": "SW5E.ToolBrewerKit", - "chef": "SW5E.ToolChefKit", - "demo": "SW5E.ToolDemolitionKit", - "disg": "SW5E.ToolDisguiseKit", - "forg": "SW5E.ToolForgeryKit", - "mech": "SW5E.ToolMechanicKit", - "game": "SW5E.ToolGamingSet", - "poi": "SW5E.ToolPoisonKit", - "scav": "SW5E.ToolScavengingKit", - "secur": "SW5E.ToolSecurityKit", - "slic": "SW5E.ToolSlicerKit", - "spice": "SW5E.ToolSpiceKit", - "music": "SW5E.ToolMusicalInstrument", - "vehicle": "SW5E.ToolVehicle" + armor: "SW5E.ToolArmormech", + arms: "SW5E.ToolArmstech", + arti: "SW5E.ToolArtificer", + art: "SW5E.ToolArtist", + astro: "SW5E.ToolAstrotech", + bio: "SW5E.ToolBiotech", + con: "SW5E.ToolConstructor", + cyb: "SW5E.ToolCybertech", + jew: "SW5E.ToolJeweler", + sur: "SW5E.ToolSurveyor", + syn: "SW5E.ToolSynthweaver", + tin: "SW5E.ToolTinker", + ant: "SW5E.ToolAntitoxkit", + arc: "SW5E.ToolArchaeologistKit", + aud: "SW5E.ToolAudiotechKit", + bioa: "SW5E.ToolBioanalysisKit", + brew: "SW5E.ToolBrewerKit", + chef: "SW5E.ToolChefKit", + demo: "SW5E.ToolDemolitionKit", + disg: "SW5E.ToolDisguiseKit", + forg: "SW5E.ToolForgeryKit", + mech: "SW5E.ToolMechanicKit", + game: "SW5E.ToolGamingSet", + poi: "SW5E.ToolPoisonKit", + scav: "SW5E.ToolScavengingKit", + secur: "SW5E.ToolSecurityKit", + slic: "SW5E.ToolSlicerKit", + spice: "SW5E.ToolSpiceKit", + music: "SW5E.ToolMusicalInstrument", + vehicle: "SW5E.ToolVehicle" }; // TODO: Same as weapon IDs @@ -257,19 +256,18 @@ SW5E.toolIds = { * @type {Object} */ SW5E.timePeriods = { - "inst": "SW5E.TimeInst", - "turn": "SW5E.TimeTurn", - "round": "SW5E.TimeRound", - "minute": "SW5E.TimeMinute", - "hour": "SW5E.TimeHour", - "day": "SW5E.TimeDay", - "month": "SW5E.TimeMonth", - "year": "SW5E.TimeYear", - "perm": "SW5E.TimePerm", - "spec": "SW5E.Special" + inst: "SW5E.TimeInst", + turn: "SW5E.TimeTurn", + round: "SW5E.TimeRound", + minute: "SW5E.TimeMinute", + hour: "SW5E.TimeHour", + day: "SW5E.TimeDay", + month: "SW5E.TimeMonth", + year: "SW5E.TimeYear", + perm: "SW5E.TimePerm", + spec: "SW5E.Special" }; - /* -------------------------------------------- */ /** @@ -277,49 +275,47 @@ SW5E.timePeriods = { * @type {Object} */ SW5E.abilityActivationTypes = { - "none": "SW5E.None", - "action": "SW5E.Action", - "bonus": "SW5E.BonusAction", - "reaction": "SW5E.Reaction", - "minute": SW5E.timePeriods.minute, - "hour": SW5E.timePeriods.hour, - "day": SW5E.timePeriods.day, - "special": SW5E.timePeriods.spec, - "legendary": "SW5E.LegendaryActionLabel", - "lair": "SW5E.LairActionLabel", - "crew": "SW5E.VehicleCrewAction" + none: "SW5E.None", + action: "SW5E.Action", + bonus: "SW5E.BonusAction", + reaction: "SW5E.Reaction", + minute: SW5E.timePeriods.minute, + hour: SW5E.timePeriods.hour, + day: SW5E.timePeriods.day, + special: SW5E.timePeriods.spec, + legendary: "SW5E.LegendaryActionLabel", + lair: "SW5E.LairActionLabel", + crew: "SW5E.VehicleCrewAction" }; /* -------------------------------------------- */ - SW5E.abilityConsumptionTypes = { - "ammo": "SW5E.ConsumeAmmunition", - "attribute": "SW5E.ConsumeAttribute", - "material": "SW5E.ConsumeMaterial", - "charges": "SW5E.ConsumeCharges" + ammo: "SW5E.ConsumeAmmunition", + attribute: "SW5E.ConsumeAttribute", + material: "SW5E.ConsumeMaterial", + charges: "SW5E.ConsumeCharges" }; - /* -------------------------------------------- */ // Creature Sizes SW5E.actorSizes = { - "tiny": "SW5E.SizeTiny", - "sm": "SW5E.SizeSmall", - "med": "SW5E.SizeMedium", - "lg": "SW5E.SizeLarge", - "huge": "SW5E.SizeHuge", - "grg": "SW5E.SizeGargantuan" + tiny: "SW5E.SizeTiny", + sm: "SW5E.SizeSmall", + med: "SW5E.SizeMedium", + lg: "SW5E.SizeLarge", + huge: "SW5E.SizeHuge", + grg: "SW5E.SizeGargantuan" }; SW5E.tokenSizes = { - "tiny": 1, - "sm": 1, - "med": 1, - "lg": 2, - "huge": 3, - "grg": 4 + tiny: 1, + sm: 1, + med: 1, + lg: 2, + huge: 3, + grg: 4 }; /** @@ -327,10 +323,10 @@ SW5E.tokenSizes = { * @enum {number} */ SW5E.tokenHPColors = { - temp: 0x66CCFF, - tempmax: 0x440066, - negmax: 0x550000 -} + temp: 0x66ccff, + tempmax: 0x440066, + negmax: 0x550000 +}; /* -------------------------------------------- */ @@ -339,17 +335,16 @@ SW5E.tokenHPColors = { * @type {Object} */ SW5E.creatureTypes = { - "aberration": "SW5E.CreatureAberration", - "beast": "SW5E.CreatureBeast", - "construct": "SW5E.CreatureConstruct", - "droid": "SW5E.CreatureDroid", - "force": "SW5E.CreatureForceEntity", - "humanoid": "SW5E.CreatureHumanoid", - "plant": "SW5E.CreaturePlant", - "undead": "SW5E.CreatureUndead" + aberration: "SW5E.CreatureAberration", + beast: "SW5E.CreatureBeast", + construct: "SW5E.CreatureConstruct", + droid: "SW5E.CreatureDroid", + force: "SW5E.CreatureForceEntity", + humanoid: "SW5E.CreatureHumanoid", + plant: "SW5E.CreaturePlant", + undead: "SW5E.CreatureUndead" }; - /* -------------------------------------------- */ /** @@ -357,22 +352,22 @@ SW5E.creatureTypes = { * @type {Object} */ SW5E.itemActionTypes = { - "mwak": "SW5E.ActionMWAK", - "rwak": "SW5E.ActionRWAK", - "mpak": "SW5E.ActionMPAK", - "rpak": "SW5E.ActionRPAK", - "save": "SW5E.ActionSave", - "heal": "SW5E.ActionHeal", - "abil": "SW5E.ActionAbil", - "util": "SW5E.ActionUtil", - "other": "SW5E.ActionOther" + mwak: "SW5E.ActionMWAK", + rwak: "SW5E.ActionRWAK", + mpak: "SW5E.ActionMPAK", + rpak: "SW5E.ActionRPAK", + save: "SW5E.ActionSave", + heal: "SW5E.ActionHeal", + abil: "SW5E.ActionAbil", + util: "SW5E.ActionUtil", + other: "SW5E.ActionOther" }; /* -------------------------------------------- */ SW5E.itemCapacityTypes = { - "items": "SW5E.ItemContainerCapacityItems", - "weight": "SW5E.ItemContainerCapacityWeight" + items: "SW5E.ItemContainerCapacityItems", + weight: "SW5E.ItemContainerCapacityWeight" }; /* -------------------------------------------- */ @@ -382,15 +377,14 @@ SW5E.itemCapacityTypes = { * @type {Object} */ SW5E.limitedUsePeriods = { - "sr": "SW5E.ShortRest", - "lr": "SW5E.LongRest", - "day": "SW5E.Day", - "charges": "SW5E.Charges", - "recharge": "SW5E.Recharge", - "refitting": "SW5E.Refitting" + sr: "SW5E.ShortRest", + lr: "SW5E.LongRest", + day: "SW5E.Day", + charges: "SW5E.Charges", + recharge: "SW5E.Recharge", + refitting: "SW5E.Refitting" }; - /* -------------------------------------------- */ /** @@ -398,23 +392,22 @@ SW5E.limitedUsePeriods = { * @type {Object} */ SW5E.equipmentTypes = { - "light": "SW5E.EquipmentLight", - "medium": "SW5E.EquipmentMedium", - "heavy": "SW5E.EquipmentHeavy", - "hyper": "SW5E.EquipmentHyperdrive", - "bonus": "SW5E.EquipmentBonus", - "natural": "SW5E.EquipmentNatural", - "powerc": "SW5E.EquipmentPowerCoupling", - "reactor": "SW5E.EquipmentReactor", - "shield": "SW5E.EquipmentShield", - "clothing": "SW5E.EquipmentClothing", - "trinket": "SW5E.EquipmentTrinket", - "ssarmor": "SW5E.EquipmentStarshipArmor", - "ssshield": "SW5E.EquipmentStarshipShield", - "vehicle": "SW5E.EquipmentVehicle" + light: "SW5E.EquipmentLight", + medium: "SW5E.EquipmentMedium", + heavy: "SW5E.EquipmentHeavy", + hyper: "SW5E.EquipmentHyperdrive", + bonus: "SW5E.EquipmentBonus", + natural: "SW5E.EquipmentNatural", + powerc: "SW5E.EquipmentPowerCoupling", + reactor: "SW5E.EquipmentReactor", + shield: "SW5E.EquipmentShield", + clothing: "SW5E.EquipmentClothing", + trinket: "SW5E.EquipmentTrinket", + ssarmor: "SW5E.EquipmentStarshipArmor", + ssshield: "SW5E.EquipmentStarshipShield", + vehicle: "SW5E.EquipmentVehicle" }; - /* -------------------------------------------- */ /** @@ -422,10 +415,10 @@ SW5E.equipmentTypes = { * @type {Object} */ SW5E.armorProficiencies = { - "lgt": SW5E.equipmentTypes.light, - "med": SW5E.equipmentTypes.medium, - "hvy": SW5E.equipmentTypes.heavy, - "shl": "SW5E.EquipmentShieldProficiency" + lgt: SW5E.equipmentTypes.light, + med: SW5E.equipmentTypes.medium, + hvy: SW5E.equipmentTypes.heavy, + shl: "SW5E.EquipmentShieldProficiency" }; /** @@ -433,14 +426,14 @@ SW5E.armorProficiencies = { * Used when a new player owned item is created * @type {Object} */ - SW5E.armorProficienciesMap = { - "natural": true, - "clothing": true, - "light": "lgt", - "medium": "med", - "heavy": "hvy", - "shield": "shl" -} +SW5E.armorProficienciesMap = { + natural: true, + clothing: true, + light: "lgt", + medium: "med", + heavy: "hvy", + shield: "shl" +}; /* -------------------------------------------- */ @@ -449,16 +442,16 @@ SW5E.armorProficiencies = { * @type {Object} */ SW5E.consumableTypes = { - "adrenal": "SW5E.ConsumableAdrenal", - "poison": "SW5E.ConsumablePoison", - "explosive": "SW5E.ConsumableExplosive", - "food": "SW5E.ConsumableFood", - "medpac": "SW5E.ConsumableMedpac", - "technology": "SW5E.ConsumableTechnology", - "ammo": "SW5E.ConsumableAmmunition", - "trinket": "SW5E.ConsumableTrinket", - "force": "SW5E.ConsumableForce", - "tech": "SW5E.ConsumableTech" + adrenal: "SW5E.ConsumableAdrenal", + poison: "SW5E.ConsumablePoison", + explosive: "SW5E.ConsumableExplosive", + food: "SW5E.ConsumableFood", + medpac: "SW5E.ConsumableMedpac", + technology: "SW5E.ConsumableTechnology", + ammo: "SW5E.ConsumableAmmunition", + trinket: "SW5E.ConsumableTrinket", + force: "SW5E.ConsumableForce", + tech: "SW5E.ConsumableTech" }; /* -------------------------------------------- */ @@ -468,26 +461,25 @@ SW5E.consumableTypes = { * @type {Object} */ SW5E.currencies = { - "CR": "SW5E.CurrencyCR", - }; + CR: "SW5E.CurrencyCR" +}; /* -------------------------------------------- */ - // Damage Types SW5E.damageTypes = { - "acid": "SW5E.DamageAcid", - "cold": "SW5E.DamageCold", - "energy": "SW5E.DamageEnergy", - "fire": "SW5E.DamageFire", - "force": "SW5E.DamageForce", - "ion": "SW5E.DamageIon", - "kinetic": "SW5E.DamageKinetic", - "lightning": "SW5E.DamageLightning", - "necrotic": "SW5E.DamageNecrotic", - "poison": "SW5E.DamagePoison", - "psychic": "SW5E.DamagePsychic", - "sonic": "SW5E.DamageSonic" + acid: "SW5E.DamageAcid", + cold: "SW5E.DamageCold", + energy: "SW5E.DamageEnergy", + fire: "SW5E.DamageFire", + force: "SW5E.DamageForce", + ion: "SW5E.DamageIon", + kinetic: "SW5E.DamageKinetic", + lightning: "SW5E.DamageLightning", + necrotic: "SW5E.DamageNecrotic", + poison: "SW5E.DamagePoison", + psychic: "SW5E.DamagePsychic", + sonic: "SW5E.DamageSonic" }; // Damage Resistance Types @@ -495,39 +487,38 @@ SW5E.damageResistanceTypes = foundry.utils.deepClone(SW5E.damageTypes); /* -------------------------------------------- */ - // armor Types SW5E.armorPropertiesTypes = { -"Absorptive": "SW5E.ArmorProperAbsorptive", -"Agile": "SW5E.ArmorProperAgile", -"Anchor": "SW5E.ArmorProperAnchor", -"Avoidant": "SW5E.ArmorProperAvoidant", -"Barbed": "SW5E.ArmorProperBarbed", -"Bulky": "SW5E.ArmorProperBulky", -"Charging": "SW5E.ArmorProperCharging", -"Concealing": "SW5E.ArmorProperConcealing", -"Cumbersome": "SW5E.ArmorProperCumbersome", -"Gauntleted": "SW5E.ArmorProperGauntleted", -"Imbalanced": "SW5E.ArmorProperImbalanced", -"Impermeable": "SW5E.ArmorProperImpermeable", -"Insulated": "SW5E.ArmorProperInsulated", -"Interlocking": "SW5E.ArmorProperInterlocking", -"Lambent": "SW5E.ArmorProperLambent", -"Lightweight": "SW5E.ArmorProperLightweight", -"Magnetic": "SW5E.ArmorProperMagnetic", -"Obscured": "SW5E.ArmorProperObscured", -"Obtrusive": "SW5E.ArmorProperObtrusive", -"Powered": "SW5E.ArmorProperPowered", -"Reactive": "SW5E.ArmorProperReactive", -"Regulated": "SW5E.ArmorProperRegulated", -"Reinforced": "SW5E.ArmorProperReinforced", -"Responsive": "SW5E.ArmorProperResponsive", -"Rigid": "SW5E.ArmorProperRigid", -"Silent": "SW5E.ArmorProperSilent", -"Spiked": "SW5E.ArmorProperSpiked", -"Strength": "SW5E.ArmorProperStrength", -"Steadfast": "SW5E.ArmorProperSteadfast", -"Versatile": "SW5E.ArmorProperVersatile" + Absorptive: "SW5E.ArmorProperAbsorptive", + Agile: "SW5E.ArmorProperAgile", + Anchor: "SW5E.ArmorProperAnchor", + Avoidant: "SW5E.ArmorProperAvoidant", + Barbed: "SW5E.ArmorProperBarbed", + Bulky: "SW5E.ArmorProperBulky", + Charging: "SW5E.ArmorProperCharging", + Concealing: "SW5E.ArmorProperConcealing", + Cumbersome: "SW5E.ArmorProperCumbersome", + Gauntleted: "SW5E.ArmorProperGauntleted", + Imbalanced: "SW5E.ArmorProperImbalanced", + Impermeable: "SW5E.ArmorProperImpermeable", + Insulated: "SW5E.ArmorProperInsulated", + Interlocking: "SW5E.ArmorProperInterlocking", + Lambent: "SW5E.ArmorProperLambent", + Lightweight: "SW5E.ArmorProperLightweight", + Magnetic: "SW5E.ArmorProperMagnetic", + Obscured: "SW5E.ArmorProperObscured", + Obtrusive: "SW5E.ArmorProperObtrusive", + Powered: "SW5E.ArmorProperPowered", + Reactive: "SW5E.ArmorProperReactive", + Regulated: "SW5E.ArmorProperRegulated", + Reinforced: "SW5E.ArmorProperReinforced", + Responsive: "SW5E.ArmorProperResponsive", + Rigid: "SW5E.ArmorProperRigid", + Silent: "SW5E.ArmorProperSilent", + Spiked: "SW5E.ArmorProperSpiked", + Strength: "SW5E.ArmorProperStrength", + Steadfast: "SW5E.ArmorProperSteadfast", + Versatile: "SW5E.ArmorProperVersatile" }; /** @@ -536,15 +527,15 @@ SW5E.armorPropertiesTypes = { * @type {Object} */ SW5E.movementTypes = { - "burrow": "SW5E.MovementBurrow", - "climb": "SW5E.MovementClimb", - "crawl": "SW5E.MovementCrawl", - "fly": "SW5E.MovementFly", - "roll": "SW5E.MovementRoll", - "space": "SW5E.MovementSpace", - "swim": "SW5E.MovementSwim", - "turn": "SW5E.MovementTurn", - "walk": "SW5E.MovementWalk", + burrow: "SW5E.MovementBurrow", + climb: "SW5E.MovementClimb", + crawl: "SW5E.MovementCrawl", + fly: "SW5E.MovementFly", + roll: "SW5E.MovementRoll", + space: "SW5E.MovementSpace", + swim: "SW5E.MovementSwim", + turn: "SW5E.MovementTurn", + walk: "SW5E.MovementWalk" }; /** @@ -553,8 +544,8 @@ SW5E.movementTypes = { * @type {Object} */ SW5E.movementUnits = { - "ft": "SW5E.DistFt", - "mi": "SW5E.DistMi" + ft: "SW5E.DistFt", + mi: "SW5E.DistMi" }; /** @@ -563,27 +554,26 @@ SW5E.movementUnits = { * @type {Object} */ SW5E.distanceUnits = { - "none": "SW5E.None", - "self": "SW5E.DistSelf", - "touch": "SW5E.DistTouch", - "spec": "SW5E.Special", - "any": "SW5E.DistAny" + none: "SW5E.None", + self: "SW5E.DistSelf", + touch: "SW5E.DistTouch", + spec: "SW5E.Special", + any: "SW5E.DistAny" }; -for ( let [k, v] of Object.entries(SW5E.movementUnits) ) { - SW5E.distanceUnits[k] = v; +for (let [k, v] of Object.entries(SW5E.movementUnits)) { + SW5E.distanceUnits[k] = v; } /* -------------------------------------------- */ - /** * Configure aspects of encumbrance calculation so that it could be configured by modules * @type {Object} */ SW5E.encumbrance = { - currencyPerWeight: 50, - strMultiplier: 15, - vehicleWeightMultiplier: 2000 // 2000 lbs in a ton + currencyPerWeight: 50, + strMultiplier: 15, + vehicleWeightMultiplier: 2000 // 2000 lbs in a ton }; /* -------------------------------------------- */ @@ -593,59 +583,54 @@ SW5E.encumbrance = { * @type {Object} */ SW5E.targetTypes = { - "none": "SW5E.None", - "self": "SW5E.TargetSelf", - "creature": "SW5E.TargetCreature", - "droid": "SW5E.TargetDroid", - "ally": "SW5E.TargetAlly", - "enemy": "SW5E.TargetEnemy", - "object": "SW5E.TargetObject", - "space": "SW5E.TargetSpace", - "radius": "SW5E.TargetRadius", - "sphere": "SW5E.TargetSphere", - "cylinder": "SW5E.TargetCylinder", - "cone": "SW5E.TargetCone", - "square": "SW5E.TargetSquare", - "cube": "SW5E.TargetCube", - "line": "SW5E.TargetLine", - "starship": "SW5E.TargetStarship", - "wall": "SW5E.TargetWall", - "weapon": "SW5E.TargetWeapon" + none: "SW5E.None", + self: "SW5E.TargetSelf", + creature: "SW5E.TargetCreature", + droid: "SW5E.TargetDroid", + ally: "SW5E.TargetAlly", + enemy: "SW5E.TargetEnemy", + object: "SW5E.TargetObject", + space: "SW5E.TargetSpace", + radius: "SW5E.TargetRadius", + sphere: "SW5E.TargetSphere", + cylinder: "SW5E.TargetCylinder", + cone: "SW5E.TargetCone", + square: "SW5E.TargetSquare", + cube: "SW5E.TargetCube", + line: "SW5E.TargetLine", + starship: "SW5E.TargetStarship", + wall: "SW5E.TargetWall", + weapon: "SW5E.TargetWeapon" }; - /* -------------------------------------------- */ - /** * Map the subset of target types which produce a template area of effect * The keys are SW5E target types and the values are MeasuredTemplate shape types * @type {Object} */ SW5E.areaTargetTypes = { - cone: "cone", - cube: "rect", - cylinder: "circle", - line: "ray", - radius: "circle", - sphere: "circle", - square: "rect", - wall: "ray" + cone: "cone", + cube: "rect", + cylinder: "circle", + line: "ray", + radius: "circle", + sphere: "circle", + square: "rect", + wall: "ray" }; - /* -------------------------------------------- */ // Healing Types SW5E.healingTypes = { - "healing": "SW5E.Healing", - "temphp": "SW5E.HealingTemp" + healing: "SW5E.Healing", + temphp: "SW5E.HealingTemp" }; - /* -------------------------------------------- */ - /** * Enumerate the denominations of hit dice which can apply to classes in the SW5E system * @type {string[]} @@ -654,29 +639,120 @@ SW5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12", "d20"]; /* -------------------------------------------- */ - /** * Enumerate the denominations of power dice which can apply to starships in the SW5E system * @enum {string} */ SW5E.powerDieTypes = [1, "d4", "d6", "d8", "d10", "d12"]; - /* -------------------------------------------- */ /** * Enumerate the base stat and feature settings for starships based on size. - * @type {Array.} + * @type {Array.} */ SW5E.baseStarshipSettings = { - "tiny": {"changes":[{"key":"data.abilities.dex.value","value":4,"mode":2,"priority":20},{"key":"data.abilities.dex.proficient","value":1,"mode":4,"priority":20}, {"key":"data.abilities.con.value","value":-4,"mode":2,"priority":20}, {"key":"data.abilities.int.proficient","value":1,"mode":4,"priority":20}], "attributes":{"crewcap":null, "hd":"1d4", "hp":{"value":4, "max":4, "temp":4, "tempmax":4}, "hsm":1, "sd":"1d4", "mods":{"open":10, "max":10}, "suites":{"open":0, "max":0}, "movement":{"fly":300, "turn":300}}}, - "sm": {"changes":[{"key":"data.abilities.dex.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.dex.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":-2,"mode":2,"priority":20},{"key":"data.abilities.str.proficient","value":1,"mode":4,"priority":20}], "attributes":{"crewcap":1, "hd":"3d6", "hp":{"value":6, "max":6, "temp":6, "tempmax":6}, "hsm":2, "sd":"3d6", "mods":{"open":20, "max":20}, "suites":{"open":-1, "max":-1}, "movement":{"fly":300, "turn":250}}}, - "med": {"attributes":{"crewcap":1, "hd":"5d8", "hp":{"value":8, "max":8, "temp":8, "tempmax":8}, "hsm":3, "sd":"5d8", "mods":{"open":30, "max":30}, "suites":{"open":3, "max":3}, "movement":{"fly":300, "turn":200}}}, - "lg": {"changes":[{"key":"data.abilities.dex.value","value":-2,"mode":2,"priority":20},{"key":"data.abilities.wis.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":2,"mode":2,"priority":20}], "attributes":{"crewcap":200, "hd":"7d10", "hp":{"value":10, "max":10, "temp":10, "tempmax":10}, "hsm":4, "sd":"7d10", "mods":{"open":50, "max":50}, "suites":{"open":3, "max":3}, "movement":{"fly":300, "turn":150}}}, - "huge": {"changes":[{"key":"data.abilities.dex.value","value":-4,"mode":2,"priority":20},{"key":"data.abilities.wis.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":4,"mode":2,"priority":20}], "attributes":{"crewcap":4000, "hd":"9d12", "hp":{"value":12, "max":12, "temp":12, "tempmax":12}, "hsm":2, "sd":"9d12", "mods":{"open":60, "max":60}, "suites":{"open":6, "max":6}, "movement":{"fly":300, "turn":100}}}, - "grg": {"changes":[{"key":"data.abilities.dex.value","value":-6,"mode":2,"priority":20},{"key":"data.abilities.wis.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":6,"mode":2,"priority":20}], "attributes":{"crewcap":80000, "hd":"11d20", "hp":{"value":20, "max":20, "temp":20, "tempmax":20}, "hsm":3, "sd":"11d20", "mods":{"open":70, "max":70}, "suites":{"open":10, "max":10}, "movement":{"fly":300, "turn":50}}} -} + tiny: { + changes: [ + {key: "data.abilities.dex.value", value: 4, mode: 2, priority: 20}, + {key: "data.abilities.dex.proficient", value: 1, mode: 4, priority: 20}, + {key: "data.abilities.con.value", value: -4, mode: 2, priority: 20}, + {key: "data.abilities.int.proficient", value: 1, mode: 4, priority: 20} + ], + attributes: { + crewcap: null, + hd: "1d4", + hp: {value: 4, max: 4, temp: 4, tempmax: 4}, + hsm: 1, + sd: "1d4", + mods: {open: 10, max: 10}, + suites: {open: 0, max: 0}, + movement: {fly: 300, turn: 300} + } + }, + sm: { + changes: [ + {key: "data.abilities.dex.value", value: 2, mode: 2, priority: 20}, + {key: "data.abilities.dex.proficient", value: 1, mode: 4, priority: 20}, + {key: "data.abilities.con.value", value: -2, mode: 2, priority: 20}, + {key: "data.abilities.str.proficient", value: 1, mode: 4, priority: 20} + ], + attributes: { + crewcap: 1, + hd: "3d6", + hp: {value: 6, max: 6, temp: 6, tempmax: 6}, + hsm: 2, + sd: "3d6", + mods: {open: 20, max: 20}, + suites: {open: -1, max: -1}, + movement: {fly: 300, turn: 250} + } + }, + med: { + attributes: { + crewcap: 1, + hd: "5d8", + hp: {value: 8, max: 8, temp: 8, tempmax: 8}, + hsm: 3, + sd: "5d8", + mods: {open: 30, max: 30}, + suites: {open: 3, max: 3}, + movement: {fly: 300, turn: 200} + } + }, + lg: { + changes: [ + {key: "data.abilities.dex.value", value: -2, mode: 2, priority: 20}, + {key: "data.abilities.wis.proficient", value: 1, mode: 4, priority: 20}, + {key: "data.abilities.con.value", value: 2, mode: 2, priority: 20} + ], + attributes: { + crewcap: 200, + hd: "7d10", + hp: {value: 10, max: 10, temp: 10, tempmax: 10}, + hsm: 4, + sd: "7d10", + mods: {open: 50, max: 50}, + suites: {open: 3, max: 3}, + movement: {fly: 300, turn: 150} + } + }, + huge: { + changes: [ + {key: "data.abilities.dex.value", value: -4, mode: 2, priority: 20}, + {key: "data.abilities.wis.proficient", value: 1, mode: 4, priority: 20}, + {key: "data.abilities.con.value", value: 4, mode: 2, priority: 20} + ], + attributes: { + crewcap: 4000, + hd: "9d12", + hp: {value: 12, max: 12, temp: 12, tempmax: 12}, + hsm: 2, + sd: "9d12", + mods: {open: 60, max: 60}, + suites: {open: 6, max: 6}, + movement: {fly: 300, turn: 100} + } + }, + grg: { + changes: [ + {key: "data.abilities.dex.value", value: -6, mode: 2, priority: 20}, + {key: "data.abilities.wis.proficient", value: 1, mode: 4, priority: 20}, + {key: "data.abilities.con.value", value: 6, mode: 2, priority: 20} + ], + attributes: { + crewcap: 80000, + hd: "11d20", + hp: {value: 20, max: 20, temp: 20, tempmax: 20}, + hsm: 3, + sd: "11d20", + mods: {open: 70, max: 70}, + suites: {open: 10, max: 10}, + movement: {fly: 300, turn: 50} + } + } +}; /* -------------------------------------------- */ @@ -685,47 +761,46 @@ SW5E.baseStarshipSettings = { * @type {Object} */ - SW5E.starshipRolestiny = { -}; +SW5E.starshipRolestiny = {}; SW5E.starshipRolessm = { - "bmbr": "SW5E.StarshipBomber", - "intc": "SW5E.StarshipInterceptor", - "scout": "SW5E.StarshipScout", - "scrm": "SW5E.StarshipScrambler", - "shtl": "SW5E.StarshipShuttle", - "strf": "SW5E.StarshipStrikeFighter" + bmbr: "SW5E.StarshipBomber", + intc: "SW5E.StarshipInterceptor", + scout: "SW5E.StarshipScout", + scrm: "SW5E.StarshipScrambler", + shtl: "SW5E.StarshipShuttle", + strf: "SW5E.StarshipStrikeFighter" }; SW5E.starshipRolesmed = { - "cour": "SW5E.StarshipCourier", - "frtr": "SW5E.StarshipFreighter", - "gnbt": "SW5E.StarshipGunboat", - "msbt": "SW5E.StarshipMissileBoat", - "nvgt": "SW5E.StarshipNavigator", - "yacht": "SW5E.StarshipYacht" + cour: "SW5E.StarshipCourier", + frtr: "SW5E.StarshipFreighter", + gnbt: "SW5E.StarshipGunboat", + msbt: "SW5E.StarshipMissileBoat", + nvgt: "SW5E.StarshipNavigator", + yacht: "SW5E.StarshipYacht" }; SW5E.starshipRoleslg = { - "ambd": "SW5E.StarshipAmbassador", - "corv": "SW5E.StarshipCorvette", - "crui": "SW5E.StarshipCruiser", - "expl": "SW5E.StarshipExplorer", - "pics": "SW5E.StarshipPicketShip", - "shtd": "SW5E.StarshipShipsTender" + ambd: "SW5E.StarshipAmbassador", + corv: "SW5E.StarshipCorvette", + crui: "SW5E.StarshipCruiser", + expl: "SW5E.StarshipExplorer", + pics: "SW5E.StarshipPicketShip", + shtd: "SW5E.StarshipShipsTender" }; SW5E.starshipRoleshuge = { - "btls": "SW5E.StarshipBattleship", - "carr": "SW5E.StarshipCarrier", - "colo": "SW5E.StarshipColonizer", - "cmds": "SW5E.StarshipCommandShip", - "intd": "SW5E.StarshipInterdictor", - "jugg": "SW5E.StarshipJuggernaut" + btls: "SW5E.StarshipBattleship", + carr: "SW5E.StarshipCarrier", + colo: "SW5E.StarshipColonizer", + cmds: "SW5E.StarshipCommandShip", + intd: "SW5E.StarshipInterdictor", + jugg: "SW5E.StarshipJuggernaut" }; SW5E.starshipRolesgrg = { - "blks": "SW5E.StarshipBlockadeShip", - "flgs": "SW5E.StarshipFlagship", - "inct": "SW5E.StarshipIndustrialCenter", - "mbmt": "SW5E.StarshipMobileMetropolis", - "rsrc": "SW5E.StarshipResearcher", - "wars": "SW5E.StarshipWarship" + blks: "SW5E.StarshipBlockadeShip", + flgs: "SW5E.StarshipFlagship", + inct: "SW5E.StarshipIndustrialCenter", + mbmt: "SW5E.StarshipMobileMetropolis", + rsrc: "SW5E.StarshipResearcher", + wars: "SW5E.StarshipWarship" }; /* -------------------------------------------- */ @@ -735,52 +810,140 @@ SW5E.starshipRolesgrg = { * @type {Object} */ - SW5E.starshipRoleBonuses = { - "bmbr": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]}, - "intc": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20}]}, - "scout": {"changes":[{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]}, - "scrm": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20}]}, - "shtl": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20}]}, - "strf": {"changes":[{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "cour": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20}]}, - "frtr": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20}]}, - "gnbt": {"changes":[{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "msbt": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]}, - "nvgt": {"changes":[{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]}, - "yacht": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20}]}, - "ambd": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20}]}, - "corv": {"changes":[{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20}]}, - "crui": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "expl": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]}, - "pics": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]}, - "shtd": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "btls": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "carr": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]}, - "colo": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]}, - "cmds": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]}, - "intd": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "jugg": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "blks": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "flgs": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]}, - "inct": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}, - "mbmt": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]}, - "rsrc": {"changes":[{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]}, - "wars": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]} +SW5E.starshipRoleBonuses = { + bmbr: {changes: [{key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20}]}, + intc: {changes: [{key: "data.abilities.dex.value", value: 1, mode: 2, priority: 20}]}, + scout: {changes: [{key: "data.abilities.int.value", value: 1, mode: 2, priority: 20}]}, + scrm: {changes: [{key: "data.abilities.cha.value", value: 1, mode: 2, priority: 20}]}, + shtl: {changes: [{key: "data.abilities.con.value", value: 1, mode: 2, priority: 20}]}, + strf: {changes: [{key: "data.abilities.str.value", value: 1, mode: 2, priority: 20}]}, + cour: {changes: [{key: "data.abilities.dex.value", value: 1, mode: 2, priority: 20}]}, + frtr: {changes: [{key: "data.abilities.con.value", value: 1, mode: 2, priority: 20}]}, + gnbt: {changes: [{key: "data.abilities.str.value", value: 1, mode: 2, priority: 20}]}, + msbt: {changes: [{key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20}]}, + nvgt: {changes: [{key: "data.abilities.int.value", value: 1, mode: 2, priority: 20}]}, + yacht: {changes: [{key: "data.abilities.cha.value", value: 1, mode: 2, priority: 20}]}, + ambd: { + changes: [ + {key: "data.abilities.cha.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.con.value", value: 1, mode: 2, priority: 20} + ] + }, + corv: { + changes: [ + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.dex.value", value: 1, mode: 2, priority: 20} + ] + }, + crui: { + changes: [ + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + }, + expl: { + changes: [ + {key: "data.abilities.dex.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.int.value", value: 1, mode: 2, priority: 20} + ] + }, + pics: { + changes: [ + {key: "data.abilities.dex.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20} + ] + }, + shtd: { + changes: [ + {key: "data.abilities.con.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + }, + btls: { + changes: [ + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + }, + carr: { + changes: [ + {key: "data.abilities.cha.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.int.value", value: 1, mode: 2, priority: 20} + ] + }, + colo: { + changes: [ + {key: "data.abilities.con.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.int.value", value: 1, mode: 2, priority: 20} + ] + }, + cmds: { + changes: [ + {key: "data.abilities.cha.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20} + ] + }, + intd: { + changes: [ + {key: "data.abilities.dex.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + }, + jugg: { + changes: [ + {key: "data.abilities.con.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + }, + blks: { + changes: [ + {key: "data.abilities.dex.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + }, + flgs: { + changes: [ + {key: "data.abilities.cha.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20} + ] + }, + inct: { + changes: [ + {key: "data.abilities.con.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + }, + mbmt: { + changes: [ + {key: "data.abilities.con.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20} + ] + }, + rsrc: { + changes: [ + {key: "data.abilities.int.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20} + ] + }, + wars: { + changes: [ + {key: "data.abilities.wis.value", value: 1, mode: 2, priority: 20}, + {key: "data.abilities.str.value", value: 1, mode: 2, priority: 20} + ] + } }; /* -------------------------------------------- */ - - /** * The set of possible sensory perception types which an Actor may have * @enum {string} */ SW5E.senses = { - "blindsight": "SW5E.SenseBlindsight", - "darkvision": "SW5E.SenseDarkvision", - "tremorsense": "SW5E.SenseTremorsense", - "truesight": "SW5E.SenseTruesight" + blindsight: "SW5E.SenseBlindsight", + darkvision: "SW5E.SenseDarkvision", + tremorsense: "SW5E.SenseTremorsense", + truesight: "SW5E.SenseTruesight" }; /* -------------------------------------------- */ @@ -790,24 +953,24 @@ SW5E.senses = { * @type {Object} */ SW5E.skills = { - "acr": "SW5E.SkillAcr", - "ani": "SW5E.SkillAni", - "ath": "SW5E.SkillAth", - "dec": "SW5E.SkillDec", - "ins": "SW5E.SkillIns", - "itm": "SW5E.SkillItm", - "inv": "SW5E.SkillInv", - "lor": "SW5E.SkillLor", - "med": "SW5E.SkillMed", - "nat": "SW5E.SkillNat", - "prc": "SW5E.SkillPrc", - "prf": "SW5E.SkillPrf", - "per": "SW5E.SkillPer", - "pil": "SW5E.SkillPil", - "slt": "SW5E.SkillSlt", - "ste": "SW5E.SkillSte", - "sur": "SW5E.SkillSur", - "tec": "SW5E.SkillTec" + acr: "SW5E.SkillAcr", + ani: "SW5E.SkillAni", + ath: "SW5E.SkillAth", + dec: "SW5E.SkillDec", + ins: "SW5E.SkillIns", + itm: "SW5E.SkillItm", + inv: "SW5E.SkillInv", + lor: "SW5E.SkillLor", + med: "SW5E.SkillMed", + nat: "SW5E.SkillNat", + prc: "SW5E.SkillPrc", + prf: "SW5E.SkillPrf", + per: "SW5E.SkillPer", + pil: "SW5E.SkillPil", + slt: "SW5E.SkillSlt", + ste: "SW5E.SkillSte", + sur: "SW5E.SkillSur", + tec: "SW5E.SkillTec" }; /* -------------------------------------------- */ @@ -817,29 +980,29 @@ SW5E.skills = { * @type {Object} */ SW5E.starshipSkills = { - "ast": "SW5E.StarshipSkillAst", - "bst": "SW5E.StarshipSkillBst", - "dat": "SW5E.StarshipSkillDat", - "hid": "SW5E.StarshipSkillHid", - "imp": "SW5E.StarshipSkillImp", - "int": "SW5E.StarshipSkillInt", - "man": "SW5E.StarshipSkillMan", - "men": "SW5E.StarshipSkillMen", - "pat": "SW5E.StarshipSkillPat", - "prb": "SW5E.StarshipSkillPrb", - "ram": "SW5E.StarshipSkillRam", - "reg": "SW5E.StarshipSkillReg", - "scn": "SW5E.StarshipSkillScn", - "swn": "SW5E.StarshipSkillSwn" + ast: "SW5E.StarshipSkillAst", + bst: "SW5E.StarshipSkillBst", + dat: "SW5E.StarshipSkillDat", + hid: "SW5E.StarshipSkillHid", + imp: "SW5E.StarshipSkillImp", + int: "SW5E.StarshipSkillInt", + man: "SW5E.StarshipSkillMan", + men: "SW5E.StarshipSkillMen", + pat: "SW5E.StarshipSkillPat", + prb: "SW5E.StarshipSkillPrb", + ram: "SW5E.StarshipSkillRam", + reg: "SW5E.StarshipSkillReg", + scn: "SW5E.StarshipSkillScn", + swn: "SW5E.StarshipSkillSwn" }; /* -------------------------------------------- */ SW5E.powerPreparationModes = { - "prepared": "SW5E.PowerPrepPrepared", - "always": "SW5E.PowerPrepAlways", - "atwill": "SW5E.PowerPrepAtWill", - "innate": "SW5E.PowerPrepInnate" + prepared: "SW5E.PowerPrepPrepared", + always: "SW5E.PowerPrepAlways", + atwill: "SW5E.PowerPrepAtWill", + innate: "SW5E.PowerPrepInnate" }; SW5E.powerUpcastModes = ["always", "prepared"]; @@ -850,12 +1013,12 @@ SW5E.powerUpcastModes = ["always", "prepared"]; */ SW5E.powerProgression = { - "none": "SW5E.PowerNone", - "consular": "SW5E.PowerProgCns", - "engineer": "SW5E.PowerProgEng", - "guardian": "SW5E.PowerProgGrd", - "scout": "SW5E.PowerProgSct", - "sentinel": "SW5E.PowerProgSnt" + none: "SW5E.PowerNone", + consular: "SW5E.PowerProgCns", + engineer: "SW5E.PowerProgEng", + guardian: "SW5E.PowerProgGrd", + scout: "SW5E.PowerProgSct", + sentinel: "SW5E.PowerProgSnt" }; /** @@ -863,12 +1026,12 @@ SW5E.powerProgression = { */ SW5E.powersKnown = { - "none": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - "consular": [9,11,13,15,17,19,21,23,25,26,28,29,31,32,34,35,37,38,39,40], - "engineer": [6,7,9,10,12,13,15,16,18,19,21,22,23,24,25,26,27,28,29,30], - "guardian": [5,7,9,10,12,13,14,15,17,18,19,20,22,23,24,25,27,28,29,30], - "scout": [0,4,5,6,7,8,9,10,12,13,14,15,16,17,18,19,20,21,22,23], - "sentinel": [7,9,11,13,15,17,18,19,21,22,24,25,26,28,29,30,32,33,34,35] + none: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + consular: [9, 11, 13, 15, 17, 19, 21, 23, 25, 26, 28, 29, 31, 32, 34, 35, 37, 38, 39, 40], + engineer: [6, 7, 9, 10, 12, 13, 15, 16, 18, 19, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], + guardian: [5, 7, 9, 10, 12, 13, 14, 15, 17, 18, 19, 20, 22, 23, 24, 25, 27, 28, 29, 30], + scout: [0, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], + sentinel: [7, 9, 11, 13, 15, 17, 18, 19, 21, 22, 24, 25, 26, 28, 29, 30, 32, 33, 34, 35] }; /** @@ -876,14 +1039,14 @@ SW5E.powersKnown = { */ SW5E.powerLimit = { - "none": [0,0,0,0,0,0,0,0,0], - "consular": [1000,1000,1000,1000,1000,1,1,1,1], - "engineer": [1000,1000,1000,1000,1000,1,1,1,1], - "guardian": [1000,1000,1000,1000,1,0,0,0,0], - "scout": [1000,1000,1000,1,1,0,0,0,0], - "sentinel": [1000,1000,1000,1000,1,1,1,0,0], - "innate": [1000,1000,1000,1000,1000,1000,1000,1000,1000], - "dual": [1000,1000,1000,1000,1000,1,1,1,1] + none: [0, 0, 0, 0, 0, 0, 0, 0, 0], + consular: [1000, 1000, 1000, 1000, 1000, 1, 1, 1, 1], + engineer: [1000, 1000, 1000, 1000, 1000, 1, 1, 1, 1], + guardian: [1000, 1000, 1000, 1000, 1, 0, 0, 0, 0], + scout: [1000, 1000, 1000, 1, 1, 0, 0, 0, 0], + sentinel: [1000, 1000, 1000, 1000, 1, 1, 1, 0, 0], + innate: [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000], + dual: [1000, 1000, 1000, 1000, 1000, 1, 1, 1, 1] }; /** @@ -891,15 +1054,15 @@ SW5E.powerLimit = { */ SW5E.powerMaxLevel = { - "none": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - "consular": [1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,9,9], - "engineer": [1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,9,9], - "guardian": [1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5], - "scout": [0,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5], - "sentinel": [1,1,2,2,2,3,3,3,4,4,5,5,5,6,6,6,7,7,7,7], - "multi": [1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,9,9], - "innate": [1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,9,9], - "dual": [1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,9,9] + none: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + consular: [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 9], + engineer: [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 9], + guardian: [1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5], + scout: [0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5], + sentinel: [1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 7], + multi: [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 9], + innate: [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 9], + dual: [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 9] }; /** @@ -907,12 +1070,12 @@ SW5E.powerMaxLevel = { */ SW5E.powerPoints = { - "none": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - "consular": [4,8,12,16,20,24,28,32,36,40,44,48,52,56,60,64,68,72,76,80], - "engineer": [2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40], - "guardian": [2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40], - "scout": [0,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20], - "sentinel": [3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60] + none: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + consular: [4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80], + engineer: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40], + guardian: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40], + scout: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + sentinel: [3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60] }; /* -------------------------------------------- */ @@ -922,37 +1085,34 @@ SW5E.powerPoints = { * @type {Object} */ SW5E.powerScalingModes = { - "none": "SW5E.PowerNone", - "atwill": "SW5E.PowerAtWill", - "level": "SW5E.PowerLevel" + none: "SW5E.PowerNone", + atwill: "SW5E.PowerAtWill", + level: "SW5E.PowerLevel" }; /* -------------------------------------------- */ - /** * Define the set of types which a weapon item can take * @type {Object} */ SW5E.weaponTypes = { - - "ammo": "SW5E.WeaponAmmo", - "improv": "SW5E.WeaponImprov", - "martialVW": "SW5E.WeaponMartialVW", - "martialB": "SW5E.WeaponMartialB", - "martialLW": "SW5E.WeaponMartialLW", - "natural": "SW5E.WeaponNatural", - "siege": "SW5E.WeaponSiege", - "simpleVW": "SW5E.WeaponSimpleVW", - "simpleB": "SW5E.WeaponSimpleB", - "simpleLW": "SW5E.WeaponSimpleLW", - "primary (starship)": "SW5E.WeaponPrimarySW", - "secondary (starship)": "SW5E.WeaponSecondarySW", - "tertiary (starship)": "SW5E.WeaponTertiarySW", - "quaternary (starship)": "SW5E.WeaponQuaternarySW" + "ammo": "SW5E.WeaponAmmo", + "improv": "SW5E.WeaponImprov", + "martialVW": "SW5E.WeaponMartialVW", + "martialB": "SW5E.WeaponMartialB", + "martialLW": "SW5E.WeaponMartialLW", + "natural": "SW5E.WeaponNatural", + "siege": "SW5E.WeaponSiege", + "simpleVW": "SW5E.WeaponSimpleVW", + "simpleB": "SW5E.WeaponSimpleB", + "simpleLW": "SW5E.WeaponSimpleLW", + "primary (starship)": "SW5E.WeaponPrimarySW", + "secondary (starship)": "SW5E.WeaponSecondarySW", + "tertiary (starship)": "SW5E.WeaponTertiarySW", + "quaternary (starship)": "SW5E.WeaponQuaternarySW" }; - /* -------------------------------------------- */ /** @@ -960,48 +1120,48 @@ SW5E.weaponTypes = { * @type {Object} */ SW5E.weaponProperties = { - "amm": "SW5E.WeaponPropertiesAmm", - "aut": "SW5E.WeaponPropertiesAut", - "bur": "SW5E.WeaponPropertiesBur", - "con": "SW5E.WeaponPropertiesCon", - "def": "SW5E.WeaponPropertiesDef", - "dex": "SW5E.WeaponPropertiesDex", - "dir": "SW5E.WeaponPropertiesDir", - "drm": "SW5E.WeaponPropertiesDrm", - "dgd": "SW5E.WeaponPropertiesDgd", - "dis": "SW5E.WeaponPropertiesDis", - "dpt": "SW5E.WeaponPropertiesDpt", - "dou": "SW5E.WeaponPropertiesDou", - "exp": "SW5E.WeaponPropertiesExp", - "fin": "SW5E.WeaponPropertiesFin", - "fix": "SW5E.WeaponPropertiesFix", - "foc": "SW5E.WeaponPropertiesFoc", - "hvy": "SW5E.WeaponPropertiesHvy", - "hid": "SW5E.WeaponPropertiesHid", - "hom": "SW5E.WeaponPropertiesHom", - "ion": "SW5E.WeaponPropertiesIon", - "ken": "SW5E.WeaponPropertiesKen", - "lgt": "SW5E.WeaponPropertiesLgt", - "lum": "SW5E.WeaponPropertiesLum", - "mlt": "SW5E.WeaponPropertiesMlt", - "mig": "SW5E.WeaponPropertiesMig", - "ovr": "SW5E.WeaponPropertiesOvr", - "pic": "SW5E.WeaponPropertiesPic", - "pow": "SW5E.WeaponPropertiesPow", - "rap": "SW5E.WeaponPropertiesRap", - "rch": "SW5E.WeaponPropertiesRch", - "rel": "SW5E.WeaponPropertiesRel", - "ret": "SW5E.WeaponPropertiesRet", - "sat": "SW5E.WeaponPropertiesSat", - "shk": "SW5E.WeaponPropertiesShk", - "sil": "SW5E.WeaponPropertiesSil", - "spc": "SW5E.WeaponPropertiesSpc", - "str": "SW5E.WeaponPropertiesStr", - "thr": "SW5E.WeaponPropertiesThr", - "two": "SW5E.WeaponPropertiesTwo", - "ver": "SW5E.WeaponPropertiesVer", - "vic": "SW5E.WeaponPropertiesVic", - "zon": "SW5E.WeaponPropertiesZon" + amm: "SW5E.WeaponPropertiesAmm", + aut: "SW5E.WeaponPropertiesAut", + bur: "SW5E.WeaponPropertiesBur", + con: "SW5E.WeaponPropertiesCon", + def: "SW5E.WeaponPropertiesDef", + dex: "SW5E.WeaponPropertiesDex", + dir: "SW5E.WeaponPropertiesDir", + drm: "SW5E.WeaponPropertiesDrm", + dgd: "SW5E.WeaponPropertiesDgd", + dis: "SW5E.WeaponPropertiesDis", + dpt: "SW5E.WeaponPropertiesDpt", + dou: "SW5E.WeaponPropertiesDou", + exp: "SW5E.WeaponPropertiesExp", + fin: "SW5E.WeaponPropertiesFin", + fix: "SW5E.WeaponPropertiesFix", + foc: "SW5E.WeaponPropertiesFoc", + hvy: "SW5E.WeaponPropertiesHvy", + hid: "SW5E.WeaponPropertiesHid", + hom: "SW5E.WeaponPropertiesHom", + ion: "SW5E.WeaponPropertiesIon", + ken: "SW5E.WeaponPropertiesKen", + lgt: "SW5E.WeaponPropertiesLgt", + lum: "SW5E.WeaponPropertiesLum", + mlt: "SW5E.WeaponPropertiesMlt", + mig: "SW5E.WeaponPropertiesMig", + ovr: "SW5E.WeaponPropertiesOvr", + pic: "SW5E.WeaponPropertiesPic", + pow: "SW5E.WeaponPropertiesPow", + rap: "SW5E.WeaponPropertiesRap", + rch: "SW5E.WeaponPropertiesRch", + rel: "SW5E.WeaponPropertiesRel", + ret: "SW5E.WeaponPropertiesRet", + sat: "SW5E.WeaponPropertiesSat", + shk: "SW5E.WeaponPropertiesShk", + sil: "SW5E.WeaponPropertiesSil", + spc: "SW5E.WeaponPropertiesSpc", + str: "SW5E.WeaponPropertiesStr", + thr: "SW5E.WeaponPropertiesThr", + two: "SW5E.WeaponPropertiesTwo", + ver: "SW5E.WeaponPropertiesVer", + vic: "SW5E.WeaponPropertiesVic", + zon: "SW5E.WeaponPropertiesZon" }; /* -------------------------------------------- */ @@ -1011,42 +1171,42 @@ SW5E.weaponProperties = { * @type {Object} */ SW5E.weaponSizes = { - "tiny": "SW5E.SizeTiny", - "sm": "SW5E.SizeSmall", - "med": "SW5E.SizeMedium", - "lg": "SW5E.SizeLarge", - "huge": "SW5E.SizeHuge", - "grg": "SW5E.SizeGargantuan" -}; + tiny: "SW5E.SizeTiny", + sm: "SW5E.SizeSmall", + med: "SW5E.SizeMedium", + lg: "SW5E.SizeLarge", + huge: "SW5E.SizeHuge", + grg: "SW5E.SizeGargantuan" +}; // Power Components SW5E.powerComponents = { - "V": "SW5E.ComponentVerbal", - "S": "SW5E.ComponentSomatic", - "M": "SW5E.ComponentMaterial" + V: "SW5E.ComponentVerbal", + S: "SW5E.ComponentSomatic", + M: "SW5E.ComponentMaterial" }; // Power Schools SW5E.powerSchools = { - "lgt": "SW5E.SchoolLgt", - "uni": "SW5E.SchoolUni", - "drk": "SW5E.SchoolDrk", - "tec": "SW5E.SchoolTec", - "enh": "SW5E.SchoolEnh" + lgt: "SW5E.SchoolLgt", + uni: "SW5E.SchoolUni", + drk: "SW5E.SchoolDrk", + tec: "SW5E.SchoolTec", + enh: "SW5E.SchoolEnh" }; // Power Levels SW5E.powerLevels = { - 0: "SW5E.PowerLevel0", - 1: "SW5E.PowerLevel1", - 2: "SW5E.PowerLevel2", - 3: "SW5E.PowerLevel3", - 4: "SW5E.PowerLevel4", - 5: "SW5E.PowerLevel5", - 6: "SW5E.PowerLevel6", - 7: "SW5E.PowerLevel7", - 8: "SW5E.PowerLevel8", - 9: "SW5E.PowerLevel9" + 0: "SW5E.PowerLevel0", + 1: "SW5E.PowerLevel1", + 2: "SW5E.PowerLevel2", + 3: "SW5E.PowerLevel3", + 4: "SW5E.PowerLevel4", + 5: "SW5E.PowerLevel5", + 6: "SW5E.PowerLevel6", + 7: "SW5E.PowerLevel7", + 8: "SW5E.PowerLevel8", + 9: "SW5E.PowerLevel9" }; // TODO: This is used for spell scrolls, it maps the level to the compendium ID of the item the spell would be bound to @@ -1072,23 +1232,23 @@ SW5E.powerScrollIds = { * @enum {string} */ SW5E.sourcePacks = { - ITEMS: "sw5e.items" -} + ITEMS: "sw5e.items" +}; // Polymorph options. SW5E.polymorphSettings = { - keepPhysical: 'SW5E.PolymorphKeepPhysical', - keepMental: 'SW5E.PolymorphKeepMental', - keepSaves: 'SW5E.PolymorphKeepSaves', - keepSkills: 'SW5E.PolymorphKeepSkills', - mergeSaves: 'SW5E.PolymorphMergeSaves', - mergeSkills: 'SW5E.PolymorphMergeSkills', - keepClass: 'SW5E.PolymorphKeepClass', - keepFeats: 'SW5E.PolymorphKeepFeats', - keepPowers: 'SW5E.PolymorphKeepPowers', - keepItems: 'SW5E.PolymorphKeepItems', - keepBio: 'SW5E.PolymorphKeepBio', - keepVision: 'SW5E.PolymorphKeepVision' + keepPhysical: "SW5E.PolymorphKeepPhysical", + keepMental: "SW5E.PolymorphKeepMental", + keepSaves: "SW5E.PolymorphKeepSaves", + keepSkills: "SW5E.PolymorphKeepSkills", + mergeSaves: "SW5E.PolymorphMergeSaves", + mergeSkills: "SW5E.PolymorphMergeSkills", + keepClass: "SW5E.PolymorphKeepClass", + keepFeats: "SW5E.PolymorphKeepFeats", + keepPowers: "SW5E.PolymorphKeepPowers", + keepItems: "SW5E.PolymorphKeepItems", + keepBio: "SW5E.PolymorphKeepBio", + keepVision: "SW5E.PolymorphKeepVision" }; /* -------------------------------------------- */ @@ -1099,10 +1259,10 @@ SW5E.polymorphSettings = { * @type {Object} */ SW5E.proficiencyLevels = { - 0: "SW5E.NotProficient", - 1: "SW5E.Proficient", - 0.5: "SW5E.HalfProficient", - 2: "SW5E.Expertise" + 0: "SW5E.NotProficient", + 1: "SW5E.Proficient", + 0.5: "SW5E.HalfProficient", + 2: "SW5E.Expertise" }; /* -------------------------------------------- */ @@ -1113,157 +1273,156 @@ SW5E.proficiencyLevels = { * in play, we take the highest value. */ SW5E.cover = { - 0: 'SW5E.None', - .5: 'SW5E.CoverHalf', - .75: 'SW5E.CoverThreeQuarters', - 1: 'SW5E.CoverTotal' + 0: "SW5E.None", + 0.5: "SW5E.CoverHalf", + 0.75: "SW5E.CoverThreeQuarters", + 1: "SW5E.CoverTotal" }; /* -------------------------------------------- */ - // Condition Types SW5E.conditionTypes = { - "blinded": "SW5E.ConBlinded", - "charmed": "SW5E.ConCharmed", - "deafened": "SW5E.ConDeafened", - "diseased": "SW5E.ConDiseased", - "exhaustion": "SW5E.ConExhaustion", - "frightened": "SW5E.ConFrightened", - "grappled": "SW5E.ConGrappled", - "incapacitated": "SW5E.ConIncapacitated", - "invisible": "SW5E.ConInvisible", - "paralyzed": "SW5E.ConParalyzed", - "petrified": "SW5E.ConPetrified", - "poisoned": "SW5E.ConPoisoned", - "prone": "SW5E.ConProne", - "restrained": "SW5E.ConRestrained", - "shocked": "SW5E.ConShocked", - "slowed": "SW5E.ConSlowed", - "stunned": "SW5E.ConStunned", - "unconscious": "SW5E.ConUnconscious" + blinded: "SW5E.ConBlinded", + charmed: "SW5E.ConCharmed", + deafened: "SW5E.ConDeafened", + diseased: "SW5E.ConDiseased", + exhaustion: "SW5E.ConExhaustion", + frightened: "SW5E.ConFrightened", + grappled: "SW5E.ConGrappled", + incapacitated: "SW5E.ConIncapacitated", + invisible: "SW5E.ConInvisible", + paralyzed: "SW5E.ConParalyzed", + petrified: "SW5E.ConPetrified", + poisoned: "SW5E.ConPoisoned", + prone: "SW5E.ConProne", + restrained: "SW5E.ConRestrained", + shocked: "SW5E.ConShocked", + slowed: "SW5E.ConSlowed", + stunned: "SW5E.ConStunned", + unconscious: "SW5E.ConUnconscious" }; // Languages SW5E.languages = { - "abyssin": "SW5E.LanguagesAbyssin", - "aleena": "SW5E.LanguagesAleena", - "antarian": "SW5E.LanguagesAntarian", - "anzellan": "SW5E.LanguagesAnzellan", - "aqualish": "SW5E.LanguagesAqualish", - "arconese": "SW5E.LanguagesArconese", - "ardennian": "SW5E.LanguagesArdennian", - "arkanian": "SW5E.LanguagesArkanian", - "balosur": "SW5E.LanguagesBalosur", - "barabel": "SW5E.LanguagesBarabel", - "basic": "SW5E.LanguagesBasic", - "besalisk": "SW5E.LanguagesBesalisk", - "binary": "SW5E.LanguagesBinary", - "bith": "SW5E.LanguagesBith", - "bocce": "SW5E.LanguagesBocce", - "bothese": "SW5E.LanguagesBothese", - "catharese": "SW5E.LanguagesCatharese", - "cerean": "SW5E.LanguagesCerean", - "chadra-fan": "SW5E.LanguagesChadra-Fan", - "chagri": "SW5E.LanguagesChagri", - "cheunh": "SW5E.LanguagesCheunh", - "chevin": "SW5E.LanguagesChevin", - "chironan": "SW5E.LanguagesChironan", - "clawdite": "SW5E.LanguagesClawdite", - "codruese": "SW5E.LanguagesCodruese", - "colicoid": "SW5E.LanguagesColicoid", - "dashadi": "SW5E.LanguagesDashadi", - "defel": "SW5E.LanguagesDefel", - "devaronese": "SW5E.LanguagesDevaronese", - "dosh": "SW5E.LanguagesDosh", - "draethos": "SW5E.LanguagesDraethos", - "durese": "SW5E.LanguagesDurese", - "dug": "SW5E.LanguagesDug", - "ewokese": "SW5E.LanguagesEwokese", - "falleen": "SW5E.LanguagesFalleen", - "felucianese": "SW5E.LanguagesFelucianese", - "gamorrese": "SW5E.LanguagesGamorrese", - "gand": "SW5E.LanguagesGand", - "geonosian": "SW5E.LanguagesGeonosian", - "givin": "SW5E.LanguagesGivin", - "gran": "SW5E.LanguagesGran", - "gungan": "SW5E.LanguagesGungan", - "hapan": "SW5E.LanguagesHapan", - "harchese": "SW5E.LanguagesHarchese", - "herglese": "SW5E.LanguagesHerglese", - "honoghran": "SW5E.LanguagesHonoghran", - "huttese": "SW5E.LanguagesHuttese", - "iktotchese": "SW5E.LanguagesIktotchese", - "ithorese": "SW5E.LanguagesIthorese", - "jawaese": "SW5E.LanguagesJawaese", - "kaleesh": "SW5E.LanguagesKaleesh", - "kaminoan": "SW5E.LanguagesKaminoan", - "karkaran": "SW5E.LanguagesKarkaran", - "keldor": "SW5E.LanguagesKelDor", - "kharan": "SW5E.LanguagesKharan", - "killik": "SW5E.LanguagesKillik", - "klatooinian": "SW5E.LanguagesKlatooinian", - "kubazian": "SW5E.LanguagesKubazian", - "kushiban": "SW5E.LanguagesKushiban", - "kyuzo": "SW5E.LanguagesKyuzo", - "lannik": "SW5E.LanguagesLannik", - "lasat": "SW5E.LanguagesLasat", - "lowickese": "SW5E.LanguagesLowickese", - "lurmese": "SW5E.LanguagesLurmese", - "mandoa": "SW5E.LanguagesMandoa", - "miralukese": "SW5E.LanguagesMiralukese", - "mirialan": "SW5E.LanguagesMirialan", - "moncal": "SW5E.LanguagesMonCal", - "mustafarian": "SW5E.LanguagesMustafarian", - "muun": "SW5E.LanguagesMuun", - "nautila": "SW5E.LanguagesNautila", - "ortolan": "SW5E.LanguagesOrtolan", - "pakpak": "SW5E.LanguagesPakPak", - "pyke": "SW5E.LanguagesPyke", - "quarrenese": "SW5E.LanguagesQuarrenese", - "rakata": "SW5E.LanguagesRakata", - "rattataki": "SW5E.LanguagesRattataki", - "rishii": "SW5E.LanguagesRishii", - "rodese": "SW5E.LanguagesRodese", - "ryn": "SW5E.LanguagesRyn", - "selkatha": "SW5E.LanguagesSelkatha", - "semblan": "SW5E.LanguagesSemblan", - "shistavanen": "SW5E.LanguagesShistavanen", - "shyriiwook": "SW5E.LanguagesShyriiwook", - "sith": "SW5E.LanguagesSith", - "squibbian": "SW5E.LanguagesSquibbian", - "sriluurian": "SW5E.LanguagesSriluurian", - "ssi-ruuvi": "SW5E.LanguagesSsi-ruuvi", - "sullustese": "SW5E.LanguagesSullustese", - "talzzi": "SW5E.LanguagesTalzzi", - "tarasinese": "SW5E.LanguagesTarasinese", - "thisspiasian": "SW5E.LanguagesThisspiasian", - "togorese": "SW5E.LanguagesTogorese", - "togruti": "SW5E.LanguagesTogruti", - "toydarian": "SW5E.LanguagesToydarian", - "tusken": "SW5E.LanguagesTusken", - "twi'leki": "SW5E.LanguagesTwileki", - "ugnaught": "SW5E.LanguagesUgnaught", - "umbaran": "SW5E.LanguagesUmbaran", - "utapese": "SW5E.LanguagesUtapese", - "verpine": "SW5E.LanguagesVerpine", - "vong": "SW5E.LanguagesVong", - "voss": "SW5E.LanguagesVoss", - "yevethan": "SW5E.LanguagesYevethan", - "zabraki": "SW5E.LanguagesZabraki", - "zygerrian": "SW5E.LanguagesZygerrian" + "abyssin": "SW5E.LanguagesAbyssin", + "aleena": "SW5E.LanguagesAleena", + "antarian": "SW5E.LanguagesAntarian", + "anzellan": "SW5E.LanguagesAnzellan", + "aqualish": "SW5E.LanguagesAqualish", + "arconese": "SW5E.LanguagesArconese", + "ardennian": "SW5E.LanguagesArdennian", + "arkanian": "SW5E.LanguagesArkanian", + "balosur": "SW5E.LanguagesBalosur", + "barabel": "SW5E.LanguagesBarabel", + "basic": "SW5E.LanguagesBasic", + "besalisk": "SW5E.LanguagesBesalisk", + "binary": "SW5E.LanguagesBinary", + "bith": "SW5E.LanguagesBith", + "bocce": "SW5E.LanguagesBocce", + "bothese": "SW5E.LanguagesBothese", + "catharese": "SW5E.LanguagesCatharese", + "cerean": "SW5E.LanguagesCerean", + "chadra-fan": "SW5E.LanguagesChadra-Fan", + "chagri": "SW5E.LanguagesChagri", + "cheunh": "SW5E.LanguagesCheunh", + "chevin": "SW5E.LanguagesChevin", + "chironan": "SW5E.LanguagesChironan", + "clawdite": "SW5E.LanguagesClawdite", + "codruese": "SW5E.LanguagesCodruese", + "colicoid": "SW5E.LanguagesColicoid", + "dashadi": "SW5E.LanguagesDashadi", + "defel": "SW5E.LanguagesDefel", + "devaronese": "SW5E.LanguagesDevaronese", + "dosh": "SW5E.LanguagesDosh", + "draethos": "SW5E.LanguagesDraethos", + "durese": "SW5E.LanguagesDurese", + "dug": "SW5E.LanguagesDug", + "ewokese": "SW5E.LanguagesEwokese", + "falleen": "SW5E.LanguagesFalleen", + "felucianese": "SW5E.LanguagesFelucianese", + "gamorrese": "SW5E.LanguagesGamorrese", + "gand": "SW5E.LanguagesGand", + "geonosian": "SW5E.LanguagesGeonosian", + "givin": "SW5E.LanguagesGivin", + "gran": "SW5E.LanguagesGran", + "gungan": "SW5E.LanguagesGungan", + "hapan": "SW5E.LanguagesHapan", + "harchese": "SW5E.LanguagesHarchese", + "herglese": "SW5E.LanguagesHerglese", + "honoghran": "SW5E.LanguagesHonoghran", + "huttese": "SW5E.LanguagesHuttese", + "iktotchese": "SW5E.LanguagesIktotchese", + "ithorese": "SW5E.LanguagesIthorese", + "jawaese": "SW5E.LanguagesJawaese", + "kaleesh": "SW5E.LanguagesKaleesh", + "kaminoan": "SW5E.LanguagesKaminoan", + "karkaran": "SW5E.LanguagesKarkaran", + "keldor": "SW5E.LanguagesKelDor", + "kharan": "SW5E.LanguagesKharan", + "killik": "SW5E.LanguagesKillik", + "klatooinian": "SW5E.LanguagesKlatooinian", + "kubazian": "SW5E.LanguagesKubazian", + "kushiban": "SW5E.LanguagesKushiban", + "kyuzo": "SW5E.LanguagesKyuzo", + "lannik": "SW5E.LanguagesLannik", + "lasat": "SW5E.LanguagesLasat", + "lowickese": "SW5E.LanguagesLowickese", + "lurmese": "SW5E.LanguagesLurmese", + "mandoa": "SW5E.LanguagesMandoa", + "miralukese": "SW5E.LanguagesMiralukese", + "mirialan": "SW5E.LanguagesMirialan", + "moncal": "SW5E.LanguagesMonCal", + "mustafarian": "SW5E.LanguagesMustafarian", + "muun": "SW5E.LanguagesMuun", + "nautila": "SW5E.LanguagesNautila", + "ortolan": "SW5E.LanguagesOrtolan", + "pakpak": "SW5E.LanguagesPakPak", + "pyke": "SW5E.LanguagesPyke", + "quarrenese": "SW5E.LanguagesQuarrenese", + "rakata": "SW5E.LanguagesRakata", + "rattataki": "SW5E.LanguagesRattataki", + "rishii": "SW5E.LanguagesRishii", + "rodese": "SW5E.LanguagesRodese", + "ryn": "SW5E.LanguagesRyn", + "selkatha": "SW5E.LanguagesSelkatha", + "semblan": "SW5E.LanguagesSemblan", + "shistavanen": "SW5E.LanguagesShistavanen", + "shyriiwook": "SW5E.LanguagesShyriiwook", + "sith": "SW5E.LanguagesSith", + "squibbian": "SW5E.LanguagesSquibbian", + "sriluurian": "SW5E.LanguagesSriluurian", + "ssi-ruuvi": "SW5E.LanguagesSsi-ruuvi", + "sullustese": "SW5E.LanguagesSullustese", + "talzzi": "SW5E.LanguagesTalzzi", + "tarasinese": "SW5E.LanguagesTarasinese", + "thisspiasian": "SW5E.LanguagesThisspiasian", + "togorese": "SW5E.LanguagesTogorese", + "togruti": "SW5E.LanguagesTogruti", + "toydarian": "SW5E.LanguagesToydarian", + "tusken": "SW5E.LanguagesTusken", + "twi'leki": "SW5E.LanguagesTwileki", + "ugnaught": "SW5E.LanguagesUgnaught", + "umbaran": "SW5E.LanguagesUmbaran", + "utapese": "SW5E.LanguagesUtapese", + "verpine": "SW5E.LanguagesVerpine", + "vong": "SW5E.LanguagesVong", + "voss": "SW5E.LanguagesVoss", + "yevethan": "SW5E.LanguagesYevethan", + "zabraki": "SW5E.LanguagesZabraki", + "zygerrian": "SW5E.LanguagesZygerrian" }; // Character Level XP Requirements -SW5E.CHARACTER_EXP_LEVELS = [ - 0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000, - 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000] -; +SW5E.CHARACTER_EXP_LEVELS = [ + 0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000, 120000, 140000, 165000, 195000, 225000, + 265000, 305000, 355000 +]; // Challenge Rating XP Levels SW5E.CR_EXP_LEVELS = [ - 10, 200, 450, 700, 1100, 1800, 2300, 2900, 3900, 5000, 5900, 7200, 8400, 10000, 11500, 13000, 15000, 18000, - 20000, 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000, 105000, 120000, 135000, 155000 + 10, 200, 450, 700, 1100, 1800, 2300, 2900, 3900, 5000, 5900, 7200, 8400, 10000, 11500, 13000, 15000, 18000, 20000, + 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000, 105000, 120000, 135000, 155000 ]; // Character Features Per Class And Level @@ -1271,354 +1430,354 @@ SW5E.classFeatures = ClassFeatures; // Configure Optional Character Flags SW5E.characterFlags = { - "adaptiveResilience": { - name: "SW5E.FlagsAdaptiveResilience", - hint: "SW5E.FlagsAdaptiveResilienceHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "aggressive": { - name: "SW5E.FlagsAggressive", - hint: "SW5E.FlagsAggressiveHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "amphibious": { - name: "SW5E.FlagsAmphibious", - hint: "SW5E.FlagsAmphibiousHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "armorIntegration": { - name: "SW5E.FlagsArmorIntegration", - hint: "SW5E.FlagsArmorIntegrationHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "businessSavvy": { - name: "SW5E.FlagsBusinessSavvy", - hint: "SW5E.FlagsBusinessSavvyHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "cannibalize": { - name: "SW5E.FlagsCannibalize", - hint: "SW5E.FlagsCannibalizeHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "closedMind": { - name: "SW5E.FlagsClosedMind", - hint: "SW5E.FlagsClosedMindHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "crudeWeaponSpecialists": { - name: "SW5E.FlagsCrudeWeaponSpecialists", - hint: "SW5E.FlagsCrudeWeaponSpecialistsHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "defiant": { - name: "SW5E.FlagsDefiant", - hint: "SW5E.FlagsDefiantHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "detailOriented": { - name: "SW5E.FlagsDetailOriented", - hint: "SW5E.FlagsDetailOrientedHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "enthrallingPheromones": { - name: "SW5E.FlagsEnthrallingPheromones", - hint: "SW5E.FlagsEnthrallingPheromonesHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "extraArms": { - name: "SW5E.FlagsExtraArms", - hint: "SW5E.FlagsExtraArmsHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "forceContention": { - name: "SW5E.FlagsForceContention", - hint: "SW5E.FlagsForceContentionHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "forceInsensitive": { - name: "SW5E.FlagsForceInsensitive", - hint: "SW5E.FlagsForceInsensitiveHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "foreignBiology": { - name: "SW5E.FlagsForeignBiology", - hint: "SW5E.FlagsForeignBiologyHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "furyOfTheSmall": { - name: "SW5E.FlagsFuryOfTheSmall", - hint: "SW5E.FlagsFuryOfTheSmallHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "grovelCowerAndBeg": { - name: "SW5E.FlagsGrovelCowerAndBeg", - hint: "SW5E.FlagsGrovelCowerAndBegHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "inscrutable": { - name: "SW5E.FlagsInscrutable", - hint: "SW5E.FlagsInscrutableHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "keenSenses": { - name: "SW5E.FlagsKeenSenses", - hint: "SW5E.FlagsKeenSensesHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "longlimbed": { - name: "SW5E.FlagsLongLimbed", - hint: "SW5E.FlagsLongLimbedHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "maintenanceMode": { - name: "SW5E.FlagsMaintenanceMode", - hint: "SW5E.FlagsMaintenanceModeHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "maskOfTheWild": { - name: "SW5E.FlagsMaskOfTheWild", - hint: "SW5E.FlagsMaskOfTheWildHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "multipleHearts": { - name: "SW5E.FlagsMultipleHearts", - hint: "SW5E.FlagsMultipleHeartsHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "naturallyStealthy": { - name: "SW5E.FlagsNaturallyStealthy", - hint: "SW5E.FlagsNaturallyStealthyHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "nimbleAgility": { - name: "SW5E.FlagsNimbleAgility", - hint: "SW5E.FlagsNimbleAgilityHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "nimbleEscape": { - name: "SW5E.FlagsNimbleEscape", - hint: "SW5E.FlagsNimbleEscapeHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "nimbleness": { - name: "SW5E.FlagsNimbleness", - hint: "SW5E.FlagsNimblenessHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "pintsized": { - name: "SW5E.FlagsPintsized", - hint: "SW5E.FlagsPintsizedHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "powerfulBuild": { - name: "SW5E.FlagsPowerfulBuild", - hint: "SW5E.FlagsPowerfulBuildHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "precognition": { - name: "SW5E.FlagsPrecognition", - hint: "SW5E.FlagsPrecognitionHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "programmer": { - name: "SW5E.FlagsProgrammer", - hint: "SW5E.FlagsProgrammerHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "puny": { - name: "SW5E.FlagsPuny", - hint: "SW5E.FlagsPunyHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "rapidReconstruction": { - name: "SW5E.FlagsRapidReconstruction", - hint: "SW5E.FlagsRapidReconstructionHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "rapidlyRegenerative": { - name: "SW5E.FlagsRapidlyRegenerative", - hint: "SW5E.FlagsRapidlyRegenerativeHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "regenerative": { - name: "SW5E.FlagsRegenerative", - hint: "SW5E.FlagsRegenerativeHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "savageAttacks": { - name: "SW5E.FlagsSavageAttacks", - hint: "SW5E.FlagsSavageAttacksHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "shapechanger": { - name: "SW5E.FlagsShapechanger", - hint: "SW5E.FlagsShapechangerHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "strongLegged": { - name: "SW5E.FlagsStrongLegged", - hint: "SW5E.FlagsStrongLeggedHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "sunlightSensitivity": { - name: "SW5E.FlagsSunlightSensitivity", - hint: "SW5E.FlagsSunlightSensitivityHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "surpriseAttack": { - name: "SW5E.FlagsSurpriseAttack", - hint: "SW5E.FlagsSurpriseAttackHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "techImpaired": { - name: "SW5E.FlagsTechImpaired", - hint: "SW5E.FlagsTechImpairedHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "techResistance": { - name: "SW5E.FlagsTechResistance", - hint: "SW5E.FlagsTechResistanceHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "tinker": { - name: "SW5E.FlagsTinker", - hint: "SW5E.FlagsTinkerHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "toughness": { - name: "SW5E.FlagsToughness", - hint: "SW5E.FlagsToughnessHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "trance": { - name: "SW5E.FlagsTrance", - hint: "SW5E.FlagsTranceHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "unarmedCombatant": { - name: "SW5E.FlagsUnarmedCombatant", - hint: "SW5E.FlagsUnarmedCombatantHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "undersized": { - name: "SW5E.FlagsUndersized", - hint: "SW5E.FlagsUndersizedHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "unsettlingVisage": { - name: "SW5E.FlagsUnsettlingVisage", - hint: "SW5E.FlagsUnsettlingVisageHint", - section: "SW5E.SpeciesTraits", - type: Boolean - }, - "initiativeAdv": { - name: "SW5E.FlagsInitiativeAdv", - hint: "SW5E.FlagsInitiativeAdvHint", - section: "SW5E.Features", - type: Boolean - }, - "initiativeAlert": { - name: "SW5E.FlagsAlert", - hint: "SW5E.FlagsAlertHint", - section: "SW5E.Features", - type: Boolean - }, - "jackOfAllTrades": { - name: "SW5E.FlagsJOAT", - hint: "SW5E.FlagsJOATHint", - section: "SW5E.Features", - type: Boolean - }, - "observantFeat": { - name: "SW5E.FlagsObservant", - hint: "SW5E.FlagsObservantHint", - skills: ['prc','inv'], - section: "SW5E.Features", - type: Boolean - }, - "reliableTalent": { - name: "SW5E.FlagsReliableTalent", - hint: "SW5E.FlagsReliableTalentHint", - section: "SW5E.Features", - type: Boolean - }, - "remarkableAthlete": { - name: "SW5E.FlagsRemarkableAthlete", - hint: "SW5E.FlagsRemarkableAthleteHint", - abilities: ['str','dex','con'], - section: "SW5E.Features", - type: Boolean - }, - "weaponCriticalThreshold": { - name: "SW5E.FlagsWeaponCritThreshold", - hint: "SW5E.FlagsWeaponCritThresholdHint", - section: "SW5E.Features", - type: Number, - placeholder: 20 - }, - "powerCriticalThreshold": { - name: "SW5E.FlagsPowerCritThreshold", - hint: "SW5E.FlagsPowerCritThresholdHint", - section: "SW5E.Features", - type: Number, - placeholder: 20 - }, - "meleeCriticalDamageDice": { - name: "SW5E.FlagsMeleeCriticalDice", - hint: "SW5E.FlagsMeleeCriticalDiceHint", - section: "SW5E.Features", - type: Number, - placeholder: 0 - } + adaptiveResilience: { + name: "SW5E.FlagsAdaptiveResilience", + hint: "SW5E.FlagsAdaptiveResilienceHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + aggressive: { + name: "SW5E.FlagsAggressive", + hint: "SW5E.FlagsAggressiveHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + amphibious: { + name: "SW5E.FlagsAmphibious", + hint: "SW5E.FlagsAmphibiousHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + armorIntegration: { + name: "SW5E.FlagsArmorIntegration", + hint: "SW5E.FlagsArmorIntegrationHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + businessSavvy: { + name: "SW5E.FlagsBusinessSavvy", + hint: "SW5E.FlagsBusinessSavvyHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + cannibalize: { + name: "SW5E.FlagsCannibalize", + hint: "SW5E.FlagsCannibalizeHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + closedMind: { + name: "SW5E.FlagsClosedMind", + hint: "SW5E.FlagsClosedMindHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + crudeWeaponSpecialists: { + name: "SW5E.FlagsCrudeWeaponSpecialists", + hint: "SW5E.FlagsCrudeWeaponSpecialistsHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + defiant: { + name: "SW5E.FlagsDefiant", + hint: "SW5E.FlagsDefiantHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + detailOriented: { + name: "SW5E.FlagsDetailOriented", + hint: "SW5E.FlagsDetailOrientedHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + enthrallingPheromones: { + name: "SW5E.FlagsEnthrallingPheromones", + hint: "SW5E.FlagsEnthrallingPheromonesHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + extraArms: { + name: "SW5E.FlagsExtraArms", + hint: "SW5E.FlagsExtraArmsHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + forceContention: { + name: "SW5E.FlagsForceContention", + hint: "SW5E.FlagsForceContentionHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + forceInsensitive: { + name: "SW5E.FlagsForceInsensitive", + hint: "SW5E.FlagsForceInsensitiveHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + foreignBiology: { + name: "SW5E.FlagsForeignBiology", + hint: "SW5E.FlagsForeignBiologyHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + furyOfTheSmall: { + name: "SW5E.FlagsFuryOfTheSmall", + hint: "SW5E.FlagsFuryOfTheSmallHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + grovelCowerAndBeg: { + name: "SW5E.FlagsGrovelCowerAndBeg", + hint: "SW5E.FlagsGrovelCowerAndBegHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + inscrutable: { + name: "SW5E.FlagsInscrutable", + hint: "SW5E.FlagsInscrutableHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + keenSenses: { + name: "SW5E.FlagsKeenSenses", + hint: "SW5E.FlagsKeenSensesHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + longlimbed: { + name: "SW5E.FlagsLongLimbed", + hint: "SW5E.FlagsLongLimbedHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + maintenanceMode: { + name: "SW5E.FlagsMaintenanceMode", + hint: "SW5E.FlagsMaintenanceModeHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + maskOfTheWild: { + name: "SW5E.FlagsMaskOfTheWild", + hint: "SW5E.FlagsMaskOfTheWildHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + multipleHearts: { + name: "SW5E.FlagsMultipleHearts", + hint: "SW5E.FlagsMultipleHeartsHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + naturallyStealthy: { + name: "SW5E.FlagsNaturallyStealthy", + hint: "SW5E.FlagsNaturallyStealthyHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + nimbleAgility: { + name: "SW5E.FlagsNimbleAgility", + hint: "SW5E.FlagsNimbleAgilityHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + nimbleEscape: { + name: "SW5E.FlagsNimbleEscape", + hint: "SW5E.FlagsNimbleEscapeHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + nimbleness: { + name: "SW5E.FlagsNimbleness", + hint: "SW5E.FlagsNimblenessHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + pintsized: { + name: "SW5E.FlagsPintsized", + hint: "SW5E.FlagsPintsizedHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + powerfulBuild: { + name: "SW5E.FlagsPowerfulBuild", + hint: "SW5E.FlagsPowerfulBuildHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + precognition: { + name: "SW5E.FlagsPrecognition", + hint: "SW5E.FlagsPrecognitionHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + programmer: { + name: "SW5E.FlagsProgrammer", + hint: "SW5E.FlagsProgrammerHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + puny: { + name: "SW5E.FlagsPuny", + hint: "SW5E.FlagsPunyHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + rapidReconstruction: { + name: "SW5E.FlagsRapidReconstruction", + hint: "SW5E.FlagsRapidReconstructionHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + rapidlyRegenerative: { + name: "SW5E.FlagsRapidlyRegenerative", + hint: "SW5E.FlagsRapidlyRegenerativeHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + regenerative: { + name: "SW5E.FlagsRegenerative", + hint: "SW5E.FlagsRegenerativeHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + savageAttacks: { + name: "SW5E.FlagsSavageAttacks", + hint: "SW5E.FlagsSavageAttacksHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + shapechanger: { + name: "SW5E.FlagsShapechanger", + hint: "SW5E.FlagsShapechangerHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + strongLegged: { + name: "SW5E.FlagsStrongLegged", + hint: "SW5E.FlagsStrongLeggedHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + sunlightSensitivity: { + name: "SW5E.FlagsSunlightSensitivity", + hint: "SW5E.FlagsSunlightSensitivityHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + surpriseAttack: { + name: "SW5E.FlagsSurpriseAttack", + hint: "SW5E.FlagsSurpriseAttackHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + techImpaired: { + name: "SW5E.FlagsTechImpaired", + hint: "SW5E.FlagsTechImpairedHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + techResistance: { + name: "SW5E.FlagsTechResistance", + hint: "SW5E.FlagsTechResistanceHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + tinker: { + name: "SW5E.FlagsTinker", + hint: "SW5E.FlagsTinkerHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + toughness: { + name: "SW5E.FlagsToughness", + hint: "SW5E.FlagsToughnessHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + trance: { + name: "SW5E.FlagsTrance", + hint: "SW5E.FlagsTranceHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + unarmedCombatant: { + name: "SW5E.FlagsUnarmedCombatant", + hint: "SW5E.FlagsUnarmedCombatantHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + undersized: { + name: "SW5E.FlagsUndersized", + hint: "SW5E.FlagsUndersizedHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + unsettlingVisage: { + name: "SW5E.FlagsUnsettlingVisage", + hint: "SW5E.FlagsUnsettlingVisageHint", + section: "SW5E.SpeciesTraits", + type: Boolean + }, + initiativeAdv: { + name: "SW5E.FlagsInitiativeAdv", + hint: "SW5E.FlagsInitiativeAdvHint", + section: "SW5E.Features", + type: Boolean + }, + initiativeAlert: { + name: "SW5E.FlagsAlert", + hint: "SW5E.FlagsAlertHint", + section: "SW5E.Features", + type: Boolean + }, + jackOfAllTrades: { + name: "SW5E.FlagsJOAT", + hint: "SW5E.FlagsJOATHint", + section: "SW5E.Features", + type: Boolean + }, + observantFeat: { + name: "SW5E.FlagsObservant", + hint: "SW5E.FlagsObservantHint", + skills: ["prc", "inv"], + section: "SW5E.Features", + type: Boolean + }, + reliableTalent: { + name: "SW5E.FlagsReliableTalent", + hint: "SW5E.FlagsReliableTalentHint", + section: "SW5E.Features", + type: Boolean + }, + remarkableAthlete: { + name: "SW5E.FlagsRemarkableAthlete", + hint: "SW5E.FlagsRemarkableAthleteHint", + abilities: ["str", "dex", "con"], + section: "SW5E.Features", + type: Boolean + }, + weaponCriticalThreshold: { + name: "SW5E.FlagsWeaponCritThreshold", + hint: "SW5E.FlagsWeaponCritThresholdHint", + section: "SW5E.Features", + type: Number, + placeholder: 20 + }, + powerCriticalThreshold: { + name: "SW5E.FlagsPowerCritThreshold", + hint: "SW5E.FlagsPowerCritThresholdHint", + section: "SW5E.Features", + type: Number, + placeholder: 20 + }, + meleeCriticalDamageDice: { + name: "SW5E.FlagsMeleeCriticalDice", + hint: "SW5E.FlagsMeleeCriticalDiceHint", + section: "SW5E.Features", + type: Number, + placeholder: 0 + } }; // Configure allowed status flags -SW5E.allowedActorFlags = ["isPolymorphed", "originalActor", "dataVersion"].concat(Object.keys(SW5E.characterFlags)); \ No newline at end of file +SW5E.allowedActorFlags = ["isPolymorphed", "originalActor", "dataVersion"].concat(Object.keys(SW5E.characterFlags)); diff --git a/module/dice.js b/module/dice.js index fc70522b..8abc0291 100644 --- a/module/dice.js +++ b/module/dice.js @@ -12,50 +12,55 @@ export {default as DamageRoll} from "./dice/damage-roll.js"; * @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; + 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; + // 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 + 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 (term instanceof OperatorTerm) 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. + for (let term of terms) { + // For each term + if (term instanceof OperatorTerm) 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.getFormula(constantTerms); // Cleans up the constant terms and produces a new formula string - const rollableFormula = Roll.getFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string + const constantFormula = Roll.getFormula(constantTerms); // Cleans up the constant terms and produces a new formula string + const rollableFormula = Roll.getFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string - // Mathematically evaluate the constant formula to produce a single constant term - let constantPart = undefined; - if ( constantFormula ) { - try { - constantPart = Roll.safeEval(constantFormula) - } catch (err) { - console.warn(`Unable to evaluate constant term ${constantFormula} in simplifyRollFormula`); + // Mathematically evaluate the constant formula to produce a single constant term + let constantPart = undefined; + if (constantFormula) { + try { + constantPart = Roll.safeEval(constantFormula); + } catch (err) { + console.warn(`Unable to evaluate constant term ${constantFormula} in simplifyRollFormula`); + } } - } - // Order the rollable and constant terms, either constant first or second depending on the optional argument - const parts = constantFirst ? [constantPart, rollableFormula] : [rollableFormula, constantPart]; + // Order the rollable and constant terms, either constant first or second depending on the optional argument + const parts = constantFirst ? [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; + // Join the parts with a + sign, pass them to `Roll` once again to clean up the formula + return new Roll(parts.filterJoin(" + ")).formula; } /* -------------------------------------------- */ @@ -66,11 +71,11 @@ export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) * @return {Boolean} True when unsupported, false if supported */ function _isUnsupportedTerm(term) { - const diceTerm = term instanceof DiceTerm; - const operator = term instanceof OperatorTerm && ["+", "-"].includes(term.operator); - const number = term instanceof NumericTerm; + const diceTerm = term instanceof DiceTerm; + const operator = term instanceof OperatorTerm && ["+", "-"].includes(term.operator); + const number = term instanceof NumericTerm; - return !(diceTerm || operator || number); + return !(diceTerm || operator || number); } /* -------------------------------------------- */ @@ -111,54 +116,75 @@ function _isUnsupportedTerm(term) { * @return {Promise} The evaluated D20Roll, or null if the workflow was cancelled */ export async function d20Roll({ - parts=[], data={}, // Roll creation - advantage, disadvantage, fumble=1, critical=20, targetValue, elvenAccuracy, halflingLucky, reliableTalent, // Roll customization - chooseModifier=false, fastForward=false, event, template, title, dialogOptions, // Dialog configuration - chatMessage=true, messageData={}, rollMode, speaker, flavor // Chat Message customization - }={}) { - - // Handle input arguments - const formula = ["1d20"].concat(parts).join(" + "); - const {advantageMode, isFF} = _determineAdvantageMode({advantage, disadvantage, fastForward, event}); - const defaultRollMode = rollMode || game.settings.get("core", "rollMode"); - if ( chooseModifier && !isFF ) data["mod"] = "@mod"; - - // Construct the D20Roll instance - const roll = new CONFIG.Dice.D20Roll(formula, data, { - flavor: flavor || title, - advantageMode, - defaultRollMode, - critical, - fumble, + parts = [], + data = {}, // Roll creation + advantage, + disadvantage, + fumble = 1, + critical = 20, targetValue, elvenAccuracy, halflingLucky, - reliableTalent - }); + reliableTalent, // Roll customization + chooseModifier = false, + fastForward = false, + event, + template, + title, + dialogOptions, // Dialog configuration + chatMessage = true, + messageData = {}, + rollMode, + speaker, + flavor // Chat Message customization +} = {}) { + // Handle input arguments + const formula = ["1d20"].concat(parts).join(" + "); + const {advantageMode, isFF} = _determineAdvantageMode({advantage, disadvantage, fastForward, event}); + const defaultRollMode = rollMode || game.settings.get("core", "rollMode"); + if (chooseModifier && !isFF) data["mod"] = "@mod"; - // Prompt a Dialog to further configure the D20Roll - if ( !isFF ) { - const configured = await roll.configureDialog({ - title, - chooseModifier, - defaultRollMode: defaultRollMode, - defaultAction: advantageMode, - defaultAbility: data?.item?.ability, - template - }, dialogOptions); - if ( configured === null ) return null; - } + // Construct the D20Roll instance + const roll = new CONFIG.Dice.D20Roll(formula, data, { + flavor: flavor || title, + advantageMode, + defaultRollMode, + critical, + fumble, + targetValue, + elvenAccuracy, + halflingLucky, + reliableTalent + }); - // Evaluate the configured roll - await roll.evaluate({async: true}); + // Prompt a Dialog to further configure the D20Roll + if (!isFF) { + const configured = await roll.configureDialog( + { + title, + chooseModifier, + defaultRollMode: defaultRollMode, + defaultAction: advantageMode, + defaultAbility: data?.item?.ability, + template + }, + dialogOptions + ); + if (configured === null) return null; + } - // Create a Chat Message - if ( speaker ) { - console.warn(`You are passing the speaker argument to the d20Roll function directly which should instead be passed as an internal key of messageData`); - messageData.speaker = speaker; - } - if ( roll && chatMessage ) await roll.toMessage(messageData); - return roll; + // Evaluate the configured roll + await roll.evaluate({async: true}); + + // Create a Chat Message + if (speaker) { + console.warn( + `You are passing the speaker argument to the d20Roll function directly which should instead be passed as an internal key of messageData` + ); + messageData.speaker = speaker; + } + if (roll && chatMessage) await roll.toMessage(messageData); + return roll; } /* -------------------------------------------- */ @@ -167,12 +193,13 @@ export async function d20Roll({ * Determines whether this d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied * @returns {{isFF: boolean, advantageMode: number}} Whether the roll is fast-forward, and its advantage mode */ -function _determineAdvantageMode({event, advantage=false, disadvantage=false, fastForward=false}={}) { - const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); - let advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.NORMAL; - if ( advantage || event?.altKey ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE; - else if ( disadvantage || event?.ctrlKey || event?.metaKey ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE; - return {isFF, advantageMode}; +function _determineAdvantageMode({event, advantage = false, disadvantage = false, fastForward = false} = {}) { + const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); + let advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.NORMAL; + if (advantage || event?.altKey) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE; + else if (disadvantage || event?.ctrlKey || event?.metaKey) + advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE; + return {isFF, advantageMode}; } /* -------------------------------------------- */ @@ -210,49 +237,67 @@ function _determineAdvantageMode({event, advantage=false, disadvantage=false, fa * @return {Promise} The evaluated DamageRoll, or null if the workflow was canceled */ export async function damageRoll({ - parts=[], data, // Roll creation - critical=false, criticalBonusDice, criticalMultiplier, multiplyNumeric, powerfulCritical, // Damage customization - fastForward=false, event, allowCritical=true, template, title, dialogOptions, // Dialog configuration - chatMessage=true, messageData={}, rollMode, speaker, flavor, // Chat Message customization - }={}) { - - // Handle input arguments - const defaultRollMode = rollMode || game.settings.get("core", "rollMode"); - - // Construct the DamageRoll instance - const formula = parts.join(" + "); - const {isCritical, isFF} = _determineCriticalMode({critical, fastForward, event}); - const roll = new CONFIG.Dice.DamageRoll(formula, data, { - flavor: flavor || title, - critical: isCritical, + parts = [], + data, // Roll creation + critical = false, criticalBonusDice, criticalMultiplier, multiplyNumeric, - powerfulCritical - }); + powerfulCritical, // Damage customization + fastForward = false, + event, + allowCritical = true, + template, + title, + dialogOptions, // Dialog configuration + chatMessage = true, + messageData = {}, + rollMode, + speaker, + flavor // Chat Message customization +} = {}) { + // Handle input arguments + const defaultRollMode = rollMode || game.settings.get("core", "rollMode"); - // Prompt a Dialog to further configure the DamageRoll - if ( !isFF ) { - const configured = await roll.configureDialog({ - title, - defaultRollMode: defaultRollMode, - defaultCritical: isCritical, - template, - allowCritical - }, dialogOptions); - if ( configured === null ) return null; - } + // Construct the DamageRoll instance + const formula = parts.join(" + "); + const {isCritical, isFF} = _determineCriticalMode({critical, fastForward, event}); + const roll = new CONFIG.Dice.DamageRoll(formula, data, { + flavor: flavor || title, + critical: isCritical, + criticalBonusDice, + criticalMultiplier, + multiplyNumeric, + powerfulCritical + }); - // Evaluate the configured roll - await roll.evaluate({async: true}); + // Prompt a Dialog to further configure the DamageRoll + if (!isFF) { + const configured = await roll.configureDialog( + { + title, + defaultRollMode: defaultRollMode, + defaultCritical: isCritical, + template, + allowCritical + }, + dialogOptions + ); + if (configured === null) return null; + } - // Create a Chat Message - if ( speaker ) { - console.warn(`You are passing the speaker argument to the damageRoll function directly which should instead be passed as an internal key of messageData`); - messageData.speaker = speaker; - } - if ( roll && chatMessage ) await roll.toMessage(messageData); - return roll; + // Evaluate the configured roll + await roll.evaluate({async: true}); + + // Create a Chat Message + if (speaker) { + console.warn( + `You are passing the speaker argument to the damageRoll function directly which should instead be passed as an internal key of messageData` + ); + messageData.speaker = speaker; + } + if (roll && chatMessage) await roll.toMessage(messageData); + return roll; } /* -------------------------------------------- */ @@ -261,8 +306,8 @@ export async function damageRoll({ * Determines whether this d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied * @returns {{isFF: boolean, isCritical: boolean}} Whether the roll is fast-forward, and whether it is a critical hit */ -function _determineCriticalMode({event, critical=false, fastForward=false}={}) { - const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); - if ( event?.altKey ) critical = true; - return {isFF, isCritical: critical}; +function _determineCriticalMode({event, critical = false, fastForward = false} = {}) { + const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); + if (event?.altKey) critical = true; + return {isFF, isCritical: critical}; } diff --git a/module/dice/d20-roll.js b/module/dice/d20-roll.js index c4b40824..fb8a18b0 100644 --- a/module/dice/d20-roll.js +++ b/module/dice/d20-roll.js @@ -16,7 +16,7 @@ export default class D20Roll extends Roll { constructor(formula, data, options) { super(formula, data, options); - if ( !((this.terms[0] instanceof Die) && (this.terms[0].faces === 20)) ) { + if (!(this.terms[0] instanceof Die && this.terms[0].faces === 20)) { throw new Error(`Invalid D20Roll formula provided ${this._formula}`); } this.configureModifiers(); @@ -31,8 +31,8 @@ export default class D20Roll extends Roll { static ADV_MODE = { NORMAL: 0, ADVANTAGE: 1, - DISADVANTAGE: -1, - } + DISADVANTAGE: -1 + }; /** * The HTML template path used to configure evaluation of this Roll @@ -71,28 +71,26 @@ export default class D20Roll extends Roll { d20.modifiers = []; // Halfling Lucky - if ( this.options.halflingLucky ) d20.modifiers.push("r1=1"); + if (this.options.halflingLucky) d20.modifiers.push("r1=1"); // Reliable Talent - if ( this.options.reliableTalent ) d20.modifiers.push("min10"); + if (this.options.reliableTalent) d20.modifiers.push("min10"); // Handle Advantage or Disadvantage - if ( this.hasAdvantage ) { + if (this.hasAdvantage) { d20.number = this.options.elvenAccuracy ? 3 : 2; d20.modifiers.push("kh"); d20.options.advantage = true; - } - else if ( this.hasDisadvantage ) { + } else if (this.hasDisadvantage) { d20.number = 2; d20.modifiers.push("kl"); d20.options.disadvantage = true; - } - else d20.number = 1; + } else d20.number = 1; // Assign critical and fumble thresholds - if ( this.options.critical ) d20.options.critical = this.options.critical; - if ( this.options.fumble ) d20.options.fumble = this.options.fumble; - if ( this.options.targetValue ) d20.options.target = this.options.targetValue; + if (this.options.critical) d20.options.critical = this.options.critical; + if (this.options.fumble) d20.options.fumble = this.options.fumble; + if (this.options.targetValue) d20.options.target = this.options.targetValue; // Re-compile the underlying formula this._formula = this.constructor.getFormula(this.terms); @@ -101,22 +99,21 @@ export default class D20Roll extends Roll { /* -------------------------------------------- */ /** @inheritdoc */ - async toMessage(messageData={}, options={}) { - + async toMessage(messageData = {}, options = {}) { // Evaluate the roll now so we have the results available to determine whether reliable talent came into play - if ( !this._evaluated ) await this.evaluate({async: true}); + if (!this._evaluated) await this.evaluate({async: true}); // Add appropriate advantage mode message flavor and sw5e roll flags messageData.flavor = messageData.flavor || this.options.flavor; - if ( this.hasAdvantage ) messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`; - else if ( this.hasDisadvantage ) messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`; + if (this.hasAdvantage) messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`; + else if (this.hasDisadvantage) messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`; // Add reliable talent to the d20-term flavor text if it applied - if ( this.options.reliableTalent ) { + if (this.options.reliableTalent) { const d20 = this.dice[0]; - const isRT = d20.results.every(r => !r.active || (r.result < 10)); + const isRT = d20.results.every((r) => !r.active || r.result < 10); const label = `(${game.i18n.localize("SW5E.FlagsReliableTalent")})`; - if ( isRT ) d20.options.flavor = d20.options.flavor ? `${d20.options.flavor} (${label})` : label; + if (isRT) d20.options.flavor = d20.options.flavor ? `${d20.options.flavor} (${label})` : label; } // Record the preferred rollMode @@ -140,8 +137,17 @@ export default class D20Roll extends Roll { * @param {object} options Additional Dialog customization options * @returns {Promise} A resulting D20Roll object constructed with the dialog, or null if the dialog was closed */ - async configureDialog({title, defaultRollMode, defaultAction=D20Roll.ADV_MODE.NORMAL, chooseModifier=false, defaultAbility, template}={}, options={}) { - + async configureDialog( + { + title, + defaultRollMode, + defaultAction = D20Roll.ADV_MODE.NORMAL, + chooseModifier = false, + defaultAbility, + template + } = {}, + options = {} + ) { // Render the Dialog inner HTML const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, { formula: `${this.formula} + @bonus`, @@ -154,32 +160,39 @@ export default class D20Roll extends Roll { let defaultButton = "normal"; switch (defaultAction) { - case D20Roll.ADV_MODE.ADVANTAGE: defaultButton = "advantage"; break; - case D20Roll.ADV_MODE.DISADVANTAGE: defaultButton = "disadvantage"; break; + case D20Roll.ADV_MODE.ADVANTAGE: + defaultButton = "advantage"; + break; + case D20Roll.ADV_MODE.DISADVANTAGE: + defaultButton = "disadvantage"; + break; } // Create the Dialog window and await submission of the form - return new Promise(resolve => { - new Dialog({ - title, - content, - buttons: { - advantage: { - label: game.i18n.localize("SW5E.Advantage"), - callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.ADVANTAGE)) + return new Promise((resolve) => { + new Dialog( + { + title, + content, + buttons: { + advantage: { + label: game.i18n.localize("SW5E.Advantage"), + callback: (html) => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.ADVANTAGE)) + }, + normal: { + label: game.i18n.localize("SW5E.Normal"), + callback: (html) => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.NORMAL)) + }, + disadvantage: { + label: game.i18n.localize("SW5E.Disadvantage"), + callback: (html) => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.DISADVANTAGE)) + } }, - normal: { - label: game.i18n.localize("SW5E.Normal"), - callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.NORMAL)) - }, - disadvantage: { - label: game.i18n.localize("SW5E.Disadvantage"), - callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.DISADVANTAGE)) - } + default: defaultButton, + close: () => resolve(null) }, - default: defaultButton, - close: () => resolve(null) - }, options).render(true); + options + ).render(true); }); } @@ -195,16 +208,16 @@ export default class D20Roll extends Roll { const form = html[0].querySelector("form"); // Append a situational bonus term - if ( form.bonus.value ) { + if (form.bonus.value) { const bonus = new Roll(form.bonus.value, this.data); - if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"})); + if (!(bonus.terms[0] instanceof OperatorTerm)) this.terms.push(new OperatorTerm({operator: "+"})); this.terms = this.terms.concat(bonus.terms); } // Customize the modifier - if ( form.ability?.value ) { + if (form.ability?.value) { const abl = this.data.abilities[form.ability.value]; - this.terms.findSplice(t => t.term === "@mod", new NumericTerm({number: abl.mod})); + this.terms.findSplice((t) => t.term === "@mod", new NumericTerm({number: abl.mod})); this.options.flavor += ` (${CONFIG.SW5E.abilities[form.ability.value]})`; } diff --git a/module/dice/damage-roll.js b/module/dice/damage-roll.js index cc901fb2..41545c8b 100644 --- a/module/dice/damage-roll.js +++ b/module/dice/damage-roll.js @@ -13,7 +13,7 @@ export default class DamageRoll extends Roll { constructor(formula, data, options) { super(formula, data, options); // For backwards compatibility, skip rolls which do not have the "critical" option defined - if ( this.options.critical !== undefined ) this.configureDamage(); + if (this.options.critical !== undefined) this.configureDamage(); } /** @@ -42,44 +42,44 @@ export default class DamageRoll extends Roll { */ configureDamage() { let flatBonus = 0; - for ( let [i, term] of this.terms.entries() ) { - + for (let [i, term] of this.terms.entries()) { // Multiply dice terms - if ( term instanceof DiceTerm ) { + if (term instanceof DiceTerm) { term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back term.number = term.options.baseNumber; - if ( this.isCritical ) { + if (this.isCritical) { let cm = this.options.criticalMultiplier ?? 2; // Powerful critical - maximize damage and reduce the multiplier by 1 - if ( this.options.powerfulCritical ) { - flatBonus += (term.number * term.faces); - cm = Math.max(1, cm-1); + if (this.options.powerfulCritical) { + flatBonus += term.number * term.faces; + cm = Math.max(1, cm - 1); } // Alter the damage term - let cb = (this.options.criticalBonusDice && (i === 0)) ? this.options.criticalBonusDice : 0; + let cb = this.options.criticalBonusDice && i === 0 ? this.options.criticalBonusDice : 0; term.alter(cm, cb); term.options.critical = true; } - } // Multiply numeric terms - else if ( this.options.multiplyNumeric && (term instanceof NumericTerm) ) { + else if (this.options.multiplyNumeric && term instanceof NumericTerm) { term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back term.number = term.options.baseNumber; - if ( this.isCritical ) { - term.number *= (this.options.criticalMultiplier ?? 2); + if (this.isCritical) { + term.number *= this.options.criticalMultiplier ?? 2; term.options.critical = true; } } } // Add powerful critical bonus - if ( this.options.powerfulCritical && (flatBonus > 0) ) { + if (this.options.powerfulCritical && flatBonus > 0) { this.terms.push(new OperatorTerm({operator: "+"})); - this.terms.push(new NumericTerm({number: flatBonus}, {flavor: game.i18n.localize("SW5E.PowerfulCritical")})); + this.terms.push( + new NumericTerm({number: flatBonus}, {flavor: game.i18n.localize("SW5E.PowerfulCritical")}) + ); } // Re-compile the underlying formula @@ -89,9 +89,9 @@ export default class DamageRoll extends Roll { /* -------------------------------------------- */ /** @inheritdoc */ - toMessage(messageData={}, options={}) { + toMessage(messageData = {}, options = {}) { messageData.flavor = messageData.flavor || this.options.flavor; - if ( this.isCritical ) { + if (this.isCritical) { const label = game.i18n.localize("SW5E.CriticalHit"); messageData.flavor = messageData.flavor ? `${messageData.flavor} (${label})` : label; } @@ -114,34 +114,39 @@ export default class DamageRoll extends Roll { * @param {object} options Additional Dialog customization options * @returns {Promise} A resulting D20Roll object constructed with the dialog, or null if the dialog was closed */ - async configureDialog({title, defaultRollMode, defaultCritical=false, template, allowCritical=true}={}, options={}) { - + async configureDialog( + {title, defaultRollMode, defaultCritical = false, template, allowCritical = true} = {}, + options = {} + ) { // Render the Dialog inner HTML const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, { formula: `${this.formula} + @bonus`, defaultRollMode, - rollModes: CONFIG.Dice.rollModes, + rollModes: CONFIG.Dice.rollModes }); // Create the Dialog window and await submission of the form - return new Promise(resolve => { - new Dialog({ - title, - content, - buttons: { - critical: { - condition: allowCritical, - label: game.i18n.localize("SW5E.CriticalHit"), - callback: html => resolve(this._onDialogSubmit(html, true)) + return new Promise((resolve) => { + new Dialog( + { + title, + content, + buttons: { + critical: { + condition: allowCritical, + label: game.i18n.localize("SW5E.CriticalHit"), + callback: (html) => resolve(this._onDialogSubmit(html, true)) + }, + normal: { + label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"), + callback: (html) => resolve(this._onDialogSubmit(html, false)) + } }, - normal: { - label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"), - callback: html => resolve(this._onDialogSubmit(html, false)) - } + default: defaultCritical ? "critical" : "normal", + close: () => resolve(null) }, - default: defaultCritical ? "critical" : "normal", - close: () => resolve(null) - }, options).render(true); + options + ).render(true); }); } @@ -157,9 +162,9 @@ export default class DamageRoll extends Roll { const form = html[0].querySelector("form"); // Append a situational bonus term - if ( form.bonus.value ) { + if (form.bonus.value) { const bonus = new Roll(form.bonus.value, this.data); - if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"})); + if (!(bonus.terms[0] instanceof OperatorTerm)) this.terms.push(new OperatorTerm({operator: "+"})); this.terms = this.terms.concat(bonus.terms); } diff --git a/module/effects.js b/module/effects.js index ef66ef77..b825af87 100644 --- a/module/effects.js +++ b/module/effects.js @@ -4,26 +4,28 @@ * @param {Actor|Item} owner The owning entity which manages this effect */ export function onManageActiveEffect(event, owner) { - event.preventDefault(); - const a = event.currentTarget; - const li = a.closest("li"); - const effect = li.dataset.effectId ? owner.effects.get(li.dataset.effectId) : null; - switch ( a.dataset.action ) { - case "create": - return owner.createEmbeddedDocuments("ActiveEffect", [{ - label: game.i18n.localize("SW5E.EffectNew"), - icon: "icons/svg/aura.svg", - origin: owner.uuid, - "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined, - disabled: li.dataset.effectType === "inactive" - }]); - case "edit": - return effect.sheet.render(true); - case "delete": - return effect.delete(); - case "toggle": - return effect.update({disabled: !effect.data.disabled}); - } + event.preventDefault(); + const a = event.currentTarget; + const li = a.closest("li"); + const effect = li.dataset.effectId ? owner.effects.get(li.dataset.effectId) : null; + switch (a.dataset.action) { + case "create": + return owner.createEmbeddedDocuments("ActiveEffect", [ + { + "label": game.i18n.localize("SW5E.EffectNew"), + "icon": "icons/svg/aura.svg", + "origin": owner.uuid, + "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined, + "disabled": li.dataset.effectType === "inactive" + } + ]); + case "edit": + return effect.sheet.render(true); + case "delete": + return effect.delete(); + case "toggle": + return effect.update({disabled: !effect.data.disabled}); + } } /** @@ -32,32 +34,31 @@ export function onManageActiveEffect(event, owner) { * @return {object} Data for rendering */ export function prepareActiveEffectCategories(effects) { - // Define effect header categories const categories = { - temporary: { - type: "temporary", - label: game.i18n.localize("SW5E.EffectTemporary"), - effects: [] - }, - passive: { - type: "passive", - label: game.i18n.localize("SW5E.EffectPassive"), - effects: [] - }, - inactive: { - type: "inactive", - label: game.i18n.localize("SW5E.EffectInactive"), - effects: [] - } + temporary: { + type: "temporary", + label: game.i18n.localize("SW5E.EffectTemporary"), + effects: [] + }, + passive: { + type: "passive", + label: game.i18n.localize("SW5E.EffectPassive"), + effects: [] + }, + inactive: { + type: "inactive", + label: game.i18n.localize("SW5E.EffectInactive"), + effects: [] + } }; // Iterate over active effects, classifying them into categories - for ( let e of effects ) { - e._getSourceName(); // Trigger a lookup for the source name - if ( e.data.disabled ) categories.inactive.effects.push(e); - else if ( e.isTemporary ) categories.temporary.effects.push(e); - else categories.passive.effects.push(e); + for (let e of effects) { + e._getSourceName(); // Trigger a lookup for the source name + if (e.data.disabled) categories.inactive.effects.push(e); + else if (e.isTemporary) categories.temporary.effects.push(e); + else categories.passive.effects.push(e); } return categories; -} \ No newline at end of file +} diff --git a/module/item/entity.js b/module/item/entity.js index 11ffc620..37784699 100644 --- a/module/item/entity.js +++ b/module/item/entity.js @@ -6,306 +6,313 @@ import AbilityUseDialog from "../apps/ability-use-dialog.js"; * @extends {Item} */ export default class Item5e extends Item { + /* -------------------------------------------- */ + /* Item Properties */ + /* -------------------------------------------- */ - /* -------------------------------------------- */ - /* 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; - /** - * 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; - // Case 1 - defined directly by the item - if (itemData.ability) return itemData.ability; + // 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"; + } - // Case 2 - inferred from a parent actor - else if (this.actor) { - const actorData = this.actor.data.data; + // Tools - default to Intelligence + else if (this.data.type === "tool") return "int"; + // Weapons + else if (this.data.type === "weapon") { + const wt = itemData.weaponType; - // 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"; - } - + // Weapons using the powercasting modifier + // No current SW5e weapons use this, but it's worth checking just in case + if (["mpak", "rpak"].includes(itemData.actionType)) { + return actorData.attributes.powercasting || "int"; + } - // Tools - default to Intelligence - else if (this.data.type === "tool") return "int"; + // Finesse weapons - Str or Dex (PHB pg. 147) + else if (itemData.properties.fin === true) { + return actorData.abilities["dex"].mod >= actorData.abilities["str"].mod ? "dex" : "str"; + } - // Weapons - else if (this.data.type === "weapon") { - const wt = itemData.weaponType; - - // Weapons using the powercasting modifier - // No current SW5e weapons use this, but it's worth checking just in case - if (["mpak", "rpak"].includes(itemData.actionType)) { - return actorData.attributes.powercasting || "int"; + // Ranged weapons - Dex (PH p.194) + else if (["simpleB", "martialB"].includes(wt)) return "dex"; + } + return "str"; } - // Finesse weapons - Str or Dex (PHB pg. 147) - else if (itemData.properties.fin === true) { - return (actorData.abilities["dex"].mod >= actorData.abilities["str"].mod) ? "dex" : "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. + */ + prepareDerivedData() { + super.prepareDerivedData(); + + // 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); } - // Ranged weapons - Dex (PH p.194) - else if ( ["simpleB", "martialB"].includes(wt) ) return "dex"; - } - return "str"; + // 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"); + } + + // TODO: Something with all this + // 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]; + } + // Deployment Items + else if (itemData.type === "deployment") { + // labels.deployment = C.deployment[data.deployment]; + } + // Venture Items + else if (itemData.type === "venture") { + // labels.venture = C.venture[data.venture]; + } + // 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 = 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")) { + // 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(", "); + } + } + + // if this item is owned, we prepareFinalAttributes() at the end of actor init + if (!this.isOwned) this.prepareFinalAttributes(); } - // Case 3 - unknown - return null - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Compute item attributes which might depend on prepared actor data. + */ + prepareFinalAttributes() { + if (this.data.data.hasOwnProperty("actionType")) { + // Saving throws + this.getSaveDC(); - /** - * 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); - } + // To Hit + this.getAttackToHit(); - /* -------------------------------------------- */ + // Limited Uses + this.prepareMaxUses(); - /** - * 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. - */ - prepareDerivedData() { - super.prepareDerivedData(); - - // 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); + // Damage Label + this.getDerivedDamageLabel(); + } } - // 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"); - } - - // TODO: Something with all this - // 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]; - } - // Deployment Items - else if ( itemData.type === "deployment" ) { - // labels.deployment = C.deployment[data.deployment]; - } - // Venture Items - else if ( itemData.type === "venture" ) { - // labels.venture = C.venture[data.venture]; - } - // 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 = 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") ) { - // 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(", "); - } - } - - // if this item is owned, we prepareFinalAttributes() at the end of actor init - if (!this.isOwned) this.prepareFinalAttributes(); - } - - /* -------------------------------------------- */ - - /** - * Compute item attributes which might depend on prepared actor data. - */ - prepareFinalAttributes() { - if ( this.data.data.hasOwnProperty("actionType") ) { - // Saving throws - this.getSaveDC(); - - // To Hit - this.getAttackToHit(); - - // Limited Uses - this.prepareMaxUses(); - - // Damage Label - this.getDerivedDamageLabel(); - } - } - /* -------------------------------------------- */ /** @@ -316,1321 +323,1349 @@ export default class Item5e extends Item { * @returns {Array} array of objects with `formula` and `damageType` */ getDerivedDamageLabel() { - const itemData = this.data.data; - if ( !this.hasDamage || !itemData || !this.isOwned ) return []; + const itemData = this.data.data; + if (!this.hasDamage || !itemData || !this.isOwned) return []; - const rollData = this.getRollData(); + const rollData = this.getRollData(); - const derivedDamage = itemData.damage?.parts?.map((damagePart) => ({ - formula: simplifyRollFormula(damagePart[0], rollData, { constantFirst: false }), - damageType: damagePart[1], - })); + const derivedDamage = itemData.damage?.parts?.map((damagePart) => ({ + formula: simplifyRollFormula(damagePart[0], rollData, {constantFirst: false}), + damageType: damagePart[1] + })); - this.labels.derivedDamage = derivedDamage + this.labels.derivedDamage = derivedDamage; - return derivedDamage; + return derivedDamage; } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * 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; + /** + * 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}; - } - - /* -------------------------------------------- */ - - /** - * Populates the max uses of an item. - * If the item is an owned item and the `max` is not numeric, calculate based on actor data. - */ - prepareMaxUses() { - const data = this.data.data; - if (!data.uses?.max) return; - let max = data.uses.max; - - // if this is an owned item and the max is not numeric, we need to calculate it - if (this.isOwned && !Number.isNumeric(max)) { - if (this.actor.data === undefined) return; - try { - max = Roll.replaceFormulaData(max, this.actor.getRollData(), {missing: 0, warn: true}); - max = Roll.safeEval(max); - } catch(e) { - console.error('Problem preparing Max uses for', this.data.name, e); - return; - } - } - data.uses.max = Number(max); - } - - /* -------------------------------------------- */ - - /** - * 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} - */ - async roll({configureDialog=true, rollMode, createMessage=true}={}) { - let item = this; - const id = this.data.data; // Item system data - const actor = this.actor; - const ad = actor.data.data; // Actor system data - - // Reference aspects of the item data necessary for usage - 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? - // We could use this for feats and architypes that let a character cast one slot every rest or so - 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 - let consumePowerLevel = null; // Consume a specific category of power slot - if ( requirePowerSlot ) consumePowerLevel = id.preparation.mode === "pact" ? "pact" : `power${id.level}`; - - // Display a configuration dialog to customize the usage - const needsConfiguration = createMeasuredTemplate || consumeRecharge || (consumeResource && !['simpleB', 'martialB'].includes(id.weaponType)) || consumePowerSlot || (consumeUsage && !['simpleB', 'martialB'].includes(id.weaponType)); - 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 ) { - consumePowerLevel = `power${configuration.level}`; - if (consumePowerSlot === false) consumePowerLevel = null; - const upcastLevel = parseInt(configuration.level); - if (upcastLevel !== id.level) { - item = this.clone({"data.level": upcastLevel}, {keepId: true}); - item.data.update({_id: this.id}); // Retain the original ID (needed until 0.8.2+) - item.prepareFinalAttributes(); // Power save DC, etc... - } - } - } - - // Determine whether the item can be used by testing for resource consumption - const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerLevel, consumeUsage, consumeQuantity}); - if ( !usage ) return; - - const {actorUpdates, itemUpdates, resourceUpdates} = usage; - - // Commit pending data updates - if ( !foundry.utils.isObjectEmpty(itemUpdates) ) await item.update(itemUpdates); - if ( consumeQuantity && (item.data.data.quantity === 0) ) await item.delete(); - if ( !foundry.utils.isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates); - if ( !foundry.utils.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|null} consumePowerLevel The category of power slot to consume, or null - * @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, consumeRecharge, consumeResource, consumePowerLevel, consumeUsage}) { - - // 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 ( consumePowerLevel ) { - if ( Number.isNumeric(consumePowerLevel) ) consumePowerLevel = `power${consumePowerLevel}`; - const level = this.actor?.data.data.powers[consumePowerLevel]; - 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; + // 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; + } } - actorUpdates[`data.powers.${consumePowerLevel}.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; + } + + // 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; + } } - 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; + } + + // 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}; + } + + /* -------------------------------------------- */ + + /** + * Populates the max uses of an item. + * If the item is an owned item and the `max` is not numeric, calculate based on actor data. + */ + prepareMaxUses() { + const data = this.data.data; + if (!data.uses?.max) return; + let max = data.uses.max; + + // if this is an owned item and the max is not numeric, we need to calculate it + if (this.isOwned && !Number.isNumeric(max)) { + if (this.actor.data === undefined) return; + try { + max = Roll.replaceFormulaData(max, this.actor.getRollData(), {missing: 0, warn: true}); + max = Roll.safeEval(max); + } catch (e) { + console.error("Problem preparing Max uses for", this.data.name, e); + return; } - actorUpdates[`data.powers.${consumePowerLevel}.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; + } + data.uses.max = Number(max); + } + + /* -------------------------------------------- */ + + /** + * 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} + */ + async roll({configureDialog = true, rollMode, createMessage = true} = {}) { + let item = this; + const id = this.data.data; // Item system data + const actor = this.actor; + const ad = actor.data.data; // Actor system data + + // Reference aspects of the item data necessary for usage + 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? + // We could use this for feats and architypes that let a character cast one slot every rest or so + 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 + let consumePowerLevel = null; // Consume a specific category of power slot + if (requirePowerSlot) consumePowerLevel = id.preparation.mode === "pact" ? "pact" : `power${id.level}`; + + // Display a configuration dialog to customize the usage + const needsConfiguration = + createMeasuredTemplate || + consumeRecharge || + (consumeResource && !["simpleB", "martialB"].includes(id.weaponType)) || + consumePowerSlot || + (consumeUsage && !["simpleB", "martialB"].includes(id.weaponType)); + 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) { + consumePowerLevel = `power${configuration.level}`; + if (consumePowerSlot === false) consumePowerLevel = null; + const upcastLevel = parseInt(configuration.level); + if (upcastLevel !== id.level) { + item = this.clone({"data.level": upcastLevel}, {keepId: true}); + item.data.update({_id: this.id}); // Retain the original ID (needed until 0.8.2+) + item.prepareFinalAttributes(); // Power save DC, etc... + } } - 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}={}) { - - // Render the chat card template - const token = this.actor.token; - const templateData = { - actor: this.actor, - tokenId: token?.uuid || 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, - isTool: this.data.type === "tool" - }; - const html = await renderTemplate("systems/sw5e/templates/chat/item-card.html", templateData); - - // Create the ChatMessage data object - const chatData = { - user: game.user.data._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 = foundry.utils.deepClone(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} 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 ( 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} 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} 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} 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} 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")}`; - - // Add global actor bonus - const bonuses = getProperty(this.actor.data.data, "bonuses.abilities") || {}; - if ( bonuses.check ) { - parts.push("@checkBonus"); - rollData.checkBonus = bonuses.check; - } - - // Compose the roll data - const rollConfig = mergeObject({ - parts: parts, - data: rollData, - 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, - }, - chooseModifier: true, - 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 = foundry.utils.deepClone(this.data.data); - - // Include an ability score modifier if one exists - const abl = this.abilityMod; - if ( abl ) { - const ability = rollData.abilities[abl]; - if ( !ability ) { - console.warn(`Item ${this.name} in Actor ${this.actor.name} has an invalid item ability modifier of ${abl} defined`); - } - 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 = await 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 ? new this(storedData, {parent: actor}) : actor.items.get(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" + // Determine whether the item can be used by testing for resource consumption + const usage = item._getUsageUpdates({ + consumeRecharge, + consumeResource, + consumePowerLevel, + consumeUsage, + consumeQuantity }); - 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 }); + if (!usage) return; + + const {actorUpdates, itemUpdates, resourceUpdates} = usage; + + // Commit pending data updates + if (!foundry.utils.isObjectEmpty(itemUpdates)) await item.update(itemUpdates); + if (consumeQuantity && item.data.data.quantity === 0) await item.delete(); + if (!foundry.utils.isObjectEmpty(actorUpdates)) await actor.update(actorUpdates); + if (!foundry.utils.isObjectEmpty(resourceUpdates)) { + const resource = actor.items.get(id.consume?.target); + if (resource) await resource.update(resourceUpdates); } - break; - case "toolCheck": - await item.rollToolCheck({event}); break; - case "placeTemplate": - const template = game.sw5e.canvas.AbilityTemplate.fromItem(item); - if ( template ) template.drawPreview(); - break; + + // 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}); } - // Re-enable the button - button.disabled = false; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * 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|null} consumePowerLevel The category of power slot to consume, or null + * @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, consumeRecharge, consumeResource, consumePowerLevel, consumeUsage}) { + // Reference item data + const id = this.data.data; + const actorUpdates = {}; + const itemUpdates = {}; + const resourceUpdates = {}; - /** - * 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"; - } + // 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; + } - /** - * 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 async _getChatCardActor(card) { + // Consume Power Slots and Force/Tech Points + if (consumePowerLevel) { + if (Number.isNumeric(consumePowerLevel)) consumePowerLevel = `power${consumePowerLevel}`; + const level = this.actor?.data.data.powers[consumePowerLevel]; + 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.${consumePowerLevel}.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.${consumePowerLevel}.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; + } + } + } + } - // Case 1 - a synthetic actor from a Token - if ( card.dataset.tokenId ) { - const token = await fromUuid(card.dataset.tokenId); - if ( !token ) return null; - return token.actor; + // 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}; } - // Case 2 - use Actor ID directory - const actorId = card.dataset.actorId; - return game.actors.get(actorId) || null; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * 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; - /** - * Get the Actor which is the author of a chat card - * @param {HTMLElement} card The chat card being used - * @return {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; - } + // 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; + } - /* -------------------------------------------- */ - /* Event Handlers */ - /* -------------------------------------------- */ + // 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; + } - /** @inheritdoc */ - async _preCreate(data, options, user) { - await super._preCreate(data, options, user); - if ( !this.isEmbedded || (this.parent.type === "vehicle") ) return; - const actorData = this.parent.data; - const isNPC = this.parent.type === "npc"; - let updates; - switch (data.type) { - case "equipment": - updates = this._onCreateOwnedEquipment(data, actorData, isNPC); - break; - case "weapon": - updates = this._onCreateOwnedWeapon(data, actorData, isNPC); - break; - case "power": - updates = this._onCreateOwnedPower(data, actorData, isNPC); - 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; + } } - if (updates) return this.data.update(updates); - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** @inheritdoc */ - _onCreate(data, options, userId) { - super._onCreate(data, options, userId); + /** + * 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} = {}) { + // Render the chat card template + const token = this.actor.token; + const templateData = { + actor: this.actor, + tokenId: token?.uuid || 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, + isTool: this.data.type === "tool" + }; + const html = await renderTemplate("systems/sw5e/templates/chat/item-card.html", templateData); - // The below options are only needed for character classes - if ( userId !== game.user.id ) return; - const isCharacterClass = this.parent && (this.parent.type !== "vehicle") && (this.type === "class"); - if ( !isCharacterClass ) return; + // Create the ChatMessage data object + const chatData = { + user: game.user.data._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} + }; - // Assign a new primary class - const pc = this.parent.items.get(this.parent.data.data.details.originalClass); - if ( !pc ) this.parent._assignPrimaryClass(); + // 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; + } - // Prompt to add new class features - if (options.addFeatures === false) return; - this.parent.getClassFeatures({ - className: this.name, - archetypeName: this.data.data.archetype, - level: this.data.data.levels - }).then(features => { - return this.parent.addEmbeddedItems(features, options.promptAddFeatures); - }); - } + // Apply the roll mode to adjust message visibility + ChatMessage.applyRollMode(chatData, rollMode || game.settings.get("core", "rollMode")); - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onUpdate(changed, options, userId) { - super._onUpdate(changed, options, userId); - - // The below options are only needed for character classes - if ( userId !== game.user.id ) return; - const isCharacterClass = this.parent && (this.parent.type !== "vehicle") && (this.type === "class"); - if ( !isCharacterClass ) return; - - // Prompt to add new class features - const addFeatures = changed["name"] || (changed.data && ["archetype", "levels"].some(k => k in changed.data)); - if ( !addFeatures || (options.addFeatures === false) ) return; - this.parent.getClassFeatures({ - className: changed.name || this.name, - archetypeName: changed.data?.archetype || this.data.data.archetype, - level: changed.data?.levels || this.data.data.levels - }).then(features => { - return this.parent.addEmbeddedItems(features, options.promptAddFeatures); - }); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - _onDelete(options, userId) { - super._onDelete(options, userId); - - // Assign a new primary class - if ( this.parent && (this.type === "class") && (userId === game.user.id) ) { - if ( this.id !== this.parent.data.data.details.originalClass ) return; - this.parent._assignPrimaryClass(); + // Create the Chat Message or return its data + return createMessage ? ChatMessage.create(chatData) : chatData; } - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ + /* Chat Cards */ + /* -------------------------------------------- */ - /** - * Pre-creation logic for the automatic configuration of owned equipment type Items - * @private - */ - _onCreateOwnedEquipment(data, actorData, isNPC) { - const updates = {}; - if ( foundry.utils.getProperty(data, "data.equipped") === undefined ) { - updates["data.equipped"] = isNPC; // NPCs automatically equip equipment + /** + * 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 = foundry.utils.deepClone(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; } - if ( foundry.utils.getProperty(data, "data.proficient") === undefined ) { - if ( isNPC ) { - updates["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - const armorProf = CONFIG.SW5E.armorProficienciesMap[data.data?.armor?.type]; // Player characters check proficiency - const actorArmorProfs = actorData.data.traits?.armorProf?.value || []; - updates["data.proficient"] = (armorProf === true) || actorArmorProfs.includes(armorProf); - } + + /* -------------------------------------------- */ + + /** + * 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 + ); } - return updates; - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * Pre-creation logic for the automatic configuration of owned power type Items - * @private - */ - _onCreateOwnedPower(data, actorData, isNPC) { - const updates = {}; - updates["data.preparation.prepared"] = true; // Automatically prepare powers for everyone - return updates; - } - - /* -------------------------------------------- */ - - /** - * Pre-creation logic for the automatic configuration of owned weapon type Items - * @private - */ - _onCreateOwnedWeapon(data, actorData, isNPC) { - const updates = {}; - if ( foundry.utils.getProperty(data, "data.equipped") === undefined ) { - updates["data.equipped"] = isNPC; // NPCs automatically equip weapons + /** + * Prepare chat card data for weapon type items + * @private + */ + _weaponChatData(data, labels, props) { + props.push(CONFIG.SW5E.weaponTypes[data.weaponType]); } - if ( foundry.utils.getProperty(data, "data.proficient") === undefined ) { - if ( isNPC ) { - updates["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - // TODO: With the changes to make weapon proficiencies more verbose, this may need revising - const weaponProf = CONFIG.SW5E.weaponProficienciesMap[data.data?.weaponType]; // Player characters check proficiency - const actorWeaponProfs = actorData.data.traits?.weaponProf?.value || []; - updates["data.proficient"] = (weaponProf === true) || actorWeaponProfs.includes(weaponProf); - } + + /* -------------------------------------------- */ + + /** + * 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; } - return updates; - } - /* -------------------------------------------- */ - /* Factory Methods */ - /* -------------------------------------------- */ -// TODO: Make work properly - /** - * 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 - */ - 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; + /** + * 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]); + } - // Get scroll data - const scrollUuid = `Compendium.${CONFIG.SW5E.sourcePacks.ITEMS}.${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 = '

'; - const scrollIntroEnd = scrollDescription.indexOf(pdel); - const scrollIntro = scrollDescription.slice(0, scrollIntroEnd + pdel.length); - const scrollDetails = scrollDescription.slice(scrollIntroEnd + pdel.length); + /** + * 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 + ); + } - // Create a composite description from the scroll description and the power details - const desc = `${scrollIntro}

${itemData.name} (Level ${level})


${description.value}

Scroll Details


${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); - } + /** + * 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} 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 (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} 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} 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} 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} 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")}`; + + // Add global actor bonus + const bonuses = getProperty(this.actor.data.data, "bonuses.abilities") || {}; + if (bonuses.check) { + parts.push("@checkBonus"); + rollData.checkBonus = bonuses.check; + } + + // Compose the roll data + const rollConfig = mergeObject( + { + parts: parts, + data: rollData, + 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 + }, + chooseModifier: true, + 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 = foundry.utils.deepClone(this.data.data); + + // Include an ability score modifier if one exists + const abl = this.abilityMod; + if (abl) { + const ability = rollData.abilities[abl]; + if (!ability) { + console.warn( + `Item ${this.name} in Actor ${this.actor.name} has an invalid item ability modifier of ${abl} defined` + ); + } + 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 = await 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 ? new this(storedData, {parent: actor}) : actor.items.get(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 async _getChatCardActor(card) { + // Case 1 - a synthetic actor from a Token + if (card.dataset.tokenId) { + const token = await fromUuid(card.dataset.tokenId); + if (!token) return null; + 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 {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; + } + + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + /** @inheritdoc */ + async _preCreate(data, options, user) { + await super._preCreate(data, options, user); + if (!this.isEmbedded || this.parent.type === "vehicle") return; + const actorData = this.parent.data; + const isNPC = this.parent.type === "npc"; + let updates; + switch (data.type) { + case "equipment": + updates = this._onCreateOwnedEquipment(data, actorData, isNPC); + break; + case "weapon": + updates = this._onCreateOwnedWeapon(data, actorData, isNPC); + break; + case "power": + updates = this._onCreateOwnedPower(data, actorData, isNPC); + break; + } + if (updates) return this.data.update(updates); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onCreate(data, options, userId) { + super._onCreate(data, options, userId); + + // The below options are only needed for character classes + if (userId !== game.user.id) return; + const isCharacterClass = this.parent && this.parent.type !== "vehicle" && this.type === "class"; + if (!isCharacterClass) return; + + // Assign a new primary class + const pc = this.parent.items.get(this.parent.data.data.details.originalClass); + if (!pc) this.parent._assignPrimaryClass(); + + // Prompt to add new class features + if (options.addFeatures === false) return; + this.parent + .getClassFeatures({ + className: this.name, + archetypeName: this.data.data.archetype, + level: this.data.data.levels + }) + .then((features) => { + return this.parent.addEmbeddedItems(features, options.promptAddFeatures); + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onUpdate(changed, options, userId) { + super._onUpdate(changed, options, userId); + + // The below options are only needed for character classes + if (userId !== game.user.id) return; + const isCharacterClass = this.parent && this.parent.type !== "vehicle" && this.type === "class"; + if (!isCharacterClass) return; + + // Prompt to add new class features + const addFeatures = changed["name"] || (changed.data && ["archetype", "levels"].some((k) => k in changed.data)); + if (!addFeatures || options.addFeatures === false) return; + this.parent + .getClassFeatures({ + className: changed.name || this.name, + archetypeName: changed.data?.archetype || this.data.data.archetype, + level: changed.data?.levels || this.data.data.levels + }) + .then((features) => { + return this.parent.addEmbeddedItems(features, options.promptAddFeatures); + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onDelete(options, userId) { + super._onDelete(options, userId); + + // Assign a new primary class + if (this.parent && this.type === "class" && userId === game.user.id) { + if (this.id !== this.parent.data.data.details.originalClass) return; + this.parent._assignPrimaryClass(); + } + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned equipment type Items + * @private + */ + _onCreateOwnedEquipment(data, actorData, isNPC) { + const updates = {}; + if (foundry.utils.getProperty(data, "data.equipped") === undefined) { + updates["data.equipped"] = isNPC; // NPCs automatically equip equipment + } + if (foundry.utils.getProperty(data, "data.proficient") === undefined) { + if (isNPC) { + updates["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + const armorProf = CONFIG.SW5E.armorProficienciesMap[data.data?.armor?.type]; // Player characters check proficiency + const actorArmorProfs = actorData.data.traits?.armorProf?.value || []; + updates["data.proficient"] = armorProf === true || actorArmorProfs.includes(armorProf); + } + } + return updates; + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned power type Items + * @private + */ + _onCreateOwnedPower(data, actorData, isNPC) { + const updates = {}; + updates["data.preparation.prepared"] = true; // Automatically prepare powers for everyone + return updates; + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned weapon type Items + * @private + */ + _onCreateOwnedWeapon(data, actorData, isNPC) { + const updates = {}; + if (foundry.utils.getProperty(data, "data.equipped") === undefined) { + updates["data.equipped"] = isNPC; // NPCs automatically equip weapons + } + if (foundry.utils.getProperty(data, "data.proficient") === undefined) { + if (isNPC) { + updates["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + // TODO: With the changes to make weapon proficiencies more verbose, this may need revising + const weaponProf = CONFIG.SW5E.weaponProficienciesMap[data.data?.weaponType]; // Player characters check proficiency + const actorWeaponProfs = actorData.data.traits?.weaponProf?.value || []; + updates["data.proficient"] = weaponProf === true || actorWeaponProfs.includes(weaponProf); + } + } + return updates; + } + + /* -------------------------------------------- */ + /* Factory Methods */ + /* -------------------------------------------- */ + // TODO: Make work properly + /** + * 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 + */ + 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 = `Compendium.${CONFIG.SW5E.sourcePacks.ITEMS}.${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 = "

"; + 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}

${itemData.name} (Level ${level})


${description.value}

Scroll Details


${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); + } } diff --git a/module/item/sheet.js b/module/item/sheet.js index ba3cfe06..0bb7f64b 100644 --- a/module/item/sheet.js +++ b/module/item/sheet.js @@ -1,361 +1,370 @@ import TraitSelector from "../apps/trait-selector.js"; -import { onManageActiveEffect, prepareActiveEffectCategories } from "../effects.js"; +import {onManageActiveEffect, prepareActiveEffectCategories} from "../effects.js"; /** * Override and extend the core ItemSheet implementation to handle specific item types * @extends {ItemSheet} */ export default class ItemSheet5e extends ItemSheet { - constructor(...args) { - super(...args); + constructor(...args) { + super(...args); - // Expand the default size of the class sheet - if (this.object.data.type === "class") { - this.options.width = this.position.width = 600; - this.options.height = this.position.height = 680; - } - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - width: 560, - height: 400, - classes: ["sw5e", "sheet", "item"], - resizable: true, - scrollY: [".tab.details"], - tabs: [{ navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description" }] - }); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - get template() { - const path = "systems/sw5e/templates/items/"; - return `${path}/${this.item.data.type}.html`; - } - - /* -------------------------------------------- */ - - /** @override */ - async getData(options) { - const data = super.getData(options); - const itemData = data.data; - data.labels = this.item.labels; - data.config = CONFIG.SW5E; - - // Item Type, Status, and Details - data.itemType = game.i18n.localize(`ITEM.Type${data.item.type.titleCase()}`); - data.itemStatus = this._getItemStatus(itemData); - data.itemProperties = this._getItemProperties(itemData); - data.isPhysical = itemData.data.hasOwnProperty("quantity"); - - // Potential consumption targets - data.abilityConsumptionTargets = this._getItemConsumptionTargets(itemData); - - // Action Details - data.hasAttackRoll = this.item.hasAttack; - data.isHealing = itemData.data.actionType === "heal"; - data.isFlatDC = getProperty(itemData, "data.save.scaling") === "flat"; - data.isLine = ["line", "wall"].includes(itemData.data.target?.type); - - // Original maximum uses formula - const sourceMax = foundry.utils.getProperty(this.item.data._source, "data.uses.max"); - if ( sourceMax ) itemData.data.uses.max = sourceMax; - - // Vehicles - data.isCrewed = itemData.data.activation?.type === "crew"; - data.isMountable = this._isItemMountable(itemData); - - // Prepare Active Effects - data.effects = prepareActiveEffectCategories(this.item.effects); - - // Re-define the template data references (backwards compatible) - data.item = itemData; - data.data = itemData.data; - return data; - } - - /* -------------------------------------------- */ - - /** - * Get the valid item consumption targets which exist on the actor - * @param {Object} item Item data for the item being displayed - * @return {{string: string}} An object of potential consumption targets - * @private - */ - _getItemConsumptionTargets(item) { - const consume = item.data.consume || {}; - if (!consume.type) return []; - const actor = this.item.actor; - if (!actor) return {}; - - // Ammunition - if (consume.type === "ammo") { - return actor.itemTypes.consumable.reduce( - (ammo, i) => { - if (i.data.data.consumableType === "ammo") { - ammo[i.id] = `${i.name} (${i.data.data.quantity})`; - } - return ammo; - }, - { [item._id]: `${item.name} (${item.data.quantity})` } - ); - } - - // Attributes - else if (consume.type === "attribute") { - const attributes = TokenDocument.getTrackedAttributes(actor.data.data); - attributes.bar.forEach(a => a.push("value")); - return attributes.bar.concat(attributes.value).reduce((obj, a) => { - let k = a.join("."); - obj[k] = k; - return obj; - }, {}); - } - - // Materials - else if (consume.type === "material") { - return actor.items.reduce((obj, i) => { - if (["consumable", "loot"].includes(i.data.type) && !i.data.data.activation) { - obj[i.id] = `${i.name} (${i.data.data.quantity})`; + // Expand the default size of the class sheet + if (this.object.data.type === "class") { + this.options.width = this.position.width = 600; + this.options.height = this.position.height = 680; } - return obj; - }, {}); } - // Charges - else if (consume.type === "charges") { - return actor.items.reduce((obj, i) => { - // Limited-use items - const uses = i.data.data.uses || {}; - if (uses.per && uses.max) { - const label = - uses.per === "charges" - ? ` (${game.i18n.format("SW5E.AbilityUseChargesLabel", { value: uses.value })})` - : ` (${game.i18n.format("SW5E.AbilityUseConsumableLabel", { max: uses.max, per: uses.per })})`; - obj[i.id] = i.name + label; + /* -------------------------------------------- */ + + /** @inheritdoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + width: 560, + height: 400, + classes: ["sw5e", "sheet", "item"], + resizable: true, + scrollY: [".tab.details"], + tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}] + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + get template() { + const path = "systems/sw5e/templates/items/"; + return `${path}/${this.item.data.type}.html`; + } + + /* -------------------------------------------- */ + + /** @override */ + async getData(options) { + const data = super.getData(options); + const itemData = data.data; + data.labels = this.item.labels; + data.config = CONFIG.SW5E; + + // Item Type, Status, and Details + data.itemType = game.i18n.localize(`ITEM.Type${data.item.type.titleCase()}`); + data.itemStatus = this._getItemStatus(itemData); + data.itemProperties = this._getItemProperties(itemData); + data.isPhysical = itemData.data.hasOwnProperty("quantity"); + + // Potential consumption targets + data.abilityConsumptionTargets = this._getItemConsumptionTargets(itemData); + + // Action Details + data.hasAttackRoll = this.item.hasAttack; + data.isHealing = itemData.data.actionType === "heal"; + data.isFlatDC = getProperty(itemData, "data.save.scaling") === "flat"; + data.isLine = ["line", "wall"].includes(itemData.data.target?.type); + + // Original maximum uses formula + const sourceMax = foundry.utils.getProperty(this.item.data._source, "data.uses.max"); + if (sourceMax) itemData.data.uses.max = sourceMax; + + // Vehicles + data.isCrewed = itemData.data.activation?.type === "crew"; + data.isMountable = this._isItemMountable(itemData); + + // Prepare Active Effects + data.effects = prepareActiveEffectCategories(this.item.effects); + + // Re-define the template data references (backwards compatible) + data.item = itemData; + data.data = itemData.data; + return data; + } + + /* -------------------------------------------- */ + + /** + * Get the valid item consumption targets which exist on the actor + * @param {Object} item Item data for the item being displayed + * @return {{string: string}} An object of potential consumption targets + * @private + */ + _getItemConsumptionTargets(item) { + const consume = item.data.consume || {}; + if (!consume.type) return []; + const actor = this.item.actor; + if (!actor) return {}; + + // Ammunition + if (consume.type === "ammo") { + return actor.itemTypes.consumable.reduce( + (ammo, i) => { + if (i.data.data.consumableType === "ammo") { + ammo[i.id] = `${i.name} (${i.data.data.quantity})`; + } + return ammo; + }, + {[item._id]: `${item.name} (${item.data.quantity})`} + ); } - // Recharging items - const recharge = i.data.data.recharge || {}; - if (recharge.value) obj[i.id] = `${i.name} (${game.i18n.format("SW5E.Recharge")})`; - return obj; - }, {}); - } else return {}; - } + // Attributes + else if (consume.type === "attribute") { + const attributes = TokenDocument.getTrackedAttributes(actor.data.data); + attributes.bar.forEach((a) => a.push("value")); + return attributes.bar.concat(attributes.value).reduce((obj, a) => { + let k = a.join("."); + obj[k] = k; + return obj; + }, {}); + } - /* -------------------------------------------- */ + // Materials + else if (consume.type === "material") { + return actor.items.reduce((obj, i) => { + if (["consumable", "loot"].includes(i.data.type) && !i.data.data.activation) { + obj[i.id] = `${i.name} (${i.data.data.quantity})`; + } + return obj; + }, {}); + } - /** - * Get the text item status which is shown beneath the Item type in the top-right corner of the sheet - * @return {string} - * @private - */ - _getItemStatus(item) { - if (item.type === "power") { - return CONFIG.SW5E.powerPreparationModes[item.data.preparation]; - } else if (["weapon", "equipment"].includes(item.type)) { - return game.i18n.localize(item.data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"); - } else if (item.type === "tool") { - return game.i18n.localize(item.data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"); - } - } + // Charges + else if (consume.type === "charges") { + return actor.items.reduce((obj, i) => { + // Limited-use items + const uses = i.data.data.uses || {}; + if (uses.per && uses.max) { + const label = + uses.per === "charges" + ? ` (${game.i18n.format("SW5E.AbilityUseChargesLabel", {value: uses.value})})` + : ` (${game.i18n.format("SW5E.AbilityUseConsumableLabel", { + max: uses.max, + per: uses.per + })})`; + obj[i.id] = i.name + label; + } - /* -------------------------------------------- */ - - /** - * Get the Array of item properties which are used in the small sidebar of the description tab - * @return {Array} - * @private - */ - _getItemProperties(item) { - const props = []; - const labels = this.item.labels; - - if (item.type === "weapon") { - props.push( - ...Object.entries(item.data.properties) - .filter((e) => e[1] === true) - .map((e) => CONFIG.SW5E.weaponProperties[e[0]]) - ); - } else if (item.type === "power") { - props.push( - labels.materials, - item.data.components.concentration ? game.i18n.localize("SW5E.Concentration") : null, - item.data.components.ritual ? game.i18n.localize("SW5E.Ritual") : null - ); - } else if (item.type === "equipment") { - props.push(CONFIG.SW5E.equipmentTypes[item.data.armor.type]); - props.push(labels.armor); - } else if (item.type === "feat") { - props.push(labels.featType); - //TODO: Work out these - } else if (item.type === "species") { - //props.push(labels.species); - } else if (item.type === "archetype") { - //props.push(labels.archetype); - } else if (item.type === "background") { - //props.push(labels.background); - } else if (item.type === "classfeature") { - //props.push(labels.classfeature); - } else if (item.type === "deployment") { - //props.push(labels.deployment); - } else if (item.type === "venture") { - //props.push(labels.venture); - } else if (item.type === "fightingmastery") { - //props.push(labels.fightingmastery); - } else if (item.type === "fightingstyle") { - //props.push(labels.fightingstyle); - } else if (item.type === "lightsaberform") { - //props.push(labels.lightsaberform); + // Recharging items + const recharge = i.data.data.recharge || {}; + if (recharge.value) obj[i.id] = `${i.name} (${game.i18n.format("SW5E.Recharge")})`; + return obj; + }, {}); + } else return {}; } - // Action type - if (item.data.actionType) { - props.push(CONFIG.SW5E.itemActionTypes[item.data.actionType]); + /* -------------------------------------------- */ + + /** + * Get the text item status which is shown beneath the Item type in the top-right corner of the sheet + * @return {string} + * @private + */ + _getItemStatus(item) { + if (item.type === "power") { + return CONFIG.SW5E.powerPreparationModes[item.data.preparation]; + } else if (["weapon", "equipment"].includes(item.type)) { + return game.i18n.localize(item.data.equipped ? "SW5E.Equipped" : "SW5E.Unequipped"); + } else if (item.type === "tool") { + return game.i18n.localize(item.data.proficient ? "SW5E.Proficient" : "SW5E.NotProficient"); + } } - // Action usage - if (item.type !== "weapon" && item.data.activation && !isObjectEmpty(item.data.activation)) { - props.push(labels.activation, labels.range, labels.target, labels.duration); - } - return props.filter((p) => !!p); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Get the Array of item properties which are used in the small sidebar of the description tab + * @return {Array} + * @private + */ + _getItemProperties(item) { + const props = []; + const labels = this.item.labels; - /** - * Is this item a separate large object like a siege engine or vehicle - * component that is usually mounted on fixtures rather than equipped, and - * has its own AC and HP. - * @param item - * @returns {boolean} - * @private - */ - _isItemMountable(item) { - const data = item.data; - return ( - (item.type === "weapon" && data.weaponType === "siege") || - (item.type === "equipment" && data.armor.type === "vehicle") - ); - } + if (item.type === "weapon") { + props.push( + ...Object.entries(item.data.properties) + .filter((e) => e[1] === true) + .map((e) => CONFIG.SW5E.weaponProperties[e[0]]) + ); + } else if (item.type === "power") { + props.push( + labels.materials, + item.data.components.concentration ? game.i18n.localize("SW5E.Concentration") : null, + item.data.components.ritual ? game.i18n.localize("SW5E.Ritual") : null + ); + } else if (item.type === "equipment") { + props.push(CONFIG.SW5E.equipmentTypes[item.data.armor.type]); + props.push(labels.armor); + } else if (item.type === "feat") { + props.push(labels.featType); + //TODO: Work out these + } else if (item.type === "species") { + //props.push(labels.species); + } else if (item.type === "archetype") { + //props.push(labels.archetype); + } else if (item.type === "background") { + //props.push(labels.background); + } else if (item.type === "classfeature") { + //props.push(labels.classfeature); + } else if (item.type === "deployment") { + //props.push(labels.deployment); + } else if (item.type === "venture") { + //props.push(labels.venture); + } else if (item.type === "fightingmastery") { + //props.push(labels.fightingmastery); + } else if (item.type === "fightingstyle") { + //props.push(labels.fightingstyle); + } else if (item.type === "lightsaberform") { + //props.push(labels.lightsaberform); + } - /* -------------------------------------------- */ + // Action type + if (item.data.actionType) { + props.push(CONFIG.SW5E.itemActionTypes[item.data.actionType]); + } - /** @inheritdoc */ - setPosition(position = {}) { - if (!(this._minimized || position.height)) { - position.height = this._tabs[0].active === "details" ? "auto" : this.options.height; - } - return super.setPosition(position); - } - - /* -------------------------------------------- */ - /* Form Submission */ - /* -------------------------------------------- */ - - /** @inheritdoc */ - _getSubmitData(updateData = {}) { - // Create the expanded update data object - const fd = new FormDataExtended(this.form, { editors: this.editors }); - let data = fd.toObject(); - if (updateData) data = mergeObject(data, updateData); - else data = expandObject(data); - - // Handle Damage array - const damage = data.data?.damage; - if (damage) damage.parts = Object.values(damage?.parts || {}).map((d) => [d[0] || "", d[1] || ""]); - - // Return the flattened submission data - return flattenObject(data); - } - - /* -------------------------------------------- */ - - /** @inheritdoc */ - activateListeners(html) { - super.activateListeners(html); - if (this.isEditable) { - html.find(".damage-control").click(this._onDamageControl.bind(this)); - html.find(".trait-selector.class-skills").click(this._onConfigureTraits.bind(this)); - html.find(".effect-control").click((ev) => { - if (this.item.isOwned) return ui.notifications.warn("Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update."); - onManageActiveEffect(ev, this.item); - }); - } - } - - /* -------------------------------------------- */ - - /** - * Add or remove a damage part from the damage formula - * @param {Event} event The original click event - * @return {Promise} - * @private - */ - async _onDamageControl(event) { - event.preventDefault(); - const a = event.currentTarget; - - // Add new damage component - if (a.classList.contains("add-damage")) { - await this._onSubmit(event); // Submit any unsaved changes - const damage = this.item.data.data.damage; - return this.item.update({ "data.damage.parts": damage.parts.concat([["", ""]]) }); + // Action usage + if (item.type !== "weapon" && item.data.activation && !isObjectEmpty(item.data.activation)) { + props.push(labels.activation, labels.range, labels.target, labels.duration); + } + return props.filter((p) => !!p); } - // Remove a damage component - if (a.classList.contains("delete-damage")) { - await this._onSubmit(event); // Submit any unsaved changes - const li = a.closest(".damage-part"); - const damage = foundry.utils.deepClone(this.item.data.data.damage); - damage.parts.splice(Number(li.dataset.damagePart), 1); - return this.item.update({ "data.damage.parts": damage.parts }); + /* -------------------------------------------- */ + + /** + * Is this item a separate large object like a siege engine or vehicle + * component that is usually mounted on fixtures rather than equipped, and + * has its own AC and HP. + * @param item + * @returns {boolean} + * @private + */ + _isItemMountable(item) { + const data = item.data; + return ( + (item.type === "weapon" && data.weaponType === "siege") || + (item.type === "equipment" && data.armor.type === "vehicle") + ); } - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ - /** - * Handle spawning the TraitSelector application for selection various options. - * @param {Event} event The click event which originated the selection - * @private - */ - _onConfigureTraits(event) { - event.preventDefault(); - const a = event.currentTarget; - - const options = { - name: a.dataset.target, - title: a.parentElement.innerText, - choices: [], - allowCustom: false - }; - - switch(a.dataset.options) { - case 'saves': - options.choices = CONFIG.SW5E.abilities; - options.valueKey = null; - break; - case 'skills': - const skills = this.item.data.data.skills; - const choiceSet = skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills); - options.choices = Object.fromEntries(Object.entries(CONFIG.SW5E.skills).filter(skill => choiceSet.includes(skill[0]))); - options.maximum = skills.number; - break; + /** @inheritdoc */ + setPosition(position = {}) { + if (!(this._minimized || position.height)) { + position.height = this._tabs[0].active === "details" ? "auto" : this.options.height; + } + return super.setPosition(position); } - new TraitSelector(this.item, options).render(true); - } - /* -------------------------------------------- */ + /* -------------------------------------------- */ + /* Form Submission */ + /* -------------------------------------------- */ - /** @inheritdoc */ - async _onSubmit(...args) { - if (this._tabs[0].active === "details") this.position.height = "auto"; - await super._onSubmit(...args); - } + /** @inheritdoc */ + _getSubmitData(updateData = {}) { + // Create the expanded update data object + const fd = new FormDataExtended(this.form, {editors: this.editors}); + let data = fd.toObject(); + if (updateData) data = mergeObject(data, updateData); + else data = expandObject(data); + + // Handle Damage array + const damage = data.data?.damage; + if (damage) damage.parts = Object.values(damage?.parts || {}).map((d) => [d[0] || "", d[1] || ""]); + + // Return the flattened submission data + return flattenObject(data); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + activateListeners(html) { + super.activateListeners(html); + if (this.isEditable) { + html.find(".damage-control").click(this._onDamageControl.bind(this)); + html.find(".trait-selector.class-skills").click(this._onConfigureTraits.bind(this)); + html.find(".effect-control").click((ev) => { + if (this.item.isOwned) + return ui.notifications.warn( + "Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update." + ); + onManageActiveEffect(ev, this.item); + }); + } + } + + /* -------------------------------------------- */ + + /** + * Add or remove a damage part from the damage formula + * @param {Event} event The original click event + * @return {Promise} + * @private + */ + async _onDamageControl(event) { + event.preventDefault(); + const a = event.currentTarget; + + // Add new damage component + if (a.classList.contains("add-damage")) { + await this._onSubmit(event); // Submit any unsaved changes + const damage = this.item.data.data.damage; + return this.item.update({"data.damage.parts": damage.parts.concat([["", ""]])}); + } + + // Remove a damage component + if (a.classList.contains("delete-damage")) { + await this._onSubmit(event); // Submit any unsaved changes + const li = a.closest(".damage-part"); + const damage = foundry.utils.deepClone(this.item.data.data.damage); + damage.parts.splice(Number(li.dataset.damagePart), 1); + return this.item.update({"data.damage.parts": damage.parts}); + } + } + + /* -------------------------------------------- */ + + /** + * Handle spawning the TraitSelector application for selection various options. + * @param {Event} event The click event which originated the selection + * @private + */ + _onConfigureTraits(event) { + event.preventDefault(); + const a = event.currentTarget; + + const options = { + name: a.dataset.target, + title: a.parentElement.innerText, + choices: [], + allowCustom: false + }; + + switch (a.dataset.options) { + case "saves": + options.choices = CONFIG.SW5E.abilities; + options.valueKey = null; + break; + case "skills": + const skills = this.item.data.data.skills; + const choiceSet = + skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills); + options.choices = Object.fromEntries( + Object.entries(CONFIG.SW5E.skills).filter((skill) => choiceSet.includes(skill[0])) + ); + options.maximum = skills.number; + break; + } + new TraitSelector(this.item, options).render(true); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + async _onSubmit(...args) { + if (this._tabs[0].active === "details") this.position.height = "auto"; + await super._onSubmit(...args); + } } diff --git a/module/macros.js b/module/macros.js index 96bb3419..e8919864 100644 --- a/module/macros.js +++ b/module/macros.js @@ -1,4 +1,3 @@ - /* -------------------------------------------- */ /* Hotbar Macros */ /* -------------------------------------------- */ @@ -11,24 +10,24 @@ * @returns {Promise} */ export async function create5eMacro(data, slot) { - if ( data.type !== "Item" ) return; - if (!( "data" in data ) ) return ui.notifications.warn("You can only create macro buttons for owned Items"); - const item = data.data; + if (data.type !== "Item") return; + if (!("data" in data)) return ui.notifications.warn("You can only create macro buttons for owned Items"); + const item = data.data; - // Create the macro command - const command = `game.sw5e.rollItemMacro("${item.name}");`; - let macro = game.macros.entities.find(m => (m.name === item.name) && (m.command === command)); - if ( !macro ) { - macro = await Macro.create({ - name: item.name, - type: "script", - img: item.img, - command: command, - flags: {"sw5e.itemMacro": true} - }); - } - game.user.assignHotbarMacro(macro, slot); - return false; + // Create the macro command + const command = `game.sw5e.rollItemMacro("${item.name}");`; + let macro = game.macros.entities.find((m) => m.name === item.name && m.command === command); + if (!macro) { + macro = await Macro.create({ + name: item.name, + type: "script", + img: item.img, + command: command, + flags: {"sw5e.itemMacro": true} + }); + } + game.user.assignHotbarMacro(macro, slot); + return false; } /* -------------------------------------------- */ @@ -40,20 +39,22 @@ export async function create5eMacro(data, slot) { * @return {Promise} */ export function rollItemMacro(itemName) { - const speaker = ChatMessage.getSpeaker(); - let actor; - if ( speaker.token ) actor = game.actors.tokens[speaker.token]; - if ( !actor ) actor = game.actors.get(speaker.actor); + const speaker = ChatMessage.getSpeaker(); + let actor; + if (speaker.token) actor = game.actors.tokens[speaker.token]; + if (!actor) actor = game.actors.get(speaker.actor); - // Get matching items - const items = actor ? actor.items.filter(i => i.name === itemName) : []; - if ( items.length > 1 ) { - ui.notifications.warn(`Your controlled Actor ${actor.name} has more than one Item with name ${itemName}. The first matched item will be chosen.`); - } else if ( items.length === 0 ) { - return ui.notifications.warn(`Your controlled Actor does not have an item named ${itemName}`); - } - const item = items[0]; + // Get matching items + const items = actor ? actor.items.filter((i) => i.name === itemName) : []; + if (items.length > 1) { + ui.notifications.warn( + `Your controlled Actor ${actor.name} has more than one Item with name ${itemName}. The first matched item will be chosen.` + ); + } else if (items.length === 0) { + return ui.notifications.warn(`Your controlled Actor does not have an item named ${itemName}`); + } + const item = items[0]; - // Trigger the item roll - return item.roll(); + // Trigger the item roll + return item.roll(); } diff --git a/module/migration.js b/module/migration.js index e549e934..ab6eb420 100644 --- a/module/migration.js +++ b/module/migration.js @@ -2,65 +2,68 @@ * Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs * @return {Promise} A Promise which resolves once the migration is completed */ -export const migrateWorld = async function() { - ui.notifications.info(`Applying SW5e System Migration for version ${game.system.data.version}. Please be patient and do not close your game or shut down your server.`, {permanent: true}); +export const migrateWorld = async function () { + ui.notifications.info( + `Applying SW5e System Migration for version ${game.system.data.version}. Please be patient and do not close your game or shut down your server.`, + {permanent: true} + ); - // Migrate World Actors - for await ( let a of game.actors.contents ) { - try { - console.log(`Checking Actor entity ${a.name} for migration needs`); - const updateData = await migrateActorData(a.data); - if ( !foundry.utils.isObjectEmpty(updateData) ) { - console.log(`Migrating Actor entity ${a.name}`); - await a.update(updateData, {enforceTypes: false}); - } - } catch(err) { - err.message = `Failed sw5e system migration for Actor ${a.name}: ${err.message}`; - console.error(err); + // Migrate World Actors + for await (let a of game.actors.contents) { + try { + console.log(`Checking Actor entity ${a.name} for migration needs`); + const updateData = await migrateActorData(a.data); + if (!foundry.utils.isObjectEmpty(updateData)) { + console.log(`Migrating Actor entity ${a.name}`); + await a.update(updateData, {enforceTypes: false}); + } + } catch (err) { + err.message = `Failed sw5e system migration for Actor ${a.name}: ${err.message}`; + console.error(err); + } } - } - // Migrate World Items - for ( let i of game.items.contents ) { - try { - const updateData = migrateItemData(i.toObject()); - if ( !foundry.utils.isObjectEmpty(updateData) ) { - console.log(`Migrating Item entity ${i.name}`); - await i.update(updateData, {enforceTypes: false}); - } - } catch(err) { - err.message = `Failed sw5e system migration for Item ${i.name}: ${err.message}`; - console.error(err); + // Migrate World Items + for (let i of game.items.contents) { + try { + const updateData = migrateItemData(i.toObject()); + if (!foundry.utils.isObjectEmpty(updateData)) { + console.log(`Migrating Item entity ${i.name}`); + await i.update(updateData, {enforceTypes: false}); + } + } catch (err) { + err.message = `Failed sw5e system migration for Item ${i.name}: ${err.message}`; + console.error(err); + } } - } - // Migrate Actor Override Tokens - for ( let s of game.scenes.contents ) { - try { - const updateData = await migrateSceneData(s.data); - if ( !foundry.utils.isObjectEmpty(updateData) ) { - console.log(`Migrating Scene entity ${s.name}`); - await s.update(updateData, {enforceTypes: false}); - // If we do not do this, then synthetic token actors remain in cache - // with the un-updated actorData. - s.tokens.contents.forEach(t => t._actor = null); - } - } catch(err) { - err.message = `Failed sw5e system migration for Scene ${s.name}: ${err.message}`; - console.error(err); + // Migrate Actor Override Tokens + for (let s of game.scenes.contents) { + try { + const updateData = await migrateSceneData(s.data); + if (!foundry.utils.isObjectEmpty(updateData)) { + console.log(`Migrating Scene entity ${s.name}`); + await s.update(updateData, {enforceTypes: false}); + // If we do not do this, then synthetic token actors remain in cache + // with the un-updated actorData. + s.tokens.contents.forEach((t) => (t._actor = null)); + } + } catch (err) { + err.message = `Failed sw5e system migration for Scene ${s.name}: ${err.message}`; + console.error(err); + } } - } - // Migrate World Compendium Packs - for ( let p of game.packs ) { - if ( p.metadata.package !== "world" ) continue; - if ( !["Actor", "Item", "Scene"].includes(p.metadata.entity) ) continue; - await migrateCompendium(p); - } + // Migrate World Compendium Packs + for (let p of game.packs) { + if (p.metadata.package !== "world") continue; + if (!["Actor", "Item", "Scene"].includes(p.metadata.entity)) continue; + await migrateCompendium(p); + } - // Set the migration as complete - game.settings.set("sw5e", "systemMigrationVersion", game.system.data.version); - ui.notifications.info(`SW5e System Migration to version ${game.system.data.version} completed!`, {permanent: true}); + // Set the migration as complete + game.settings.set("sw5e", "systemMigrationVersion", game.system.data.version); + ui.notifications.info(`SW5e System Migration to version ${game.system.data.version} completed!`, {permanent: true}); }; /* -------------------------------------------- */ @@ -70,50 +73,48 @@ export const migrateWorld = async function() { * @param pack * @return {Promise} */ -export const migrateCompendium = async function(pack) { - const entity = pack.metadata.entity; - if ( !["Actor", "Item", "Scene"].includes(entity) ) return; +export const migrateCompendium = async function (pack) { + const entity = pack.metadata.entity; + if (!["Actor", "Item", "Scene"].includes(entity)) return; - // Unlock the pack for editing - const wasLocked = pack.locked; - await pack.configure({locked: false}); + // Unlock the pack for editing + const wasLocked = pack.locked; + await pack.configure({locked: false}); - // Begin by requesting server-side data model migration and get the migrated content - await pack.migrate(); - const documents = await pack.getDocuments(); + // Begin by requesting server-side data model migration and get the migrated content + await pack.migrate(); + const documents = await pack.getDocuments(); - // Iterate over compendium entries - applying fine-tuned migration functions - for await ( let doc of documents ) { - let updateData = {}; - try { - switch (entity) { - case "Actor": - updateData = await migrateActorData(doc.data); - break; - case "Item": - updateData = migrateItemData(doc.toObject()); - break; - case "Scene": - updateData = await migrateSceneData(doc.data); - break; - } - if ( foundry.utils.isObjectEmpty(updateData) ) continue; + // Iterate over compendium entries - applying fine-tuned migration functions + for await (let doc of documents) { + let updateData = {}; + try { + switch (entity) { + case "Actor": + updateData = await migrateActorData(doc.data); + break; + case "Item": + updateData = migrateItemData(doc.toObject()); + break; + case "Scene": + updateData = await migrateSceneData(doc.data); + break; + } + if (foundry.utils.isObjectEmpty(updateData)) continue; - // Save the entry, if data was changed - await doc.update(updateData); - console.log(`Migrated ${entity} entity ${doc.name} in Compendium ${pack.collection}`); + // Save the entry, if data was changed + await doc.update(updateData); + console.log(`Migrated ${entity} entity ${doc.name} in Compendium ${pack.collection}`); + } catch (err) { + // Handle migration failures + err.message = `Failed sw5e system migration for entity ${doc.name} in pack ${pack.collection}: ${err.message}`; + console.error(err); + } } - // Handle migration failures - catch(err) { - err.message = `Failed sw5e system migration for entity ${doc.name} in pack ${pack.collection}: ${err.message}`; - console.error(err); - } - } - - // Apply the original locked status for the pack - await pack.configure({locked: wasLocked}); - console.log(`Migrated all ${entity} entities from Compendium ${pack.collection}`); + // Apply the original locked status for the pack + await pack.configure({locked: wasLocked}); + console.log(`Migrated all ${entity} entities from Compendium ${pack.collection}`); }; /* -------------------------------------------- */ @@ -126,84 +127,82 @@ export const migrateCompendium = async function(pack) { * @param {object} actor The actor data object to update * @return {Object} The updateData to apply */ -export const migrateActorData = async function(actor) { - const updateData = {}; +export const migrateActorData = async function (actor) { + const updateData = {}; - // Actor Data Updates - if(actor.data) { - _migrateActorMovement(actor, updateData); - _migrateActorSenses(actor, updateData); - _migrateActorType(actor, updateData); - } + // Actor Data Updates + if (actor.data) { + _migrateActorMovement(actor, updateData); + _migrateActorSenses(actor, updateData); + _migrateActorType(actor, updateData); + } - // Migrate Owned Items - if ( !!actor.items ) { - const items = await actor.items.reduce(async (memo, i) => { - const results = await memo; + // Migrate Owned Items + if (!!actor.items) { + const items = await actor.items.reduce(async (memo, i) => { + const results = await memo; - // Migrate the Owned Item - const itemData = i instanceof CONFIG.Item.documentClass ? i.toObject() : i; - let itemUpdate = await migrateActorItemData(itemData, actor); + // Migrate the Owned Item + const itemData = i instanceof CONFIG.Item.documentClass ? i.toObject() : i; + let itemUpdate = await migrateActorItemData(itemData, actor); - // Prepared, Equipped, and Proficient for NPC actors - if ( actor.type === "npc" ) { - if (getProperty(itemData.data, "preparation.prepared") === false) itemUpdate["data.preparation.prepared"] = true; - if (getProperty(itemData.data, "equipped") === false) itemUpdate["data.equipped"] = true; - if (getProperty(itemData.data, "proficient") === false) itemUpdate["data.proficient"] = true; - } + // Prepared, Equipped, and Proficient for NPC actors + if (actor.type === "npc") { + if (getProperty(itemData.data, "preparation.prepared") === false) + itemUpdate["data.preparation.prepared"] = true; + if (getProperty(itemData.data, "equipped") === false) itemUpdate["data.equipped"] = true; + if (getProperty(itemData.data, "proficient") === false) itemUpdate["data.proficient"] = true; + } - // Update the Owned Item - if ( !isObjectEmpty(itemUpdate) ) { - itemUpdate._id = itemData._id; - console.log(`Migrating Actor ${actor.name}'s ${i.name}`); - results.push(expandObject(itemUpdate)); - } + // Update the Owned Item + if (!isObjectEmpty(itemUpdate)) { + itemUpdate._id = itemData._id; + console.log(`Migrating Actor ${actor.name}'s ${i.name}`); + results.push(expandObject(itemUpdate)); + } - return results; - }, []); + return results; + }, []); - if ( items.length > 0 ) updateData.items = items; - } + if (items.length > 0) updateData.items = items; + } - // Update NPC data with new datamodel information - if (actor.type === "npc") { - _updateNPCData(actor); - } + // Update NPC data with new datamodel information + if (actor.type === "npc") { + _updateNPCData(actor); + } - // migrate powers last since it relies on item classes being migrated first. - _migrateActorPowers(actor, updateData); - - return updateData; + // migrate powers last since it relies on item classes being migrated first. + _migrateActorPowers(actor, updateData); + + return updateData; }; /* -------------------------------------------- */ - /** * Scrub an Actor's system data, removing all keys which are not explicitly defined in the system template * @param {Object} actorData The data object for an Actor * @return {Object} The scrubbed Actor data */ function cleanActorData(actorData) { + // Scrub system data + const model = game.system.model.Actor[actorData.type]; + actorData.data = filterObject(actorData.data, model); - // Scrub system data - const model = game.system.model.Actor[actorData.type]; - actorData.data = filterObject(actorData.data, model); + // Scrub system flags + const allowedFlags = CONFIG.SW5E.allowedActorFlags.reduce((obj, f) => { + obj[f] = null; + return obj; + }, {}); + if (actorData.flags.sw5e) { + actorData.flags.sw5e = filterObject(actorData.flags.sw5e, allowedFlags); + } - // Scrub system flags - const allowedFlags = CONFIG.SW5E.allowedActorFlags.reduce((obj, f) => { - obj[f] = null; - return obj; - }, {}); - if ( actorData.flags.sw5e ) { - actorData.flags.sw5e = filterObject(actorData.flags.sw5e, allowedFlags); - } - - // Return the scrubbed data - return actorData; + // Return the scrubbed data + return actorData; } - /* -------------------------------------------- */ /** @@ -212,11 +211,11 @@ function cleanActorData(actorData) { * @param {object} item Item data to migrate * @return {object} The updateData to apply */ -export const migrateItemData = function(item) { - const updateData = {}; - _migrateItemClassPowerCasting(item, updateData); - _migrateItemAttunement(item, updateData); - return updateData; +export const migrateItemData = function (item) { + const updateData = {}; + _migrateItemClassPowerCasting(item, updateData); + _migrateItemAttunement(item, updateData); + return updateData; }; /* -------------------------------------------- */ @@ -226,12 +225,12 @@ export const migrateItemData = function(item) { * @param item * @param actor */ -export const migrateActorItemData = async function(item, actor) { - const updateData = {}; - _migrateItemClassPowerCasting(item, updateData); - _migrateItemAttunement(item, updateData); - await _migrateItemPower(item, actor, updateData); - return updateData; +export const migrateActorItemData = async function (item, actor) { + const updateData = {}; + _migrateItemClassPowerCasting(item, updateData); + _migrateItemAttunement(item, updateData); + await _migrateItemPower(item, actor, updateData); + return updateData; }; /* -------------------------------------------- */ @@ -242,33 +241,34 @@ export const migrateActorItemData = async function(item, actor) { * @param {Object} scene The Scene data to Update * @return {Object} The updateData to apply */ - export const migrateSceneData = async function(scene) { - const tokens = await Promise.all(scene.tokens.map(async token => { - const t = token.toJSON(); - if (!t.actorId || t.actorLink) { - t.actorData = {}; - } - else if (!game.actors.has(t.actorId)) { - t.actorId = null; - t.actorData = {}; - } else if ( !t.actorLink ) { - const actorData = duplicate(t.actorData); - actorData.type = token.actor?.type; - const update = migrateActorData(actorData); - ['items', 'effects'].forEach(embeddedName => { - if (!update[embeddedName]?.length) return; - const updates = new Map(update[embeddedName].map(u => [u._id, u])); - t.actorData[embeddedName].forEach(original => { - const update = updates.get(original._id); - if (update) mergeObject(original, update); - }); - delete update[embeddedName]; - }); +export const migrateSceneData = async function (scene) { + const tokens = await Promise.all( + scene.tokens.map(async (token) => { + const t = token.toJSON(); + if (!t.actorId || t.actorLink) { + t.actorData = {}; + } else if (!game.actors.has(t.actorId)) { + t.actorId = null; + t.actorData = {}; + } else if (!t.actorLink) { + const actorData = duplicate(t.actorData); + actorData.type = token.actor?.type; + const update = migrateActorData(actorData); + ["items", "effects"].forEach((embeddedName) => { + if (!update[embeddedName]?.length) return; + const updates = new Map(update[embeddedName].map((u) => [u._id, u])); + t.actorData[embeddedName].forEach((original) => { + const update = updates.get(original._id); + if (update) mergeObject(original, update); + }); + delete update[embeddedName]; + }); - mergeObject(t.actorData, update); - } - return t; - })); + mergeObject(t.actorData, update); + } + return t; + }) + ); return {tokens}; }; @@ -284,87 +284,93 @@ export const migrateActorItemData = async function(item, actor) { * @return {Object} The updated Actor */ function _updateNPCData(actor) { + let actorData = actor.data; + const updateData = {}; + // check for flag.core, if not there is no compendium monster so exit + const hasSource = actor?.flags?.core?.sourceId !== undefined; + if (!hasSource) return actor; + // shortcut out if dataVersion flag is set to 1.2.4 or higher + const hasDataVersion = actor?.flags?.sw5e?.dataVersion !== undefined; + if ( + hasDataVersion && + (actor.flags.sw5e.dataVersion === "1.2.4" || isNewerVersion("1.2.4", actor.flags.sw5e.dataVersion)) + ) + return actor; + // Check to see what the source of NPC is + const sourceId = actor.flags.core.sourceId; + const coreSource = sourceId.substr(0, sourceId.length - 17); + const core_id = sourceId.substr(sourceId.length - 16, 16); + if (coreSource === "Compendium.sw5e.monsters") { + game.packs + .get("sw5e.monsters") + .getEntity(core_id) + .then((monster) => { + const monsterData = monster.data.data; + // copy movement[], senses[], powercasting, force[], tech[], powerForceLevel, powerTechLevel + updateData["data.attributes.movement"] = monsterData.attributes.movement; + updateData["data.attributes.senses"] = monsterData.attributes.senses; + updateData["data.attributes.powercasting"] = monsterData.attributes.powercasting; + updateData["data.attributes.force"] = monsterData.attributes.force; + updateData["data.attributes.tech"] = monsterData.attributes.tech; + updateData["data.details.powerForceLevel"] = monsterData.details.powerForceLevel; + updateData["data.details.powerTechLevel"] = monsterData.details.powerTechLevel; + // push missing powers onto actor + let newPowers = []; + for (let i of monster.items) { + const itemData = i.data; + if (itemData.type === "power") { + const itemCompendium_id = itemData.flags?.core?.sourceId.split(".").slice(-1)[0]; + let hasPower = !!actor.items.find( + (item) => item.flags?.core?.sourceId.split(".").slice(-1)[0] === itemCompendium_id + ); + if (!hasPower) { + // Clone power to new object. Don't know if it is technically needed, but seems to prevent some weirdness. + const newPower = JSON.parse(JSON.stringify(itemData)); - let actorData = actor.data; - const updateData = {}; - // check for flag.core, if not there is no compendium monster so exit - const hasSource = actor?.flags?.core?.sourceId !== undefined; - if (!hasSource) return actor; - // shortcut out if dataVersion flag is set to 1.2.4 or higher - const hasDataVersion = actor?.flags?.sw5e?.dataVersion !== undefined; - if (hasDataVersion && (actor.flags.sw5e.dataVersion === "1.2.4" || isNewerVersion("1.2.4", actor.flags.sw5e.dataVersion))) return actor; - // Check to see what the source of NPC is - const sourceId = actor.flags.core.sourceId; - const coreSource = sourceId.substr(0,sourceId.length-17); - const core_id = sourceId.substr(sourceId.length-16,16); - if (coreSource === "Compendium.sw5e.monsters"){ - game.packs.get("sw5e.monsters").getEntity(core_id).then(monster => { - const monsterData = monster.data.data; - // copy movement[], senses[], powercasting, force[], tech[], powerForceLevel, powerTechLevel - updateData["data.attributes.movement"] = monsterData.attributes.movement; - updateData["data.attributes.senses"] = monsterData.attributes.senses; - updateData["data.attributes.powercasting"] = monsterData.attributes.powercasting; - updateData["data.attributes.force"] = monsterData.attributes.force; - updateData["data.attributes.tech"] = monsterData.attributes.tech; - updateData["data.details.powerForceLevel"] = monsterData.details.powerForceLevel; - updateData["data.details.powerTechLevel"] = monsterData.details.powerTechLevel; - // push missing powers onto actor - let newPowers = []; - for ( let i of monster.items ) { - const itemData = i.data; - if ( itemData.type === "power" ) { - const itemCompendium_id = itemData.flags?.core?.sourceId.split(".").slice(-1)[0]; - let hasPower = !!actor.items.find(item => item.flags?.core?.sourceId.split(".").slice(-1)[0] === itemCompendium_id); - if (!hasPower) { - // Clone power to new object. Don't know if it is technically needed, but seems to prevent some weirdness. - const newPower = JSON.parse(JSON.stringify(itemData)); + newPowers.push(newPower); + } + } + } - newPowers.push(newPower); - } - } - } + // get actor to create new powers + const liveActor = game.actors.get(actor._id); + // create the powers on the actor + liveActor.createEmbeddedEntity("OwnedItem", newPowers); - // get actor to create new powers - const liveActor = game.actors.get(actor._id); - // create the powers on the actor - liveActor.createEmbeddedEntity("OwnedItem", newPowers); + // set flag to check to see if migration has been done so we don't do it again. + liveActor.setFlag("sw5e", "dataVersion", "1.2.4"); + }); + } - // set flag to check to see if migration has been done so we don't do it again. - liveActor.setFlag("sw5e", "dataVersion", "1.2.4"); - }) - } - - - //merge object - actorData = mergeObject(actorData, updateData); - // Return the scrubbed data - return actor; + //merge object + actorData = mergeObject(actorData, updateData); + // Return the scrubbed data + return actor; } - /** * Migrate the actor speed string to movement object * @private */ function _migrateActorMovement(actorData, updateData) { - const ad = actorData.data; + const ad = actorData.data; - // Work is needed if old data is present - const old = actorData.type === 'vehicle' ? ad?.attributes?.speed : ad?.attributes?.speed?.value; - const hasOld = old !== undefined; - if ( hasOld ) { + // Work is needed if old data is present + const old = actorData.type === "vehicle" ? ad?.attributes?.speed : ad?.attributes?.speed?.value; + const hasOld = old !== undefined; + if (hasOld) { + // If new data is not present, migrate the old data + const hasNew = ad?.attributes?.movement?.walk !== undefined; + if (!hasNew && typeof old === "string") { + const s = (old || "").split(" "); + if (s.length > 0) + updateData["data.attributes.movement.walk"] = Number.isNumeric(s[0]) ? parseInt(s[0]) : null; + } - // If new data is not present, migrate the old data - const hasNew = ad?.attributes?.movement?.walk !== undefined; - if ( !hasNew && (typeof old === "string") ) { - const s = (old || "").split(" "); - if ( s.length > 0 ) updateData["data.attributes.movement.walk"] = Number.isNumeric(s[0]) ? parseInt(s[0]) : null; + // Remove the old attribute + updateData["data.attributes.-=speed"] = null; } - - // Remove the old attribute - updateData["data.attributes.-=speed"] = null; - } - return updateData + return updateData; } /* -------------------------------------------- */ @@ -374,58 +380,58 @@ function _migrateActorMovement(actorData, updateData) { * @private */ function _migrateActorPowers(actorData, updateData) { - const ad = actorData.data; + const ad = actorData.data; - // If new Force & Tech data is not present, create it - let hasNewAttrib = ad?.attributes?.force?.level !== undefined; - if ( !hasNewAttrib ) { - updateData["data.attributes.force.known.value"] = 0; - updateData["data.attributes.force.known.max"] = 0; - updateData["data.attributes.force.points.value"] = 0; - updateData["data.attributes.force.points.min"] = 0; - updateData["data.attributes.force.points.max"] = 0; - updateData["data.attributes.force.points.temp"] = null; - updateData["data.attributes.force.points.tempmax"] = null; - updateData["data.attributes.force.level"] = 0; - updateData["data.attributes.tech.known.value"] = 0; - updateData["data.attributes.tech.known.max"] = 0; - updateData["data.attributes.tech.points.value"] = 0; - updateData["data.attributes.tech.points.min"] = 0; - updateData["data.attributes.tech.points.max"] = 0; - updateData["data.attributes.tech.points.temp"] = null; - updateData["data.attributes.tech.points.tempmax"] = null; - updateData["data.attributes.tech.level"] = 0; - } - - // If new Power F/T split data is not present, create it - const hasNewLimit = ad?.powers?.power1?.foverride !== undefined; - if ( !hasNewLimit ) { - for (let i = 1; i <= 9; i++) { - // add new - updateData["data.powers.power" + i + ".fvalue"] = getProperty(ad.powers,"power" + i + ".value"); - updateData["data.powers.power" + i + ".fmax"] = getProperty(ad.powers,"power" + i + ".max"); - updateData["data.powers.power" + i + ".foverride"] = null; - updateData["data.powers.power" + i + ".tvalue"] = getProperty(ad.powers,"power" + i + ".value"); - updateData["data.powers.power" + i + ".tmax"] = getProperty(ad.powers,"power" + i + ".max"); - updateData["data.powers.power" + i + ".toverride"] = null; - //remove old - updateData["data.powers.power" + i + ".-=value"] = null; - updateData["data.powers.power" + i + ".-=override"] = null; + // If new Force & Tech data is not present, create it + let hasNewAttrib = ad?.attributes?.force?.level !== undefined; + if (!hasNewAttrib) { + updateData["data.attributes.force.known.value"] = 0; + updateData["data.attributes.force.known.max"] = 0; + updateData["data.attributes.force.points.value"] = 0; + updateData["data.attributes.force.points.min"] = 0; + updateData["data.attributes.force.points.max"] = 0; + updateData["data.attributes.force.points.temp"] = null; + updateData["data.attributes.force.points.tempmax"] = null; + updateData["data.attributes.force.level"] = 0; + updateData["data.attributes.tech.known.value"] = 0; + updateData["data.attributes.tech.known.max"] = 0; + updateData["data.attributes.tech.points.value"] = 0; + updateData["data.attributes.tech.points.min"] = 0; + updateData["data.attributes.tech.points.max"] = 0; + updateData["data.attributes.tech.points.temp"] = null; + updateData["data.attributes.tech.points.tempmax"] = null; + updateData["data.attributes.tech.level"] = 0; } - } - // If new Bonus Power DC data is not present, create it - const hasNewBonus = ad?.bonuses?.power?.forceLightDC !== undefined; - if ( !hasNewBonus ) { - updateData["data.bonuses.power.forceLightDC"] = ""; - updateData["data.bonuses.power.forceDarkDC"] = ""; - updateData["data.bonuses.power.forceUnivDC"] = ""; - updateData["data.bonuses.power.techDC"] = ""; - } - // Remove the Power DC Bonus - updateData["data.bonuses.power.-=dc"] = null; + // If new Power F/T split data is not present, create it + const hasNewLimit = ad?.powers?.power1?.foverride !== undefined; + if (!hasNewLimit) { + for (let i = 1; i <= 9; i++) { + // add new + updateData["data.powers.power" + i + ".fvalue"] = getProperty(ad.powers, "power" + i + ".value"); + updateData["data.powers.power" + i + ".fmax"] = getProperty(ad.powers, "power" + i + ".max"); + updateData["data.powers.power" + i + ".foverride"] = null; + updateData["data.powers.power" + i + ".tvalue"] = getProperty(ad.powers, "power" + i + ".value"); + updateData["data.powers.power" + i + ".tmax"] = getProperty(ad.powers, "power" + i + ".max"); + updateData["data.powers.power" + i + ".toverride"] = null; + //remove old + updateData["data.powers.power" + i + ".-=value"] = null; + updateData["data.powers.power" + i + ".-=override"] = null; + } + } + // If new Bonus Power DC data is not present, create it + const hasNewBonus = ad?.bonuses?.power?.forceLightDC !== undefined; + if (!hasNewBonus) { + updateData["data.bonuses.power.forceLightDC"] = ""; + updateData["data.bonuses.power.forceDarkDC"] = ""; + updateData["data.bonuses.power.forceUnivDC"] = ""; + updateData["data.bonuses.power.techDC"] = ""; + } - return updateData + // Remove the Power DC Bonus + updateData["data.bonuses.power.-=dc"] = null; + + return updateData; } /* -------------------------------------------- */ @@ -435,35 +441,35 @@ function _migrateActorPowers(actorData, updateData) { * @private */ function _migrateActorSenses(actor, updateData) { - const ad = actor.data; - if ( ad?.traits?.senses === undefined ) return; - const original = ad.traits.senses || ""; - if ( typeof original !== "string" ) return; + const ad = actor.data; + if (ad?.traits?.senses === undefined) return; + const original = ad.traits.senses || ""; + if (typeof original !== "string") return; - // Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft" - const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/; - let wasMatched = false; + // Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft" + const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/; + let wasMatched = false; - // Match each comma-separated term - for ( let s of original.split(",") ) { - s = s.trim(); - const match = s.match(pattern); - if ( !match ) continue; - const type = match[1].toLowerCase(); - if ( type in CONFIG.SW5E.senses ) { - updateData[`data.attributes.senses.${type}`] = Number(match[2]).toNearest(0.5); - wasMatched = true; + // Match each comma-separated term + for (let s of original.split(",")) { + s = s.trim(); + const match = s.match(pattern); + if (!match) continue; + const type = match[1].toLowerCase(); + if (type in CONFIG.SW5E.senses) { + updateData[`data.attributes.senses.${type}`] = Number(match[2]).toNearest(0.5); + wasMatched = true; + } } - } - // If nothing was matched, but there was an old string - put the whole thing in "special" - if ( !wasMatched && !!original ) { - updateData["data.attributes.senses.special"] = original; - } + // If nothing was matched, but there was an old string - put the whole thing in "special" + if (!wasMatched && !!original) { + updateData["data.attributes.senses.special"] = original; + } - // Remove the old traits.senses string once the migration is complete - updateData["data.traits.-=senses"] = null; - return updateData; + // Remove the old traits.senses string once the migration is complete + updateData["data.traits.-=senses"] = null; + return updateData; } /* -------------------------------------------- */ @@ -473,76 +479,77 @@ function _migrateActorSenses(actor, updateData) { * @private */ function _migrateActorType(actor, updateData) { - const ad = actor.data; - const original = ad.details?.type; - if ( typeof original !== "string" ) return; + const ad = actor.data; + const original = ad.details?.type; + if (typeof original !== "string") return; - // New default data structure - let data = { - "value": "", - "subtype": "", - "swarm": "", - "custom": "" - } + // New default data structure + let data = { + value: "", + subtype: "", + swarm: "", + custom: "" + }; - // Specifics - // (Some of these have weird names, these need to be addressed individually) - if (original === "force entity") { - data.value = "force"; - data.subtype = "storm"; - } else if (original === "human") { - data.value = "humanoid"; - data.subtype = "human"; - } else if (["humanoid (any)", "humanoid (Villainous"].includes(original)) { - data.value = "humanoid"; - } else if (original === "tree") { - data.value = "plant"; - data.subtype = "tree"; - } else if (original === "(humanoid) or Large (beast) force entity") { - data.value = "force"; - } else if (original === "droid (appears human)") { - data.value = "droid"; - } else { - // Match the existing string - const pattern = /^(?:swarm of (?[\w\-]+) )?(?[^(]+?)(?:\((?[^)]+)\))?$/i; - const match = original.trim().match(pattern); - if (match) { + // Specifics + // (Some of these have weird names, these need to be addressed individually) + if (original === "force entity") { + data.value = "force"; + data.subtype = "storm"; + } else if (original === "human") { + data.value = "humanoid"; + data.subtype = "human"; + } else if (["humanoid (any)", "humanoid (Villainous"].includes(original)) { + data.value = "humanoid"; + } else if (original === "tree") { + data.value = "plant"; + data.subtype = "tree"; + } else if (original === "(humanoid) or Large (beast) force entity") { + data.value = "force"; + } else if (original === "droid (appears human)") { + data.value = "droid"; + } else { + // Match the existing string + const pattern = /^(?:swarm of (?[\w\-]+) )?(?[^(]+?)(?:\((?[^)]+)\))?$/i; + const match = original.trim().match(pattern); + if (match) { + // Match a known creature type + const typeLc = match.groups.type.trim().toLowerCase(); + const typeMatch = Object.entries(CONFIG.SW5E.creatureTypes).find(([k, v]) => { + return ( + typeLc === k || + typeLc === game.i18n.localize(v).toLowerCase() || + typeLc === game.i18n.localize(`${v}Pl`).toLowerCase() + ); + }); + if (typeMatch) data.value = typeMatch[0]; + else { + data.value = "custom"; + data.custom = match.groups.type.trim().titleCase(); + } + data.subtype = match.groups.subtype?.trim().titleCase() || ""; - // Match a known creature type - const typeLc = match.groups.type.trim().toLowerCase(); - const typeMatch = Object.entries(CONFIG.SW5E.creatureTypes).find(([k, v]) => { - return (typeLc === k) || - (typeLc === game.i18n.localize(v).toLowerCase()) || - (typeLc === game.i18n.localize(`${v}Pl`).toLowerCase()); - }); - if (typeMatch) data.value = typeMatch[0]; - else { - data.value = "custom"; - data.custom = match.groups.type.trim().titleCase(); - } - data.subtype = match.groups.subtype?.trim().titleCase() || ""; + // Match a swarm + const isNamedSwarm = actor.name.startsWith(game.i18n.localize("SW5E.CreatureSwarm")); + if (match.groups.size || isNamedSwarm) { + const sizeLc = match.groups.size ? match.groups.size.trim().toLowerCase() : "tiny"; + const sizeMatch = Object.entries(CONFIG.SW5E.actorSizes).find(([k, v]) => { + return sizeLc === k || sizeLc === game.i18n.localize(v).toLowerCase(); + }); + data.swarm = sizeMatch ? sizeMatch[0] : "tiny"; + } else data.swarm = ""; + } - // Match a swarm - const isNamedSwarm = actor.name.startsWith(game.i18n.localize("SW5E.CreatureSwarm")); - if (match.groups.size || isNamedSwarm) { - const sizeLc = match.groups.size ? match.groups.size.trim().toLowerCase() : "tiny"; - const sizeMatch = Object.entries(CONFIG.SW5E.actorSizes).find(([k, v]) => { - return (sizeLc === k) || (sizeLc === game.i18n.localize(v).toLowerCase()); - }); - data.swarm = sizeMatch ? sizeMatch[0] : "tiny"; - } else data.swarm = ""; + // No match found + else { + data.value = "custom"; + data.custom = original; + } } - // No match found - else { - data.value = "custom"; - data.custom = original; - } - } - - // Update the actor data - updateData["data.details.type"] = data; - return updateData; + // Update the actor data + updateData["data.details.type"] = data; + return updateData; } /* -------------------------------------------- */ @@ -551,42 +558,41 @@ function _migrateActorType(actor, updateData) { * @private */ function _migrateItemClassPowerCasting(item, updateData) { - if (item.type === "class"){ - switch (item.name){ - case "Consular": - updateData["data.powercasting"] = { - progression: "consular", - ability: "" - }; - break; - case "Engineer": - - updateData["data.powercasting"] = { - progression: "engineer", - ability: "" - }; - break; - case "Guardian": - updateData["data.powercasting"] = { - progression: "guardian", - ability: "" - }; - break; - case "Scout": - updateData["data.powercasting"] = { - progression: "scout", - ability: "" - }; - break; - case "Sentinel": - updateData["data.powercasting"] = { - progression: "sentinel", - ability: "" - }; - break; + if (item.type === "class") { + switch (item.name) { + case "Consular": + updateData["data.powercasting"] = { + progression: "consular", + ability: "" + }; + break; + case "Engineer": + updateData["data.powercasting"] = { + progression: "engineer", + ability: "" + }; + break; + case "Guardian": + updateData["data.powercasting"] = { + progression: "guardian", + ability: "" + }; + break; + case "Scout": + updateData["data.powercasting"] = { + progression: "scout", + ability: "" + }; + break; + case "Sentinel": + updateData["data.powercasting"] = { + progression: "sentinel", + ability: "" + }; + break; + } } - } - return updateData; + return updateData; } /* -------------------------------------------- */ @@ -598,42 +604,45 @@ function _migrateItemClassPowerCasting(item, updateData) { * @private */ async function _migrateItemPower(item, actor, updateData) { - // if item is not a power shortcut out - if (item.type !== "power") return updateData; + // if item is not a power shortcut out + if (item.type !== "power") return updateData; - console.log(`Checking Actor ${actor.name}'s ${item.name} for migration needs`); - // check for flag.core, if not there is no compendium power so exit - const hasSource = item?.flags?.core?.sourceId !== undefined; - if (!hasSource) return updateData; + console.log(`Checking Actor ${actor.name}'s ${item.name} for migration needs`); + // check for flag.core, if not there is no compendium power so exit + const hasSource = item?.flags?.core?.sourceId !== undefined; + if (!hasSource) return updateData; - // shortcut out if dataVersion flag is set to 1.2.4 or higher - const hasDataVersion = item?.flags?.sw5e?.dataVersion !== undefined; - if (hasDataVersion && (item.flags.sw5e.dataVersion === "1.2.4" || isNewerVersion("1.2.4", item.flags.sw5e.dataVersion))) return updateData; - - // Check to see what the source of Power is - const sourceId = item.flags.core.sourceId; - const coreSource = sourceId.substr(0, sourceId.length - 17); - const core_id = sourceId.substr(sourceId.length - 16, 16); - - //if power type is not force or tech exit out - let powerType = "none"; - if (coreSource === "Compendium.sw5e.forcepowers") powerType = "sw5e.forcepowers"; - if (coreSource === "Compendium.sw5e.techpowers") powerType = "sw5e.techpowers"; - if (powerType === "none") return updateData; + // shortcut out if dataVersion flag is set to 1.2.4 or higher + const hasDataVersion = item?.flags?.sw5e?.dataVersion !== undefined; + if ( + hasDataVersion && + (item.flags.sw5e.dataVersion === "1.2.4" || isNewerVersion("1.2.4", item.flags.sw5e.dataVersion)) + ) + return updateData; + + // Check to see what the source of Power is + const sourceId = item.flags.core.sourceId; + const coreSource = sourceId.substr(0, sourceId.length - 17); + const core_id = sourceId.substr(sourceId.length - 16, 16); + + //if power type is not force or tech exit out + let powerType = "none"; + if (coreSource === "Compendium.sw5e.forcepowers") powerType = "sw5e.forcepowers"; + if (coreSource === "Compendium.sw5e.techpowers") powerType = "sw5e.techpowers"; + if (powerType === "none") return updateData; const corePower = duplicate(await game.packs.get(powerType).getEntity(core_id)); console.log(`Updating Actor ${actor.name}'s ${item.name} from compendium`); const corePowerData = corePower.data; // copy Core Power Data over original Power updateData["data"] = corePowerData; - updateData["flags"] = {"sw5e": {"dataVersion": "1.2.4"}}; + updateData["flags"] = {sw5e: {dataVersion: "1.2.4"}}; return updateData; - - - //game.packs.get(powerType).getEntity(core_id).then(corePower => { - //}) + //game.packs.get(powerType).getEntity(core_id).then(corePower => { + + //}) } /* -------------------------------------------- */ @@ -647,10 +656,10 @@ async function _migrateItemPower(item, actor, updateData) { * @private */ function _migrateItemAttunement(item, updateData) { - if ( item.data?.attuned === undefined ) return updateData; - updateData["data.attunement"] = CONFIG.SW5E.attunementTypes.NONE; - updateData["data.-=attuned"] = null; - return updateData; + if (item.data?.attuned === undefined) return updateData; + updateData["data.attunement"] = CONFIG.SW5E.attunementTypes.NONE; + updateData["data.-=attuned"] = null; + return updateData; } /* -------------------------------------------- */ @@ -661,43 +670,41 @@ function _migrateItemAttunement(item, updateData) { * @private */ export async function purgeFlags(pack) { - const cleanFlags = (flags) => { - const flags5e = flags.sw5e || null; - return flags5e ? {sw5e: flags5e} : {}; - }; - await pack.configure({locked: false}); - const content = await pack.getContent(); - for ( let entity of content ) { - const update = {_id: entity.id, flags: cleanFlags(entity.data.flags)}; - if ( pack.entity === "Actor" ) { - update.items = entity.data.items.map(i => { - i.flags = cleanFlags(i.flags); - return i; - }) + const cleanFlags = (flags) => { + const flags5e = flags.sw5e || null; + return flags5e ? {sw5e: flags5e} : {}; + }; + await pack.configure({locked: false}); + const content = await pack.getContent(); + for (let entity of content) { + const update = {_id: entity.id, flags: cleanFlags(entity.data.flags)}; + if (pack.entity === "Actor") { + update.items = entity.data.items.map((i) => { + i.flags = cleanFlags(i.flags); + return i; + }); + } + await pack.updateEntity(update, {recursive: false}); + console.log(`Purged flags from ${entity.name}`); } - await pack.updateEntity(update, {recursive: false}); - console.log(`Purged flags from ${entity.name}`); - } - await pack.configure({locked: true}); + await pack.configure({locked: true}); } /* -------------------------------------------- */ - /** * Purge the data model of any inner objects which have been flagged as _deprecated. * @param {object} data The data to clean * @private */ export function removeDeprecatedObjects(data) { - for ( let [k, v] of Object.entries(data) ) { - if ( getType(v) === "Object" ) { - if (v._deprecated === true) { - console.log(`Deleting deprecated object key ${k}`); - delete data[k]; - } - else removeDeprecatedObjects(v); + for (let [k, v] of Object.entries(data)) { + if (getType(v) === "Object") { + if (v._deprecated === true) { + console.log(`Deleting deprecated object key ${k}`); + delete data[k]; + } else removeDeprecatedObjects(v); + } } - } - return data; + return data; } diff --git a/module/pixi/ability-template.js b/module/pixi/ability-template.js index 3af99181..6799eaec 100644 --- a/module/pixi/ability-template.js +++ b/module/pixi/ability-template.js @@ -1,133 +1,132 @@ -import { SW5E } from "../config.js"; +import {SW5E} from "../config.js"; /** * A helper class for building MeasuredTemplates for 5e powers and abilities * @extends {MeasuredTemplate} */ export default class AbilityTemplate extends MeasuredTemplate { + /** + * A factory method to create an AbilityTemplate instance using provided data from an Item5e instance + * @param {Item5e} item The Item object for which to construct the template + * @return {AbilityTemplate|null} The template object, or null if the item does not produce a template + */ + static fromItem(item) { + const target = getProperty(item.data, "data.target") || {}; + const templateShape = SW5E.areaTargetTypes[target.type]; + if (!templateShape) return null; - /** - * A factory method to create an AbilityTemplate instance using provided data from an Item5e instance - * @param {Item5e} item The Item object for which to construct the template - * @return {AbilityTemplate|null} The template object, or null if the item does not produce a template - */ - static fromItem(item) { - const target = getProperty(item.data, "data.target") || {}; - const templateShape = SW5E.areaTargetTypes[target.type]; - if ( !templateShape ) return null; + // Prepare template data + const templateData = { + t: templateShape, + user: game.user.data._id, + distance: target.value, + direction: 0, + x: 0, + y: 0, + fillColor: game.user.color + }; - // Prepare template data - const templateData = { - t: templateShape, - user: game.user.data._id, - distance: target.value, - direction: 0, - x: 0, - y: 0, - fillColor: game.user.color - }; + // Additional type-specific data + switch (templateShape) { + case "cone": + templateData.angle = CONFIG.MeasuredTemplate.defaults.angle; + break; + case "rect": // 5e rectangular AoEs are always cubes + templateData.distance = Math.hypot(target.value, target.value); + templateData.width = target.value; + templateData.direction = 45; + break; + case "ray": // 5e rays are most commonly 1 square (5 ft) in width + templateData.width = target.width ?? canvas.dimensions.distance; + break; + default: + break; + } - // Additional type-specific data - switch ( templateShape ) { - case "cone": - templateData.angle = CONFIG.MeasuredTemplate.defaults.angle; - break; - case "rect": // 5e rectangular AoEs are always cubes - templateData.distance = Math.hypot(target.value, target.value); - templateData.width = target.value; - templateData.direction = 45; - break; - case "ray": // 5e rays are most commonly 1 square (5 ft) in width - templateData.width = target.width ?? canvas.dimensions.distance; - break; - default: - break; + // Return the template constructed from the item data + const cls = CONFIG.MeasuredTemplate.documentClass; + const template = new cls(templateData, {parent: canvas.scene}); + const object = new this(template); + object.item = item; + object.actorSheet = item.actor?.sheet || null; + return object; } - // Return the template constructed from the item data - const cls = CONFIG.MeasuredTemplate.documentClass; - const template = new cls(templateData, {parent: canvas.scene}); - const object = new this(template); - object.item = item; - object.actorSheet = item.actor?.sheet || null; - return object; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Creates a preview of the power template + */ + drawPreview() { + const initialLayer = canvas.activeLayer; - /** - * Creates a preview of the power template - */ - drawPreview() { - const initialLayer = canvas.activeLayer; + // Draw the template and switch to the template layer + this.draw(); + this.layer.activate(); + this.layer.preview.addChild(this); - // Draw the template and switch to the template layer - this.draw(); - this.layer.activate(); - this.layer.preview.addChild(this); + // Hide the sheet that originated the preview + if (this.actorSheet) this.actorSheet.minimize(); - // Hide the sheet that originated the preview - if ( this.actorSheet ) this.actorSheet.minimize(); + // Activate interactivity + this.activatePreviewListeners(initialLayer); + } - // Activate interactivity - this.activatePreviewListeners(initialLayer); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Activate listeners for the template preview + * @param {CanvasLayer} initialLayer The initially active CanvasLayer to re-activate after the workflow is complete + */ + activatePreviewListeners(initialLayer) { + const handlers = {}; + let moveTime = 0; - /** - * Activate listeners for the template preview - * @param {CanvasLayer} initialLayer The initially active CanvasLayer to re-activate after the workflow is complete - */ - activatePreviewListeners(initialLayer) { - const handlers = {}; - let moveTime = 0; + // Update placement (mouse-move) + handlers.mm = (event) => { + event.stopPropagation(); + let now = Date.now(); // Apply a 20ms throttle + if (now - moveTime <= 20) return; + const center = event.data.getLocalPosition(this.layer); + const snapped = canvas.grid.getSnappedPosition(center.x, center.y, 2); + this.data.update({x: snapped.x, y: snapped.y}); + this.refresh(); + moveTime = now; + }; - // Update placement (mouse-move) - handlers.mm = event => { - event.stopPropagation(); - let now = Date.now(); // Apply a 20ms throttle - if ( now - moveTime <= 20 ) return; - const center = event.data.getLocalPosition(this.layer); - const snapped = canvas.grid.getSnappedPosition(center.x, center.y, 2); - this.data.update({x: snapped.x, y: snapped.y}); - this.refresh(); - moveTime = now; - }; + // Cancel the workflow (right-click) + handlers.rc = (event) => { + this.layer.preview.removeChildren(); + canvas.stage.off("mousemove", handlers.mm); + canvas.stage.off("mousedown", handlers.lc); + canvas.app.view.oncontextmenu = null; + canvas.app.view.onwheel = null; + initialLayer.activate(); + this.actorSheet.maximize(); + }; - // Cancel the workflow (right-click) - handlers.rc = event => { - this.layer.preview.removeChildren(); - canvas.stage.off("mousemove", handlers.mm); - canvas.stage.off("mousedown", handlers.lc); - canvas.app.view.oncontextmenu = null; - canvas.app.view.onwheel = null; - initialLayer.activate(); - this.actorSheet.maximize(); - }; + // Confirm the workflow (left-click) + handlers.lc = (event) => { + handlers.rc(event); + const destination = canvas.grid.getSnappedPosition(this.data.x, this.data.y, 2); + this.data.update(destination); + canvas.scene.createEmbeddedDocuments("MeasuredTemplate", [this.data]); + }; - // Confirm the workflow (left-click) - handlers.lc = event => { - handlers.rc(event); - const destination = canvas.grid.getSnappedPosition(this.data.x, this.data.y, 2); - this.data.update(destination); - canvas.scene.createEmbeddedDocuments("MeasuredTemplate", [this.data]); - }; + // Rotate the template by 3 degree increments (mouse-wheel) + handlers.mw = (event) => { + if (event.ctrlKey) event.preventDefault(); // Avoid zooming the browser window + event.stopPropagation(); + let delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15; + let snap = event.shiftKey ? delta : 5; + this.data.update({direction: this.data.direction + snap * Math.sign(event.deltaY)}); + this.refresh(); + }; - // Rotate the template by 3 degree increments (mouse-wheel) - handlers.mw = event => { - if ( event.ctrlKey ) event.preventDefault(); // Avoid zooming the browser window - event.stopPropagation(); - let delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15; - let snap = event.shiftKey ? delta : 5; - this.data.update({direction: this.data.direction + (snap * Math.sign(event.deltaY))}); - this.refresh(); - }; - - // Activate listeners - canvas.stage.on("mousemove", handlers.mm); - canvas.stage.on("mousedown", handlers.lc); - canvas.app.view.oncontextmenu = handlers.rc; - canvas.app.view.onwheel = handlers.mw; - } + // Activate listeners + canvas.stage.on("mousemove", handlers.mm); + canvas.stage.on("mousedown", handlers.lc); + canvas.app.view.oncontextmenu = handlers.rc; + canvas.app.view.onwheel = handlers.mw; + } } diff --git a/module/settings.js b/module/settings.js index 2adde8d3..3b765c69 100644 --- a/module/settings.js +++ b/module/settings.js @@ -1,145 +1,144 @@ -export const registerSystemSettings = function() { +export const registerSystemSettings = function () { + /** + * Track the system version upon which point a migration was last applied + */ + game.settings.register("sw5e", "systemMigrationVersion", { + name: "System Migration Version", + scope: "world", + config: false, + type: String, + default: game.system.data.version + }); - /** - * Track the system version upon which point a migration was last applied - */ - game.settings.register("sw5e", "systemMigrationVersion", { - name: "System Migration Version", - scope: "world", - config: false, - type: String, - default: game.system.data.version - }); + /** + * Register resting variants + */ + game.settings.register("sw5e", "restVariant", { + name: "SETTINGS.5eRestN", + hint: "SETTINGS.5eRestL", + scope: "world", + config: true, + default: "normal", + type: String, + choices: { + normal: "SETTINGS.5eRestPHB", + gritty: "SETTINGS.5eRestGritty", + epic: "SETTINGS.5eRestEpic" + } + }); - /** - * Register resting variants - */ - game.settings.register("sw5e", "restVariant", { - name: "SETTINGS.5eRestN", - hint: "SETTINGS.5eRestL", - scope: "world", - config: true, - default: "normal", - type: String, - choices: { - "normal": "SETTINGS.5eRestPHB", - "gritty": "SETTINGS.5eRestGritty", - "epic": "SETTINGS.5eRestEpic", - } - }); + /** + * Register diagonal movement rule setting + */ + game.settings.register("sw5e", "diagonalMovement", { + name: "SETTINGS.5eDiagN", + hint: "SETTINGS.5eDiagL", + scope: "world", + config: true, + default: "555", + type: String, + choices: { + 555: "SETTINGS.5eDiagPHB", + 5105: "SETTINGS.5eDiagDMG", + EUCL: "SETTINGS.5eDiagEuclidean" + }, + onChange: (rule) => (canvas.grid.diagonalRule = rule) + }); - /** - * Register diagonal movement rule setting - */ - game.settings.register("sw5e", "diagonalMovement", { - name: "SETTINGS.5eDiagN", - hint: "SETTINGS.5eDiagL", - scope: "world", - config: true, - default: "555", - type: String, - choices: { - "555": "SETTINGS.5eDiagPHB", - "5105": "SETTINGS.5eDiagDMG", - "EUCL": "SETTINGS.5eDiagEuclidean", - }, - onChange: rule => canvas.grid.diagonalRule = rule - }); + /** + * Register Initiative formula setting + */ + game.settings.register("sw5e", "initiativeDexTiebreaker", { + name: "SETTINGS.5eInitTBN", + hint: "SETTINGS.5eInitTBL", + scope: "world", + config: true, + default: false, + type: Boolean + }); - /** - * Register Initiative formula setting - */ - game.settings.register("sw5e", "initiativeDexTiebreaker", { - name: "SETTINGS.5eInitTBN", - hint: "SETTINGS.5eInitTBL", - scope: "world", - config: true, - default: false, - type: Boolean - }); + /** + * Require Currency Carrying Weight + */ + game.settings.register("sw5e", "currencyWeight", { + name: "SETTINGS.5eCurWtN", + hint: "SETTINGS.5eCurWtL", + scope: "world", + config: true, + default: true, + type: Boolean + }); - /** - * Require Currency Carrying Weight - */ - game.settings.register("sw5e", "currencyWeight", { - name: "SETTINGS.5eCurWtN", - hint: "SETTINGS.5eCurWtL", - scope: "world", - config: true, - default: true, - type: Boolean - }); + /** + * Option to disable XP bar for session-based or story-based advancement. + */ + game.settings.register("sw5e", "disableExperienceTracking", { + name: "SETTINGS.5eNoExpN", + hint: "SETTINGS.5eNoExpL", + scope: "world", + config: true, + default: false, + type: Boolean + }); - /** - * Option to disable XP bar for session-based or story-based advancement. - */ - game.settings.register("sw5e", "disableExperienceTracking", { - name: "SETTINGS.5eNoExpN", - hint: "SETTINGS.5eNoExpL", - scope: "world", - config: true, - default: false, - type: Boolean, - }); + /** + * Option to automatically collapse Item Card descriptions + */ + game.settings.register("sw5e", "autoCollapseItemCards", { + name: "SETTINGS.5eAutoCollapseCardN", + hint: "SETTINGS.5eAutoCollapseCardL", + scope: "client", + config: true, + default: false, + type: Boolean, + onChange: (s) => { + ui.chat.render(); + } + }); - /** - * Option to automatically collapse Item Card descriptions - */ - game.settings.register("sw5e", "autoCollapseItemCards", { - name: "SETTINGS.5eAutoCollapseCardN", - hint: "SETTINGS.5eAutoCollapseCardL", - scope: "client", - config: true, - default: false, - type: Boolean, - onChange: s => { - ui.chat.render(); - } - }); + /** + * Option to allow GMs to restrict polymorphing to GMs only. + */ + game.settings.register("sw5e", "allowPolymorphing", { + name: "SETTINGS.5eAllowPolymorphingN", + hint: "SETTINGS.5eAllowPolymorphingL", + scope: "world", + config: true, + default: false, + type: Boolean + }); - /** - * Option to allow GMs to restrict polymorphing to GMs only. - */ - game.settings.register('sw5e', 'allowPolymorphing', { - name: 'SETTINGS.5eAllowPolymorphingN', - hint: 'SETTINGS.5eAllowPolymorphingL', - scope: 'world', - config: true, - default: false, - type: Boolean - }); - - /** - * Remember last-used polymorph settings. - */ - game.settings.register('sw5e', 'polymorphSettings', { - scope: 'client', - default: { - keepPhysical: false, - keepMental: false, - keepSaves: false, - keepSkills: false, - mergeSaves: false, - mergeSkills: false, - keepClass: false, - keepFeats: false, - keepPowers: false, - keepItems: false, - keepBio: false, - keepVision: true, - transformTokens: true - } - }); - game.settings.register("sw5e", "colorTheme", { - name: "SETTINGS.SWColorN", - hint: "SETTINGS.SWColorL", - scope: "world", - config: true, - default: "light", - type: String, - choices: { - "light": "SETTINGS.SWColorLight", - "dark": "SETTINGS.SWColorDark" - } - }); + /** + * Remember last-used polymorph settings. + */ + game.settings.register("sw5e", "polymorphSettings", { + scope: "client", + default: { + keepPhysical: false, + keepMental: false, + keepSaves: false, + keepSkills: false, + mergeSaves: false, + mergeSkills: false, + keepClass: false, + keepFeats: false, + keepPowers: false, + keepItems: false, + keepBio: false, + keepVision: true, + transformTokens: true + } + }); + game.settings.register("sw5e", "colorTheme", { + name: "SETTINGS.SWColorN", + hint: "SETTINGS.SWColorL", + scope: "world", + config: true, + default: "light", + type: String, + choices: { + light: "SETTINGS.SWColorLight", + dark: "SETTINGS.SWColorDark" + } + }); }; diff --git a/module/templates.js b/module/templates.js index a27bdf71..e810b6da 100644 --- a/module/templates.js +++ b/module/templates.js @@ -3,34 +3,33 @@ * Pre-loaded templates are compiled and cached for fast access when rendering * @return {Promise} */ -export const preloadHandlebarsTemplates = async function() { - return loadTemplates([ +export const preloadHandlebarsTemplates = async function () { + return loadTemplates([ + // Shared Partials + "systems/sw5e/templates/actors/parts/active-effects.html", - // Shared Partials - "systems/sw5e/templates/actors/parts/active-effects.html", + // Actor Sheet Partials + "systems/sw5e/templates/actors/oldActor/parts/actor-traits.html", + "systems/sw5e/templates/actors/oldActor/parts/actor-inventory.html", + "systems/sw5e/templates/actors/oldActor/parts/actor-features.html", + "systems/sw5e/templates/actors/oldActor/parts/actor-powerbook.html", + "systems/sw5e/templates/actors/oldActor/parts/actor-notes.html", - // Actor Sheet Partials - "systems/sw5e/templates/actors/oldActor/parts/actor-traits.html", - "systems/sw5e/templates/actors/oldActor/parts/actor-inventory.html", - "systems/sw5e/templates/actors/oldActor/parts/actor-features.html", - "systems/sw5e/templates/actors/oldActor/parts/actor-powerbook.html", - "systems/sw5e/templates/actors/oldActor/parts/actor-notes.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-biography.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-core.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-crew.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-active-effects.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-features.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-inventory.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-force-powerbook.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-tech-powerbook.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-resources.html", + "systems/sw5e/templates/actors/newActor/parts/swalt-traits.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-biography.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-core.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-crew.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-active-effects.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-features.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-inventory.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-force-powerbook.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-tech-powerbook.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-resources.html", - "systems/sw5e/templates/actors/newActor/parts/swalt-traits.html", - - // Item Sheet Partials - "systems/sw5e/templates/items/parts/item-action.html", - "systems/sw5e/templates/items/parts/item-activation.html", - "systems/sw5e/templates/items/parts/item-description.html", - "systems/sw5e/templates/items/parts/item-mountable.html" - ]); + // Item Sheet Partials + "systems/sw5e/templates/items/parts/item-action.html", + "systems/sw5e/templates/items/parts/item-activation.html", + "systems/sw5e/templates/items/parts/item-description.html", + "systems/sw5e/templates/items/parts/item-mountable.html" + ]); }; diff --git a/module/token.js b/module/token.js index db811603..873372cc 100644 --- a/module/token.js +++ b/module/token.js @@ -3,11 +3,10 @@ * @extends {TokenDocument} */ export class TokenDocument5e extends TokenDocument { - /** @inheritdoc */ getBarAttribute(...args) { const data = super.getBarAttribute(...args); - if ( data && (data.attribute === "attributes.hp") ) { + if (data && data.attribute === "attributes.hp") { data.value += parseInt(getProperty(this.actor.data, "data.attributes.hp.temp") || 0); data.max += parseInt(getProperty(this.actor.data, "data.attributes.hp.tempmax") || 0); } @@ -15,19 +14,16 @@ export class TokenDocument5e extends TokenDocument { } } - /* -------------------------------------------- */ - /** * Extend the base Token class to implement additional system-specific logic. * @extends {Token} */ export class Token5e extends Token { - /** @inheritdoc */ _drawBar(number, bar, data) { - if ( data.attribute === "attributes.hp" ) return this._drawHPBar(number, bar, data); + if (data.attribute === "attributes.hp") return this._drawHPBar(number, bar, data); return super._drawBar(number, bar, data); } @@ -41,7 +37,6 @@ export class Token5e extends Token { * @private */ _drawHPBar(number, bar, data) { - // Extract health data let {value, max, temp, tempmax} = this.document.actor.data.data.attributes.hp; temp = Number(temp || 0); @@ -58,42 +53,50 @@ export class Token5e extends Token { // Determine colors to use const blk = 0x000000; - const hpColor = PIXI.utils.rgb2hex([(1-(colorPct/2)), colorPct, 0]); + const hpColor = PIXI.utils.rgb2hex([1 - colorPct / 2, colorPct, 0]); const c = CONFIG.SW5E.tokenHPColors; // Determine the container size (logic borrowed from core) const w = this.w; - let h = Math.max((canvas.dimensions.size / 12), 8); - if ( this.data.height >= 2 ) h *= 1.6; + let h = Math.max(canvas.dimensions.size / 12, 8); + if (this.data.height >= 2) h *= 1.6; const bs = Math.clamped(h / 8, 1, 2); - const bs1 = bs+1; + const bs1 = bs + 1; // Overall bar container - bar.clear() + bar.clear(); bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, w, h, 3); // Temporary maximum HP if (tempmax > 0) { const pct = max / effectiveMax; - bar.beginFill(c.tempmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2); + bar.beginFill(c.tempmax, 1.0) + .lineStyle(1, blk, 1.0) + .drawRoundedRect(pct * w, 0, (1 - pct) * w, h, 2); } // Maximum HP penalty else if (tempmax < 0) { const pct = (max + tempmax) / max; - bar.beginFill(c.negmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2); + bar.beginFill(c.negmax, 1.0) + .lineStyle(1, blk, 1.0) + .drawRoundedRect(pct * w, 0, (1 - pct) * w, h, 2); } // Health bar - bar.beginFill(hpColor, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, valuePct*w, h, 2) + bar.beginFill(hpColor, 1.0) + .lineStyle(bs, blk, 1.0) + .drawRoundedRect(0, 0, valuePct * w, h, 2); // Temporary hit points - if ( temp > 0 ) { - bar.beginFill(c.temp, 1.0).lineStyle(0).drawRoundedRect(bs1, bs1, (tempPct*w)-(2*bs1), h-(2*bs1), 1); + if (temp > 0) { + bar.beginFill(c.temp, 1.0) + .lineStyle(0) + .drawRoundedRect(bs1, bs1, tempPct * w - 2 * bs1, h - 2 * bs1, 1); } // Set position - let posY = (number === 0) ? (this.h - h) : 0; + let posY = number === 0 ? this.h - h : 0; bar.position.set(0, posY); } } diff --git a/sw5e.js b/sw5e.js index 9c691619..3a1f6439 100644 --- a/sw5e.js +++ b/sw5e.js @@ -8,17 +8,17 @@ */ // Import Modules -import { SW5E } from "./module/config.js"; -import { registerSystemSettings } from "./module/settings.js"; -import { preloadHandlebarsTemplates } from "./module/templates.js"; -import { _getInitiativeFormula } from "./module/combat.js"; -import { measureDistances } from "./module/canvas.js"; +import {SW5E} from "./module/config.js"; +import {registerSystemSettings} from "./module/settings.js"; +import {preloadHandlebarsTemplates} from "./module/templates.js"; +import {_getInitiativeFormula} from "./module/combat.js"; +import {measureDistances} from "./module/canvas.js"; // Import Documents import Actor5e from "./module/actor/entity.js"; import Item5e from "./module/item/entity.js"; import CharacterImporter from "./module/characterImporter.js"; -import { TokenDocument5e, Token5e } from "./module/token.js" +import {TokenDocument5e, Token5e} from "./module/token.js"; // Import Applications import AbilityTemplate from "./module/pixi/ability-template.js"; @@ -46,119 +46,137 @@ import * as migrations from "./module/migration.js"; /* Foundry VTT Initialization */ /* -------------------------------------------- */ -Hooks.once("init", function() { - console.log(`SW5e | Initializing SW5E System\n${SW5E.ASCII}`); +Hooks.once("init", function () { + console.log(`SW5e | Initializing SW5E System\n${SW5E.ASCII}`); - // Create a SW5E namespace within the game global - game.sw5e = { - applications: { - AbilityUseDialog, - ActorSheetFlags, - ActorSheet5eCharacter, - ActorSheet5eCharacterNew, - ActorSheet5eNPC, - ActorSheet5eNPCNew, - ActorSheet5eVehicle, - ItemSheet5e, - ShortRestDialog, - TraitSelector, - ActorMovementConfig, - ActorSensesConfig - }, - canvas: { - AbilityTemplate - }, - config: SW5E, - dice: dice, - entities: { - Actor5e, - Item5e, - TokenDocument5e, - Token5e, - }, - macros: macros, - migrations: migrations, - rollItemMacro: macros.rollItemMacro - }; + // Create a SW5E namespace within the game global + game.sw5e = { + applications: { + AbilityUseDialog, + ActorSheetFlags, + ActorSheet5eCharacter, + ActorSheet5eCharacterNew, + ActorSheet5eNPC, + ActorSheet5eNPCNew, + ActorSheet5eVehicle, + ItemSheet5e, + ShortRestDialog, + TraitSelector, + ActorMovementConfig, + ActorSensesConfig + }, + canvas: { + AbilityTemplate + }, + config: SW5E, + dice: dice, + entities: { + Actor5e, + Item5e, + TokenDocument5e, + Token5e + }, + macros: macros, + migrations: migrations, + rollItemMacro: macros.rollItemMacro + }; - // Record Configuration Values - CONFIG.SW5E = SW5E; - CONFIG.Actor.documentClass = Actor5e; - CONFIG.Item.documentClass = Item5e; - CONFIG.Token.documentClass = TokenDocument5e; - CONFIG.Token.objectClass = Token5e; - CONFIG.time.roundTime = 6; - CONFIG.fontFamilies = [ - "Engli-Besh", - "Open Sans", - "Russo One" - ]; + // Record Configuration Values + CONFIG.SW5E = SW5E; + CONFIG.Actor.documentClass = Actor5e; + CONFIG.Item.documentClass = Item5e; + CONFIG.Token.documentClass = TokenDocument5e; + CONFIG.Token.objectClass = Token5e; + CONFIG.time.roundTime = 6; + CONFIG.fontFamilies = ["Engli-Besh", "Open Sans", "Russo One"]; - CONFIG.Dice.DamageRoll = dice.DamageRoll; - CONFIG.Dice.D20Roll = dice.D20Roll; + CONFIG.Dice.DamageRoll = dice.DamageRoll; + CONFIG.Dice.D20Roll = dice.D20Roll; - // 5e cone RAW should be 53.13 degrees - CONFIG.MeasuredTemplate.defaults.angle = 53.13; + // 5e cone RAW should be 53.13 degrees + CONFIG.MeasuredTemplate.defaults.angle = 53.13; - // Add DND5e namespace for module compatability - game.dnd5e = game.sw5e; - CONFIG.DND5E = CONFIG.SW5E; + // Add DND5e namespace for module compatability + game.dnd5e = game.sw5e; + CONFIG.DND5E = CONFIG.SW5E; - // Register System Settings - registerSystemSettings(); + // Register System Settings + registerSystemSettings(); - // Patch Core Functions - CONFIG.Combat.initiative.formula = "1d20 + @attributes.init.mod + @attributes.init.prof + @attributes.init.bonus"; - Combatant.prototype._getInitiativeFormula = _getInitiativeFormula; + // Patch Core Functions + CONFIG.Combat.initiative.formula = "1d20 + @attributes.init.mod + @attributes.init.prof + @attributes.init.bonus"; + Combatant.prototype._getInitiativeFormula = _getInitiativeFormula; - // Register Roll Extensions - CONFIG.Dice.rolls.push(dice.D20Roll); - CONFIG.Dice.rolls.push(dice.DamageRoll); + // Register Roll Extensions + CONFIG.Dice.rolls.push(dice.D20Roll); + CONFIG.Dice.rolls.push(dice.DamageRoll); - // Register sheet application classes - Actors.unregisterSheet("core", ActorSheet); - Actors.registerSheet("sw5e", ActorSheet5eCharacterNew, { - types: ["character"], - makeDefault: true, - label: "SW5E.SheetClassCharacter" - }); - Actors.registerSheet("sw5e", ActorSheet5eCharacter, { - types: ["character"], - makeDefault: false, - label: "SW5E.SheetClassCharacterOld" - }); - Actors.registerSheet("sw5e", ActorSheet5eNPCNew, { - types: ["npc"], - makeDefault: true, - label: "SW5E.SheetClassNPC" - }); - Actors.registerSheet("sw5e", ActorSheet5eNPC, { - types: ["npc"], - makeDefault: false, - label: "SW5E.SheetClassNPCOld" - }); - // Actors.registerSheet("sw5e", ActorSheet5eStarship, { - // types: ["starship"], - // makeDefault: true, - // label: "SW5E.SheetClassStarship" - // }); - Actors.registerSheet('sw5e', ActorSheet5eVehicle, { - types: ['vehicle'], - makeDefault: true, - label: "SW5E.SheetClassVehicle" - }); - Items.unregisterSheet("core", ItemSheet); - Items.registerSheet("sw5e", ItemSheet5e, { - types: ['weapon', 'equipment', 'consumable', 'tool', 'loot', 'class', 'power', 'feat', 'species', 'backpack', 'archetype', 'classfeature', 'background', 'fightingmastery', 'fightingstyle', 'lightsaberform', 'deployment', 'deploymentfeature', 'starship', 'starshipfeature', 'starshipmod', 'venture'], - makeDefault: true, - label: "SW5E.SheetClassItem" - }); + // Register sheet application classes + Actors.unregisterSheet("core", ActorSheet); + Actors.registerSheet("sw5e", ActorSheet5eCharacterNew, { + types: ["character"], + makeDefault: true, + label: "SW5E.SheetClassCharacter" + }); + Actors.registerSheet("sw5e", ActorSheet5eCharacter, { + types: ["character"], + makeDefault: false, + label: "SW5E.SheetClassCharacterOld" + }); + Actors.registerSheet("sw5e", ActorSheet5eNPCNew, { + types: ["npc"], + makeDefault: true, + label: "SW5E.SheetClassNPC" + }); + Actors.registerSheet("sw5e", ActorSheet5eNPC, { + types: ["npc"], + makeDefault: false, + label: "SW5E.SheetClassNPCOld" + }); + // Actors.registerSheet("sw5e", ActorSheet5eStarship, { + // types: ["starship"], + // makeDefault: true, + // label: "SW5E.SheetClassStarship" + // }); + Actors.registerSheet("sw5e", ActorSheet5eVehicle, { + types: ["vehicle"], + makeDefault: true, + label: "SW5E.SheetClassVehicle" + }); + Items.unregisterSheet("core", ItemSheet); + Items.registerSheet("sw5e", ItemSheet5e, { + types: [ + "weapon", + "equipment", + "consumable", + "tool", + "loot", + "class", + "power", + "feat", + "species", + "backpack", + "archetype", + "classfeature", + "background", + "fightingmastery", + "fightingstyle", + "lightsaberform", + "deployment", + "deploymentfeature", + "starship", + "starshipfeature", + "starshipmod", + "venture" + ], + makeDefault: true, + label: "SW5E.SheetClassItem" + }); - // Preload Handlebars Templates - return preloadHandlebarsTemplates(); + // Preload Handlebars Templates + return preloadHandlebarsTemplates(); }); - /* -------------------------------------------- */ /* Foundry VTT Setup */ /* -------------------------------------------- */ @@ -166,131 +184,175 @@ Hooks.once("init", function() { /** * This function runs after game data has been requested and loaded from the servers, so entities exist */ -Hooks.once("setup", function() { +Hooks.once("setup", function () { + // Localize CONFIG objects once up-front + const toLocalize = [ + "abilities", + "abilityAbbreviations", + "abilityActivationTypes", + "abilityConsumptionTypes", + "actorSizes", + "alignments", + "armorProficiencies", + "armorPropertiesTypes", + "conditionTypes", + "consumableTypes", + "cover", + "currencies", + "damageResistanceTypes", + "damageTypes", + "distanceUnits", + "equipmentTypes", + "healingTypes", + "itemActionTypes", + "languages", + "limitedUsePeriods", + "movementTypes", + "movementUnits", + "polymorphSettings", + "proficiencyLevels", + "senses", + "skills", + "starshipRolessm", + "starshipRolesmed", + "starshipRoleslg", + "starshipRoleshuge", + "starshipRolesgrg", + "starshipSkills", + "powerComponents", + "powerLevels", + "powerPreparationModes", + "powerScalingModes", + "powerSchools", + "targetTypes", + "timePeriods", + "toolProficiencies", + "weaponProficiencies", + "weaponProperties", + "weaponSizes", + "weaponTypes" + ]; - // Localize CONFIG objects once up-front - const toLocalize = [ - "abilities", "abilityAbbreviations", "abilityActivationTypes", "abilityConsumptionTypes", "actorSizes", "alignments", - "armorProficiencies", "armorPropertiesTypes", "conditionTypes", "consumableTypes", "cover", "currencies", "damageResistanceTypes", - "damageTypes", "distanceUnits", "equipmentTypes", "healingTypes", "itemActionTypes", "languages", - "limitedUsePeriods", "movementTypes", "movementUnits", "polymorphSettings", "proficiencyLevels", "senses", "skills", - "starshipRolessm", "starshipRolesmed", "starshipRoleslg", "starshipRoleshuge", "starshipRolesgrg", "starshipSkills", - "powerComponents", "powerLevels", "powerPreparationModes", "powerScalingModes", "powerSchools", "targetTypes", - "timePeriods", "toolProficiencies", "weaponProficiencies", "weaponProperties", "weaponSizes", "weaponTypes" - ]; + // Exclude some from sorting where the default order matters + const noSort = [ + "abilities", + "alignments", + "currencies", + "distanceUnits", + "movementUnits", + "itemActionTypes", + "proficiencyLevels", + "limitedUsePeriods", + "powerComponents", + "powerLevels", + "powerPreparationModes", + "weaponTypes" + ]; - // Exclude some from sorting where the default order matters - const noSort = [ - "abilities", "alignments", "currencies", "distanceUnits", "movementUnits", "itemActionTypes", "proficiencyLevels", - "limitedUsePeriods", "powerComponents", "powerLevels", "powerPreparationModes", "weaponTypes" - ]; - - // Localize and sort CONFIG objects - for ( let o of toLocalize ) { - const localized = Object.entries(CONFIG.SW5E[o]).map(e => { - return [e[0], game.i18n.localize(e[1])]; - }); - if ( !noSort.includes(o) ) localized.sort((a, b) => a[1].localeCompare(b[1])); - CONFIG.SW5E[o] = localized.reduce((obj, e) => { - obj[e[0]] = e[1]; - return obj; - }, {}); - } - // add DND5E translation for module compatability - game.i18n.translations.DND5E = game.i18n.translations.SW5E; - // console.log(game.settings.get("sw5e", "colorTheme")); - let theme = game.settings.get("sw5e", "colorTheme") + '-theme'; - document.body.classList.add(theme); + // Localize and sort CONFIG objects + for (let o of toLocalize) { + const localized = Object.entries(CONFIG.SW5E[o]).map((e) => { + return [e[0], game.i18n.localize(e[1])]; + }); + if (!noSort.includes(o)) localized.sort((a, b) => a[1].localeCompare(b[1])); + CONFIG.SW5E[o] = localized.reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {}); + } + // add DND5E translation for module compatability + game.i18n.translations.DND5E = game.i18n.translations.SW5E; + // console.log(game.settings.get("sw5e", "colorTheme")); + let theme = game.settings.get("sw5e", "colorTheme") + "-theme"; + document.body.classList.add(theme); }); /* -------------------------------------------- */ /** * Once the entire VTT framework is initialized, check to see if we should perform a data migration */ -Hooks.once("ready", function() { +Hooks.once("ready", function () { + // Wait to register hotbar drop hook on ready so that modules could register earlier if they want to + Hooks.on("hotbarDrop", (bar, data, slot) => macros.create5eMacro(data, slot)); - // Wait to register hotbar drop hook on ready so that modules could register earlier if they want to - Hooks.on("hotbarDrop", (bar, data, slot) => macros.create5eMacro(data, slot)); + // Determine whether a system migration is required and feasible + if (!game.user.isGM) return; + const currentVersion = game.settings.get("sw5e", "systemMigrationVersion"); + const NEEDS_MIGRATION_VERSION = "1.3.5.R1-A6"; + // Check for R1 SW5E versions + const SW5E_NEEDS_MIGRATION_VERSION = "R1-A6"; + const COMPATIBLE_MIGRATION_VERSION = 0.8; + const needsMigration = + currentVersion && + (isNewerVersion(SW5E_NEEDS_MIGRATION_VERSION, currentVersion) || + isNewerVersion(NEEDS_MIGRATION_VERSION, currentVersion)); + if (!needsMigration && needsMigration !== "") return; - // Determine whether a system migration is required and feasible - if ( !game.user.isGM ) return; - const currentVersion = game.settings.get("sw5e", "systemMigrationVersion"); - const NEEDS_MIGRATION_VERSION = "1.3.5.R1-A6"; - // Check for R1 SW5E versions - const SW5E_NEEDS_MIGRATION_VERSION = "R1-A6"; - const COMPATIBLE_MIGRATION_VERSION = 0.80; - const needsMigration = currentVersion && (isNewerVersion(SW5E_NEEDS_MIGRATION_VERSION, currentVersion) || isNewerVersion(NEEDS_MIGRATION_VERSION, currentVersion)); - if (!needsMigration && needsMigration !== "") return; - - // Perform the migration - if ( currentVersion && isNewerVersion(COMPATIBLE_MIGRATION_VERSION, currentVersion) ) { - const warning = `Your SW5e system data is from too old a Foundry version and cannot be reliably migrated to the latest version. The process will be attempted, but errors may occur.`; - ui.notifications.error(warning, {permanent: true}); - } - migrations.migrateWorld(); + // Perform the migration + if (currentVersion && isNewerVersion(COMPATIBLE_MIGRATION_VERSION, currentVersion)) { + const warning = `Your SW5e system data is from too old a Foundry version and cannot be reliably migrated to the latest version. The process will be attempted, but errors may occur.`; + ui.notifications.error(warning, {permanent: true}); + } + migrations.migrateWorld(); }); /* -------------------------------------------- */ /* Canvas Initialization */ /* -------------------------------------------- */ -Hooks.on("canvasInit", function() { - // Extend Diagonal Measurement - canvas.grid.diagonalRule = game.settings.get("sw5e", "diagonalMovement"); - SquareGrid.prototype.measureDistances = measureDistances; +Hooks.on("canvasInit", function () { + // Extend Diagonal Measurement + canvas.grid.diagonalRule = game.settings.get("sw5e", "diagonalMovement"); + SquareGrid.prototype.measureDistances = measureDistances; }); - /* -------------------------------------------- */ /* Other Hooks */ /* -------------------------------------------- */ Hooks.on("renderChatMessage", (app, html, data) => { + // Display action buttons + chat.displayChatActionButtons(app, html, data); - // Display action buttons - chat.displayChatActionButtons(app, html, data); + // Highlight critical success or failure die + chat.highlightCriticalSuccessFailure(app, html, data); - // Highlight critical success or failure die - chat.highlightCriticalSuccessFailure(app, html, data); - - // Optionally collapse the content - if (game.settings.get("sw5e", "autoCollapseItemCards")) html.find(".card-content").hide(); + // Optionally collapse the content + if (game.settings.get("sw5e", "autoCollapseItemCards")) html.find(".card-content").hide(); }); Hooks.on("getChatLogEntryContext", chat.addChatMessageContextOptions); Hooks.on("renderChatLog", (app, html, data) => Item5e.chatListeners(html)); Hooks.on("renderChatPopout", (app, html, data) => Item5e.chatListeners(html)); -Hooks.on('getActorDirectoryEntryContext', Actor5e.addDirectoryContextOptions); -Hooks.on("renderSceneDirectory", (app, html, data)=> { - //console.log(html.find("header.folder-header")); - setFolderBackground(html); +Hooks.on("getActorDirectoryEntryContext", Actor5e.addDirectoryContextOptions); +Hooks.on("renderSceneDirectory", (app, html, data) => { + //console.log(html.find("header.folder-header")); + setFolderBackground(html); }); -Hooks.on("renderActorDirectory", (app, html, data)=> { - setFolderBackground(html); - CharacterImporter.addImportButton(html); +Hooks.on("renderActorDirectory", (app, html, data) => { + setFolderBackground(html); + CharacterImporter.addImportButton(html); }); -Hooks.on("renderItemDirectory", (app, html, data)=> { - setFolderBackground(html); +Hooks.on("renderItemDirectory", (app, html, data) => { + setFolderBackground(html); }); -Hooks.on("renderJournalDirectory", (app, html, data)=> { - setFolderBackground(html); +Hooks.on("renderJournalDirectory", (app, html, data) => { + setFolderBackground(html); }); -Hooks.on("renderRollTableDirectory", (app, html, data)=> { - setFolderBackground(html); +Hooks.on("renderRollTableDirectory", (app, html, data) => { + setFolderBackground(html); }); Hooks.on("ActorSheet5eCharacterNew", (app, html, data) => { - console.log("renderSwaltSheet"); + console.log("renderSwaltSheet"); }); // FIXME: This helper is needed for the vehicle sheet. It should probably be refactored. -Handlebars.registerHelper('getProperty', function (data, property) { - return getProperty(data, property); +Handlebars.registerHelper("getProperty", function (data, property) { + return getProperty(data, property); }); - function setFolderBackground(html) { - html.find("header.folder-header").each(function() { - let bgColor = $(this).css("background-color"); - if(bgColor == undefined) - bgColor = "rgb(255,255,255)"; - $(this).closest('li').css("background-color", bgColor); - }) -} \ No newline at end of file + html.find("header.folder-header").each(function () { + let bgColor = $(this).css("background-color"); + if (bgColor == undefined) bgColor = "rgb(255,255,255)"; + $(this).closest("li").css("background-color", bgColor); + }); +}