From 5fe2740b5a211b59993093a5f28d1e040fb208a8 Mon Sep 17 00:00:00 2001 From: supervj <64861570+supervj@users.noreply.github.com> Date: Fri, 16 Jul 2021 15:26:24 -0400 Subject: [PATCH] Add Prettier, fix Actor power DC's See text above --- .prettierrc | 14 + module/actor/entity.js | 4360 ++++++++++++++++---------------- module/actor/old_entity.js | 2040 --------------- packs/packs/adventuringgear.db | 2 +- packs/packs/species.db | 2 - packs/packs/weapons.db | 1 - 6 files changed, 2263 insertions(+), 4156 deletions(-) create mode 100644 .prettierrc delete mode 100644 module/actor/old_entity.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..592d302e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "printWidth": 120, + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "consistent", + "jsxSingleQuote": false, + "trailingComma": "none", + "bracketSpacing": false, + "jsxBracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/module/actor/entity.js b/module/actor/entity.js index 3250fe70..3d8855f9 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,2209 +10,2345 @@ 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; - - /* -------------------------------------------- */ - /* 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); - } - } - - /* -------------------------------------------- */ - - /** @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); - - 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.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); - - // 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; - 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.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 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; - } - - } - - /* -------------------------------------------- */ - - /** - * 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) { - // 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 */ - /* -------------------------------------------- */ - - _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 - /* -------------------------------------------- */ - - /** @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) - } - } - }).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 } - } - }); - 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 = 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); - } - - /* -------------------------------------------- */ - - /** - * 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 = {}; - - // 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; - } - - /* -------------------------------------------- */ - - /** - * 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; - } - - /* -------------------------------------------- */ - - /** - * 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; - } - - /* -------------------------------------------- */ - - /** - * 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; - } - - // 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; - } - + /** + * The data source for Actor5e.classes allowing it to be lazily computed. + * @type {Object} + * @private + */ + _classes = undefined; + + /* -------------------------------------------- */ + /* Properties */ /* -------------------------------------------- */ - /** - * 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; + /** + * 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; + }, {})); } - // 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)); - }); + /* -------------------------------------------- */ + + /** + * Is this Actor currently polymorphed into some other creature? + * @type {boolean} + */ + get isPolymorphed() { + return this.getFlag("sw5e", "isPolymorphed") || false; } - // 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; + /* -------------------------------------------- */ + /* Methods */ + /* -------------------------------------------- */ + + /** @override */ + prepareData() { + super.prepareData(); + + // iterate over owned items and recompute attributes that depend on prepared actor data + this.items.forEach((item) => item.prepareFinalAttributes()); } - // 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; - } - - /* -------------------------------------------- */ - - /** - * 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"; + /** @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); } - }else{ - if (dtp !== 0){ - message = "SW5E.ShortRestResultOnlyTech"; + } + + /* -------------------------------------------- */ + + /** @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 Starship Data + if (actorData.type === "starship") this._computeStarshipData(actorData, data); + + // 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); } - // 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); - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - - /** - * 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; - - 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; + /** + * 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 diceRolled; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - - /** - * 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; - - 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; + /** + * 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]; } - return { updates, hitPointsRecovered: max - data.attributes.hp.value }; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - - /** - * 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; - } - - /* -------------------------------------------- */ - - /** - * 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); - } + /** @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; } - 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); - } + /** + * 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}); } - return updates; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ - - /** - * 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); + /** + * 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)) || []; } - // 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 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; } - return { updates, hitDiceRecovered }; - } + /* -------------------------------------------- */ + /* Data Preparation Helpers */ + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Prepare Character type specific data + */ + _prepareCharacterData(actorData) { + const data = actorData.data; - /** - * 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"); + // 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; - 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}); - } + // 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); } - return updates; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * Prepare NPC type specific data + */ + _prepareNPCData(actorData) { + const data = actorData.data; + // Kill Experience + data.details.xp.value = this.getCRExp(data.details.cr); - /** - * 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}={}) { + // Proficiency + data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4); - // 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; + this._computeBasePowercasting(actorData); + + // Powercaster Level + if (data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel)) { + data.details.powerLevel = Math.max(data.details.cr, 1); + } } - if (coord){ - ssDeploy.coord.uuid = charUUID; - ssDeploy.coord.name = charName; - ssDeploy.coord.rank = charRank.coord; - ssDeploy.coord.prof = charProf; + /* -------------------------------------------- */ + + /** + * 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.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; + } } - if (gunner){ - ssDeploy.gunner.uuid = charUUID; - ssDeploy.gunner.name = charName; - ssDeploy.gunner.rank = charRank.gunner; - ssDeploy.gunner.prof = charProf; + /* -------------------------------------------- */ + + /** + * 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; + } } - if (mech){ - ssDeploy.mechanic.uuid = charUUID; - ssDeploy.mechanic.name = charName; - ssDeploy.mechanic.rank = charRank.mechanic; - ssDeploy.mechanic.prof = charProf; + /* -------------------------------------------- */ + + /** + * 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; + } } - if (oper){ - ssDeploy.operator.uuid = charUUID; - ssDeploy.operator.name = charName; - ssDeploy.operator.rank = charRank.operator; - ssDeploy.operator.prof = charProf; + /* -------------------------------------------- */ + + /** + * 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; + } } - if (pilot){ - ssDeploy.pilot.uuid = charUUID; - ssDeploy.pilot.name = charName; - ssDeploy.pilot.rank = charRank.pilot; - ssDeploy.pilot.prof = charProf; + /* -------------------------------------------- */ + + /** + * 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}; } - if (tech){ - ssDeploy.technician.uuid = charUUID; - ssDeploy.technician.name = charName; - ssDeploy.technician.rank = charRank.technician; - ssDeploy.technician.prof = charProf; + _computeStarshipData(actorData, data) { + // 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.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); } - if (crew){ - ssDeploy.crew.push({"uuid": charUUID, "name": charName, "rank": charRank, "prof": charProf}); + /* -------------------------------------------- */ + /* Event Handlers */ + /* -------------------------------------------- */ + + _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}; } - if (pass){ - ssDeploy.passenger.push({"uuid": charUUID, "name": charName, "rank": charRank, "prof": charProf}); - } - this.update({"data.attributes.deployment": ssDeploy}); - } + /** @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}); - /** - * 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")); + // Player character configuration + if (this.type === "character") { + this.data.token.update({vision: true, actorLink: true, disposition: 1}); + } } - // 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 - }; + /** @inheritdoc */ + async _preUpdate(changed, options, user) { + await super._preUpdate(changed, options, user); - // 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 + // 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; + } + } - // 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)]; + // 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); + } } - // 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); + /* -------------------------------------------- */ + + /** + * 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}); } - // 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); - } + /* -------------------------------------------- */ + /* 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); } - // 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 } - }); + /** + * 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; } - // Keep biography - if (keepBio) d.data.details.biography = o.data.details.biography; + /* -------------------------------------------- */ - // Keep senses - if (keepVision) d.data.traits.senses = o.data.traits.senses; + /** + * 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") || {}; - // Set new data flags - if ( !this.isPolymorphed || !d.flags.sw5e.originalActor ) d.flags.sw5e.originalActor = this.id; - d.flags.sw5e.isPolymorphed = true; + // Compose roll parts and data + const parts = ["@mod"]; + const data = {mod: skl.mod + skl.prof}; - // 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); + // 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); } - // 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")); + /** + * 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); } - // 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}); + /* -------------------------------------------- */ + + /** + * 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} + } + }); + return d20Roll(rollData); } - // 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); + /** + * 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 = 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); } - // 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; - } + /* -------------------------------------------- */ - /* -------------------------------------------- */ + /** + * 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; + } - /** - * 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 => { + // Evaluate a global saving throw bonus + const parts = []; + const data = {}; + + // 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; + } + + /* -------------------------------------------- */ + + /** + * 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; + } + + /* -------------------------------------------- */ + + /** + * 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; + } + + /* -------------------------------------------- */ + + /** + * 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; + } + + // 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; + } + + /* -------------------------------------------- */ + + /** + * 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; + } + + /** + * 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"; + } + } 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); + } + + /* -------------------------------------------- */ + + /** + * 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; + + 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; + } + + return diceRolled; + } + + /* -------------------------------------------- */ + + /** + * 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; + + 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; + } + + return {updates, hitPointsRecovered: max - data.attributes.hp.value}; + } + + /* -------------------------------------------- */ + + /** + * 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; + } + + /* -------------------------------------------- */ + + /** + * 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; + } + + /* -------------------------------------------- */ + + /** + * 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}; + } + + /* -------------------------------------------- */ + + /** + * 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; + } + + /* -------------------------------------------- */ + + /** + * 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 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 deleted file mode 100644 index 23080d27..00000000 --- a/module/actor/old_entity.js +++ /dev/null @@ -1,2040 +0,0 @@ -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'; - -/** - * 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; - } - } - } - - // 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 - } - 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 }} - }); - 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 - }); - 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; - } - - /* -------------------------------------------- */ - - /** - * 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 = 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; - - // 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; - } - - /* -------------------------------------------- */ - - /** - * 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; - } - - /* -------------------------------------------- */ - - /** - * 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; - } - - // 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; - } - - /* -------------------------------------------- */ - - /** - * 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"; - } - }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 - } - } - - /* -------------------------------------------- */ - - /** - * 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 - } - } - - /* -------------------------------------------- */ - - - /** - * 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 => { - 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(); - } -} \ No newline at end of file diff --git a/packs/packs/adventuringgear.db b/packs/packs/adventuringgear.db index 428c31ce..e9b74e5a 100644 --- a/packs/packs/adventuringgear.db +++ b/packs/packs/adventuringgear.db @@ -52,7 +52,7 @@ {"name":"Traz","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"tool","data":{"description":{"value":"","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":6,"price":300,"attuned":false,"equipped":false,"rarity":"","identified":true,"ability":"int","chatFlavor":"","proficient":0,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Musical%20Instrument/Traz.webp","_id":"UQu4duMtxYEXKAbo"} {"name":"Tent, two-person","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"loot","data":{"description":{"value":"","chat":"","unidentified":""},"source":"","quantity":1,"weight":5,"price":20,"attuned":false,"equipped":false,"rarity":"","identified":true,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{},"img":"systems/sw5e/packs/Icons/Utility/Tent.webp","_id":"UxL0trd3omeqzBk4"} {"name":"Homing Beacon","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"loot","data":{"description":{"value":"

A homing beacon is a device used to track starships or any other entity being transported. Homing beacons transmit using non-mass HoloNet transceivers able to be tracked through hyperspace. Homing beacons are small enough that they can easily be hidden inside a ship, or tucked into some crevice on its exterior.

","chat":"","unidentified":""},"source":"","quantity":1,"weight":1,"price":450,"attuned":false,"equipped":false,"rarity":"","identified":true,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{},"img":"systems/sw5e/packs/Icons/Utility/Homing%20Beacon.webp","_id":"V2hSxkLfq461mvNz"} -{"name":"Power Cell","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"consumable","data":{"description":{"value":"

Power cells fuel blaster weapons that deal energy or ion damage. Additionally, power cells are used to energize certain tools.

","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":1,"price":10,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation": {"type": "none","cost": null,"condition": ""},"duration": {"value": null,"units": ""},"target": {"value": null,"width": null,"units": "","type": ""},"range":{"value": null,"long": null,"units": ""},"uses": {"value": 100,"max": "100","per": "charges","autoDestroy": false},"consume": {"type": "","target": "","amount": null},"ability": null,"actionType": "","attackBonus": 0,"chatFlavor": "","critical": null,"damage": {"parts": [],"versatile": ""},"formula": "","save": {"ability": "","dc": null,"scaling": "spell"},"consumableType": "ammo","attributes": {"spelldc": 10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Ammunition/Power%20Cell.webp","_id":"VUkO1T2aYMuUcBZM"} +{"name":"Power Cell","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"consumable","data":{"description":{"value":"

Power cells fuel blaster weapons that deal energy or ion damage. Additionally, power cells are used to energize certain tools.

","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":1,"price":10,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation":{"type":"none","cost":null,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":100,"max":"100","per":"charges","autoDestroy":false},"consume":{"type":"","target":"","amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"spell"},"consumableType":"ammo","attributes":{"spelldc":10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Ammunition/Power%20Cell.webp","_id":"VUkO1T2aYMuUcBZM"} {"name":"Propulsion pack","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"loot","data":{"description":{"value":"

Propulsion packs enhance underwater movement. Activating or deactivating the propulsion pack requires a bonus action and, while active, you have a swimming speed of 30 feet. The propulsion pack lasts for 1 minute per power cell (to a maximum of 10 minutes) and can be recharged by a power source or replacing the power cells.

","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":20,"price":400,"attuned":false,"equipped":false,"rarity":"","identified":true,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{},"img":"systems/sw5e/packs/Icons/Weapon%20or%20Armor%20Accessory/Propulsion%20Pack.webp","_id":"XR1obpDj1PqDLfA8"} {"name":"Emergency Battery","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"consumable","data":{"description":{"value":"

All non-expendable droids need recharging as they are used. The battery has ten uses. As an action, you can expend one use of the kit to stabilize a droid that has 0 hit points, without needing to make an Intelligence (Technology) check.

","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":5,"price":70,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"units":"","type":"creature"},"range":{"value":null,"long":null,"units":""},"uses":{"value":10,"max":10,"per":"charges","autoDestroy":true},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"other","attackBonus":0,"chatFlavor":"Stabilize Droid","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"spell"},"consumableType":"potion","attributes":{"spelldc":10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Medical/Emergency%20Battery.webp","_id":"Z0YM3aYCyCRhL6cx"} {"name":"Smugglepack","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"backpack","data":{"description":{"value":"

This backpack comes with a main compartment that can store up to 15 lb., not exceeding a volume of 1/2 cubic foot. Additionally, it has a hidden storage compartment that can hold up to 5 lb, not exceeding a volume of 1/4 cubic foot. Finding the hidden compartment requires a DC 15 Investigation check.

","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":6,"price":400,"attuned":false,"equipped":false,"rarity":"","identified":true,"capacity":{"type":"weight","value":20,"weightless":false},"currency":{"cp":0,"sp":0,"ep":0,"gp":0,"pp":0},"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Storage/Smugglerpack.webp","_id":"Zlj5z56A4oVQ5iEC"} diff --git a/packs/packs/species.db b/packs/packs/species.db index b3ae3616..f5122423 100644 --- a/packs/packs/species.db +++ b/packs/packs/species.db @@ -119,5 +119,3 @@ {"_id":"ynuvnI54pMKLwF2a","name":"Echani","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"

Biology and Appearance

Echani are characterized by their white skin, hair, and eyes, and their remarkable tendency to look very much alike one another to outside observers, particularly amongst family members. It is thought that their origins stem from Arkanian experimentation on the human genome, a hypothesis that could explain their physical conformity.

Society and Culture

A matriarchal, caste-based society originating from the Inner Rim world of Eshan, the echani spread to encompass a confederacy of six worlds including Bengali and Thyrsus, known as the Six Sisters, governed by the all-female Echani Command. \r\n\t\r\nEchani generals are sometimes seen by others as having the ability to predict their opponent's next move. This is no biologicial trait inherent to the species, but rather stems from the fact that combat is so ingrained into every level of echani culture; the echani hold to the idea that combat is the truest form of communication, and to know someone fully, you must fight them. While their combat rituals require complete freedom of movement and unarmed martial arts, in warfare, they tend towards light armor and melee weapons, and are considered excellent craftsmen of such.

Names

Echani names tend to lack hard consonants, but are otherwise as variable as human ones. Echani surnames are tied directly to their place in the caste system.

  Male Names. Caelian, Inarin, Losor, Uelis, Yusanis

  Female Names. Astri, Brianna, Isena, Raskta, Senriel

  Surnames. Authal, Elysi, Fenni, Kinro, Lsu","chat":"","unidentified":""},"traits":{"value":"

Ability Score Increase. Your Dexterity score increases by 2, and your Wisdom score increases by 1.

\n

Age. Echani reach adulthood in their late teens and live less than a century.

\n

Alignment. Echani culture's emphasis on honor and combat cause them to tend towards lawful alignments, though there are exceptions.

\n

Size. Echani stand between 5 and a half and 6 feet tall and weigh around 150 lbs, with little variation between them. Your size is Medium.

\n

Speed. Your base walking speed is 30 feet.

\n

Allies of the Force. Whenever you make a Wisdom (Insight) check against someone you know to wield the Force, you are considered to have expertise in the Insight skill.

\n

Combative Culture. You have proficiency in Lore and Acrobatics.

\n

Echani Art. If a humanoid you can see makes a melee weapon attack, you can use your reaction to make a Wisdom (Insight) check against the target's Charisma (Deception). On a success you learn one of the following traits about that creature: it's Strength, Dexterity or Constitution score; bonus to Strength, Dexterity or Constitution saving throws; armor class; or current hit points. On a failure, the target becomes immune to this feature for one day. You can use this ability a number of times equal to your Wisdom modifier (a minimum of once). You regain all expended uses on a long rest.

\n

Martial Upbringing. You have proficiency in light armor, and gain proficiency with two martial vibroweapons of your choice.

\n

Unarmed Combatant. Your unarmed strikes deal 1d6 kinetic damage. You can use your choice of your Strength or Dexterity modifier for the attack and damage rolls. You must use the same modifier for both rolls.

\n

Languages. You can speak, read, and write Galactic Basic and one extra language of your choice.

"},"skinColorOptions":{"value":"Pale tones"},"hairColorOptions":{"value":"White"},"eyeColorOptions":{"value":"Silver"},"distinctions":{"value":"Fair skin, white hair and eyes, remarkable familial similarity."},"heightAverage":{"value":"5'1\""},"heightRollMod":{"value":"+1d10\""},"weightAverage":{"value":"105 lb."},"weightRollMod":{"value":"x(2d4) lb."},"homeworld":{"value":"Eshan"},"slanguage":{"value":"Galactic Basic"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"alwaysActive":false,"effects":[{"modSpecKey":"data.details.species","value":"Echani","mode":"=","targetSpecific":false,"id":1,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.dex.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Abilities Dexterity"},{"modSpecKey":"data.abilities.wis.value","value":"1","mode":"+","targetSpecific":false,"id":3,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Abilities Wisdom"},{"modSpecKey":"data.traits.size","value":"med","mode":"=","targetSpecific":false,"id":4,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"30","mode":"=","targetSpecific":false,"id":5,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.skills.lor.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Skills Lore"},{"modSpecKey":"data.skills.acr.value","value":"1","mode":"+","targetSpecific":false,"id":7,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Skills Acrobatics"},{"modSpecKey":"data.traits.armorProf.value","value":"lgt","mode":"+","targetSpecific":false,"id":8,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":9,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[]}]},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Echani.webp","effects":[{"_id":"HNBMxZCToQEy6PSr","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Echani","mode":5,"priority":5},{"key":"data.abilities.dex.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.traits.size","value":"med","mode":0,"priority":5},{"key":"data.attributes.movement.walk","value":"30","mode":5,"priority":5},{"key":"data.skills.lor.value","value":1,"mode":4,"priority":20},{"key":"data.skills.acr.value","value":1,"mode":4,"priority":20},{"key":"data.traits.armorProf.value","value":"lgt","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"flags.sw5e.unarmedCombatant","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Echani.webp","label":"Echani","tint":"","transfer":true}]} {"_id":"yyCUAG4cUUKh4IUz","name":"Iktotchi","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"

Biology and Appearance

Iktotchi do not have hair, but rather they had a very resistant skin which protected them from the violent winds which crossed the satellite. Both males and females have down-curved cranial horns, which gave them an aggressive aspect. The males' horns are generally a little larger, a remnant from their mountain-dwelling, caprinaen ancestors. The horns are able to regenerate if damaged.

Society and Culture

The Iktotchi are a fiercely guarded and isolationist species - vaunted for their ability to hide their feelings and bury any semblance of emotion. Originating on the harsh, windy moon of Iktotch, which orbits the planet Iktotchon in the Expansion Region, the Iktotch are gifted with precognition, and are courted as often by Jedi as by pirates for their skills.\r\n\r\nIktotchi society is a stratified society. Upward mobility is both possible and encouraged. Iktotchi are an outwardly dispassionate people, which is evidenced by their culture. They have a robust legal system, and suffer little crime. Iktotchi are respectful of cultures other than their own and can easily integrate with others.\r\n\r\nIktotchi who distinguish themselves often earn a titular nickname, by which they are referred to in place of their name. Generally, this is done by accomplishing a remarkable feat that benefits the Iktotchi as whole.

Names

Iktotchi names are generally two syllables. Surnames are familial. Respected Iktotchi often adopt a nickname, which they use in place of their birth name.

  Male Names. Dilnam, Imruth, Kashkil, Yellam

  Female Names. Kemkal, Onyeth, Reshu, Zorlu

  Surnames. Hevil, Kaawi, Mimir, Nudaal, Zelend","chat":"","unidentified":""},"traits":{"value":"

Ability Score Increase. Your Intelligence score increases by 2, and your Strength score increases by 1.

\n

Age. Iktotchi reach adulthood in their late teens and live less than a century.

\n

Alignment. Iktotchi are lawful and tend toward the light side, though there are exceptions.

\n

Size. Iktotchi typically stand between 5 and 6 feet tall and weigh about 170 lbs. Regardless of your position in that range, your size is Medium.

\n

Speed. Your base walking speed is 30 feet.

\n

Precognition. You can see brief fragments of the future that allow you to turn failures into successes. When you roll a 1 on an attack roll, ability check, or saving throw, you can reroll the die and must use the new roll.

\n

Telepathy. You can communicate telepathically with creatures within 30 feet of you. You must share a language with the target in order to communicate in this way.

\n

Horns. Your horns are a natural weapon, which you can use to make unarmed strikes. If you hit with it, you deal kinetic damage equal to 1d6 + your Strength modifier.

\n

Pilot. You have proficiency in the Piloting skill.

\n

Languages. You can speak, read, and write Galactic Basic and Iktotchese.

"},"skinColorOptions":{"value":"Pink"},"hairColorOptions":{"value":"None"},"eyeColorOptions":{"value":"Black"},"distinctions":{"value":"Horns, precognition, telepathy, thick pink skin"},"heightAverage":{"value":"4'11\""},"heightRollMod":{"value":"+2d10\""},"weightAverage":{"value":"120 lb."},"weightRollMod":{"value":"x(2d4) lb."},"homeworld":{"value":"Iktotch, moon of Iktotchon"},"slanguage":{"value":"Iktotchese"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"colorScheme":{"value":""},"manufacturer":{"value":""},"planguage":{"value":""},"droidDistinctions":{"value":""},"droidLanguage":{"value":""},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"alwaysActive":false,"effects":[{"modSpecKey":"data.details.species","value":"Iktotchi","mode":"=","targetSpecific":false,"id":1,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.abilities.str.value","value":"1","mode":"+","targetSpecific":false,"id":3,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Abilities Strength"},{"modSpecKey":"data.traits.size","value":"med","mode":"=","targetSpecific":false,"id":4,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"30","mode":"=","targetSpecific":false,"id":5,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.skills.pil.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":7,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"iktotchese","mode":"+","targetSpecific":false,"id":8,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":"+","targetSpecific":false,"id":9,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]}]},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Iktotchi.webp","effects":[{"_id":"MAjUMql3tivJTfVO","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Iktotchi","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20},{"key":"data.traits.size","value":"med","mode":0,"priority":5},{"key":"data.attributes.movement.walk","value":"30","mode":5,"priority":5},{"key":"data.skills.pil.value","value":1,"mode":4,"priority":20},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"iktotchese","mode":0,"priority":0},{"key":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":0,"priority":0},{"key":"flags.sw5e.precognition","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Iktotchi.webp","label":"Iktotchi","tint":"","transfer":true}]} {"_id":"zKpCsa8WCfz9abwv","name":"Killik","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"

Biology and Appearance

\n

Killiks possess a strong chitinous exoskeleton that is glossy and greenish with their carcasses capable of surviving thousands of years of erosion as seen by the colonists of Alderaan. The exoskeleton also contains a number of spiracles which served as their way of breathing. Typically, these Human-sized hive creatures have four arms with each ending in a powerful three-fingered claw. They stand on two stout legs that are capable of leaping great distances. Killiks can communicate with other Killiks through use of pheromones.

\n

Society and Culture

\n

The Killiks have a communal society, with each and every Killik being in mental contact with another. Due to their hive mind, every Killik nest is virtually one individual. Killiks are also peaceful in nature. Their telepathic connection is capable of extending to other species which includes non-insectoids. A willing creature can submit to this telepathy to become a Joiner. They effectively become another vessel of the hive mind. Killiks lose connection to their hive mind at great distances. Those who voluntarily leave the hive mind are referred to as Leavers. It is rare that they are allowed to rejoin their hive without reason.

\n

Names

\n

Killiks are a hive-mind insectoid that typically don't use names. On the off chance they do, it's usually an incomprehensible series of clicking noises. They are receptive to nicknames given by others.

","chat":"","unidentified":""},"traits":{"value":"

Ability Score Increase. Your Intelligence score increases by 2, and your Constitution score increases by 1.

Age. Killiks reach adulthood in their 40s and live an average of 200 years.

Alignment. Killiks' willingness to brainwash or kill their enemies cause them to tend towards the dark side, though there are exceptions.

Size. Killiks stand between 5 and 6 feet tall and weigh about 160 lbs. Regardless of your position in that range, your size is Medium.

Speed. Your base walking speed is 30 feet.

Four-Armed. Killiks have four arms which they can use independently of one another. You can only gain the benefit of items held by two of your arms at any given time, and once per round you can switch which arms you are benefiting from (no action required).

Hardened Carapace. While you are unarmored or wearing light armor, your AC is 13 + your Dexterity modifier.

Strong-Legged. When you make a long jump, you can cover a number of feet up to twice your Strength score. When you make a high jump, you can leap a number of feet up into the air equal to 3 + twice your Strength modifier.

Telepathy. You can communicate telepathically with creatures within 30 feet of you. You must share a language with the target in order to communicate in this way.

Languages. You can speak, read, and write Killik. You can understand spoken and written Galactic Basic, but your vocal cords do not allow you to speak it.

"},"skinColorOptions":{"value":"Brown, chestnut, green, red, scarlet, or yellow"},"hairColorOptions":{"value":"None"},"eyeColorOptions":{"value":"Black or orange"},"distinctions":{"value":"Chitinous armor, mandibles projected from face, four arms ending in long three toed claws protrude from their torsos"},"heightAverage":{"value":"4'9\""},"heightRollMod":{"value":"+2d10\""},"weightAverage":{"value":"110 lb."},"weightRollMod":{"value":"x(2d4) lb."},"homeworld":{"value":"Alderaan"},"slanguage":{"value":"Killik"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"alwaysActive":false,"effects":[{"modSpecKey":"data.details.species","value":"Killik","mode":"=","targetSpecific":false,"id":1,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.abilities.con.value","value":"1","mode":"+","targetSpecific":false,"id":3,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Abilities Constitution"},{"modSpecKey":"data.traits.size","value":"med","mode":"=","targetSpecific":false,"id":4,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"30","mode":"=","targetSpecific":false,"id":5,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.attributes.ac.min","value":"13","mode":"=","targetSpecific":false,"id":6,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Attributes Armor Class Min"},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":7,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"killik","mode":"+","targetSpecific":false,"id":8,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":"+","targetSpecific":false,"id":9,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[]}]},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Killik.webp","effects":[{"_id":"EWQOMXrZbfi4zSPF","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Killik","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.traits.size","value":"med","mode":0,"priority":5},{"key":"data.attributes.movement.walk","value":"30","mode":5,"priority":5},{"key":"data.attributes.ac.value","value":"13+@abilities.dex.mod","mode":5,"priority":1},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"killik","mode":0,"priority":0},{"key":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":0,"priority":0},{"key":"flags.sw5e.strongLegged","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.extraArms","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Killik.webp","label":"Killik","tint":"","transfer":true}]} -{"_id":"J7HJQkcvghtwMAdF","name":"Anzellan","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"

Biology and Appearance

\n

The anzellans are a diminutive species hailing from the secluded planet Anzella. Their eyes have floating corneal micro-lenses that allow them to see microscopic details. Anzellans are a bubbly and receptive people. They are a jovial and trusting people that tend to welcome strangers with open arms. Due to their volcanic homeworld, anzellans are also adapted towards heat. This, coupled with their small size, make them well-suited to working in compact places.

\n

Society and Culture

\n

Anzella is a tropical planet covered in thousands of small volcanic islands. Many of these islands are developed as small villages, with the largest islands designed to accommodate larger species. Anzellan culture is generally based around tourism and crafting; in fact, anzellans are renowned craftsmen due to their discerning eyesight and ability to fit into small spaces. Anzellan government is generally casual. Each village has its own governing council of rotating members; these villages act independently from one another unless their decisions would affect more than a single island. In that case, all of the councils work together to come to a planet-wide decision.

\n

Names

\n

Anzellan names are rarely longer than two syllables, with a bouncy intonation to them. Their surnames are familial.

\n

  Male Names. Babu, Gridel, Moru, Rano, Yodel

\n

  Female Names. Dibi, Fing, Nooni, Teena, Zazi

\n

  Surnames. E'ayoo, Frik, Meer, Tanni, Vrut

","chat":"","unidentified":""},"traits":{"value":"

Ability Score Increase. Your Intelligence score increases by 2, and two other ability scores of your choice increase by 1.

Age. Anzellans are considered adults at ten years old. They are a short-lived species, however, that rarely lives longer than 60 years.

Alignment. Anzellans are a friendly and respectful people, which causes them to tend toward lawful light side, though there are exceptions.

Size. Anzellans stand between 1 and 2 feet tall and weigh around 10 lbs. Regardless of your position in that range, your size is Tiny.

Speed. Your base walking speed is 20 feet.

Crafters. You have proficiency in one tool of your choice.

Detail Oriented. You are practiced at scouring for details. You have advantage on Intelligence (Investigation) checks within 5 feet.

Pintsized. Your tiny stature makes it hard for you to wield bigger weapons. You can't use medium or heavy shields. Additionally, you can't wield weapons with the two-handed or versatile property, and you can only wield one-handed weapons in two hands unless they have the light property.

Puny. Anzellans are too small to pack much of a punch. You have disadvantage on Strength saving throws, and when determining your bonus to attack and damage rolls for weapon attacks using Strength, you can't add more than +3.

Small and Nimble. You are too small and fast to effectively target. You have a +1 bonus to AC, and you have advantage on Dexterity saving throws.

Tanned. You are naturally adapted to hot climates, as described in chapter 5 of the Dungeon Master's Guide.

Technician. You are proficient in the Technology skill.

Tinker. You have proficiency with tinker's tools. You can use these and spend 1 hour and 100 cr worth of materials to construct a Tiny Device (AC 5, 1 hp). You can take the Use an Object action to have your device cause one of the following effects: create a small explosion, create a repeating loud noise for 1 minute, create smoke for 1 minute, create a soothing melody for 1 minute. You can maintain a number of these devices up to your proficiency bonus at once, and a device stops functioning after 24 hours away from you. You can dismantle the device to reclaim the materials used to create it.

Languages. You can speak, read, and write Galactic Basic and Anzellan. Anzellan is characterized by its bouncy sound and emphasis on alternating syllables.

","source":"Expanded Content"},"skinColorOptions":{"value":"Brown, green, or tan"},"hairColorOptions":{"value":"Black, gray, or white"},"eyeColorOptions":{"value":"Black"},"distinctions":{"value":"Diminutive size, wispy eyebrows"},"heightAverage":{"value":"1'0\""},"heightRollMod":{"value":"+2d6\""},"weightAverage":{"value":"3 lb."},"weightRollMod":{"value":"x1 lb."},"homeworld":{"value":"Anzella"},"slanguage":{"value":"Anzellan"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"effects":[{"modSpecKey":"data.details.species","value":"Anzellan","mode":"=","targetSpecific":false,"id":1,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.traits.size","value":"tin","mode":"=","targetSpecific":false,"id":3,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"20","mode":"=","targetSpecific":false,"id":4,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.attributes.ac.min","value":"1","mode":"+","targetSpecific":false,"id":5,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Armor Class Min"},{"modSpecKey":"data.skills.tec.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Skills Technology"},{"modSpecKey":"data.traits.toolProf.custom","value":"Tinker's tools","mode":"+","targetSpecific":false,"id":7,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Tool Prof Custom"},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":8,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Language"},{"modSpecKey":"data.traits.languages.value","value":"anzellan","mode":"+","targetSpecific":false,"id":9,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[]}],"alwaysActive":false},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Anzellan.webp","effects":[{"_id":"ZGdzAq1Gl4xaxDRD","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Anzellan","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.traits.size","value":"tiny","mode":5,"priority":5},{"key":"data.attributes.movement.walk","value":20,"mode":5,"priority":5},{"key":"data.attributes.ac.value","value":1,"mode":2,"priority":20},{"key":"data.skills.tec.value","value":1,"mode":4,"priority":20},{"key":"data.traits.toolProf.custom","value":"Tinker's tools","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"anzellan","mode":0,"priority":0},{"key":"flags.sw5e.detailOriented","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.pintsized","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.puny","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.tinker","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Anzellan.webp","label":"Anzellan","tint":"","transfer":true}]} -{"_id":"J7HJQkcvghtwMAdF","name":"Anzellan","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"

Biology and Appearance

\n

The anzellans are a diminutive species hailing from the secluded planet Anzella. Their eyes have floating corneal micro-lenses that allow them to see microscopic details. Anzellans are a bubbly and receptive people. They are a jovial and trusting people that tend to welcome strangers with open arms. Due to their volcanic homeworld, anzellans are also adapted towards heat. This, coupled with their small size, make them well-suited to working in compact places.

\n

Society and Culture

\n

Anzella is a tropical planet covered in thousands of small volcanic islands. Many of these islands are developed as small villages, with the largest islands designed to accommodate larger species. Anzellan culture is generally based around tourism and crafting; in fact, anzellans are renowned craftsmen due to their discerning eyesight and ability to fit into small spaces. Anzellan government is generally casual. Each village has its own governing council of rotating members; these villages act independently from one another unless their decisions would affect more than a single island. In that case, all of the councils work together to come to a planet-wide decision.

\n

Names

\n

Anzellan names are rarely longer than two syllables, with a bouncy intonation to them. Their surnames are familial.

\n

  Male Names. Babu, Gridel, Moru, Rano, Yodel

\n

  Female Names. Dibi, Fing, Nooni, Teena, Zazi

\n

  Surnames. E'ayoo, Frik, Meer, Tanni, Vrut

","chat":"","unidentified":""},"traits":{"value":"

Ability Score Increase. Your Intelligence score increases by 2, and two other ability scores of your choice increase by 1.

Age. Anzellans are considered adults at ten years old. They are a short-lived species, however, that rarely lives longer than 60 years.

Alignment. Anzellans are a friendly and respectful people, which causes them to tend toward lawful light side, though there are exceptions.

Size. Anzellans stand between 1 and 2 feet tall and weigh around 10 lbs. Regardless of your position in that range, your size is Tiny.

Speed. Your base walking speed is 20 feet.

Crafters. You have proficiency in one tool of your choice.

Detail Oriented. You are practiced at scouring for details. You have advantage on Intelligence (Investigation) checks within 5 feet.

Pintsized. Your tiny stature makes it hard for you to wield bigger weapons. You can't use medium or heavy shields. Additionally, you can't wield weapons with the two-handed or versatile property, and you can only wield one-handed weapons in two hands unless they have the light property.

Puny. Anzellans are too small to pack much of a punch. You have disadvantage on Strength saving throws, and when determining your bonus to attack and damage rolls for weapon attacks using Strength, you can't add more than +3.

Small and Nimble. You are too small and fast to effectively target. You have a +1 bonus to AC, and you have advantage on Dexterity saving throws.

Tanned. You are naturally adapted to hot climates, as described in chapter 5 of the Dungeon Master's Guide.

Technician. You are proficient in the Technology skill.

Tinker. You have proficiency with tinker's tools. You can use these and spend 1 hour and 100 cr worth of materials to construct a Tiny Device (AC 5, 1 hp). You can take the Use an Object action to have your device cause one of the following effects: create a small explosion, create a repeating loud noise for 1 minute, create smoke for 1 minute, create a soothing melody for 1 minute. You can maintain a number of these devices up to your proficiency bonus at once, and a device stops functioning after 24 hours away from you. You can dismantle the device to reclaim the materials used to create it.

Languages. You can speak, read, and write Galactic Basic and Anzellan. Anzellan is characterized by its bouncy sound and emphasis on alternating syllables.

","source":"Expanded Content"},"skinColorOptions":{"value":"Brown, green, or tan"},"hairColorOptions":{"value":"Black, gray, or white"},"eyeColorOptions":{"value":"Black"},"distinctions":{"value":"Diminutive size, wispy eyebrows"},"heightAverage":{"value":"1'0\""},"heightRollMod":{"value":"+2d6\""},"weightAverage":{"value":"3 lb."},"weightRollMod":{"value":"x1 lb."},"homeworld":{"value":"Anzella"},"slanguage":{"value":"Anzellan"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"effects":[{"modSpecKey":"data.details.species","value":"Anzellan","mode":"=","targetSpecific":false,"id":1,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.traits.size","value":"tin","mode":"=","targetSpecific":false,"id":3,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"20","mode":"=","targetSpecific":false,"id":4,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.attributes.ac.min","value":"1","mode":"+","targetSpecific":false,"id":5,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Armor Class Min"},{"modSpecKey":"data.skills.tec.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Skills Technology"},{"modSpecKey":"data.traits.toolProf.custom","value":"Tinker's tools","mode":"+","targetSpecific":false,"id":7,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Tool Prof Custom"},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":8,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Language"},{"modSpecKey":"data.traits.languages.value","value":"anzellan","mode":"+","targetSpecific":false,"id":9,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[]}],"alwaysActive":false},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Anzellan.webp","effects":[{"_id":"ZGdzAq1Gl4xaxDRD","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Anzellan","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.traits.size","value":"tiny","mode":5,"priority":5},{"key":"data.attributes.movement.walk","value":20,"mode":5,"priority":5},{"key":"data.attributes.ac.value","value":1,"mode":2,"priority":20},{"key":"data.skills.tec.value","value":1,"mode":4,"priority":20},{"key":"data.traits.toolProf.custom","value":"Tinker's tools","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"anzellan","mode":0,"priority":0},{"key":"flags.sw5e.detailOriented","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.pintsized","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.puny","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.tinker","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Anzellan.webp","label":"Anzellan","tint":"","transfer":true}]} diff --git a/packs/packs/weapons.db b/packs/packs/weapons.db index 39e87327..708c8be2 100644 --- a/packs/packs/weapons.db +++ b/packs/packs/weapons.db @@ -137,7 +137,6 @@ {"_id":"uQ2AXesizBRcTjRl","name":"Ion Carbine","permission":{"default":0,"vXYkFWX6qzvOu2jc":3,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"

Reload 16

","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":8,"price":300,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":60,"long":240,"units":"ft"},"uses":{"value":0,"max":0,"per":""},"consume":{"type":"ammo","target":"","amount":1},"ability":"","actionType":"rwak","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[["1d3 + @mod","ion"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"simpleB","properties":{"amm":true,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":false,"spc":false,"str":false,"thr":false,"two":true,"ver":false,"vic":false,"mgc":false,"nodam":false,"faulldam":false},"proficient":false},"folder":"7rtfHBtXhhiRSS8u","sort":2600001,"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false}},"img":"systems/sw5e/packs/Icons/Simple%20Blasters/Ion%20Carbine.webp","effects":[]} {"_id":"v55dQl0raOAucwgP","name":"Vibromace","permission":{"default":0,"vXYkFWX6qzvOu2jc":3,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":12,"price":80,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":5,"long":null,"units":"ft"},"uses":{"value":0,"max":0,"per":""},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"mwak","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[["1d8 + @mod","kinetic"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"simpleVW","properties":{"amm":false,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":true,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":false,"ret":false,"shk":false,"sil":false,"spc":false,"str":false,"thr":false,"two":true,"ver":false,"vic":false,"mgc":false,"nodam":false,"faulldam":false},"proficient":false},"folder":"7rtfHBtXhhiRSS8u","sort":6400001,"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false}},"img":"systems/sw5e/packs/Icons/Simple%20Vibroweapons/Vibromace.webp","effects":[]} {"_id":"w62Yd7ahdYyTH61q","name":"Shatter cannon","permission":{"default":0,"MmfWtlBdw3ij5wl9":3},"type":"weapon","data":{"description":{"value":"

Ammunition (range 80/320), Burst 4, Reload 8, Silent, Strength 15, Two-handed

","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":24,"price":1300,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"creature"},"range":{"value":80,"long":320,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"charges","target":"","amount":30},"ability":"","actionType":"rwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d10","kinetic"]],"versatile":""},"formula":"","save":{"ability":"dex","dc":null,"scaling":"dex"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialB","properties":{"amm":true,"aut":false,"bur":true,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":true,"spc":false,"str":true,"thr":false,"two":true,"ver":false,"vic":false,"nodam":false,"fulldam":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":6999220,"flags":{},"img":"systems/sw5e/packs/Icons/Martial%20Blasters/Shatter%20Cannon.webp","effects":[]} -{"_id":"woDLArHK5OZHsTeU","name":"Disguised Blade","permission":{"default":0,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"","chat":"","unidentified":""},"source":"EC","quantity":1,"weight":1,"price":200,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":5,"long":null,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"mwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d4 + @mod","kinetic"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialVW","properties":{"amm":false,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":true,"dgd":true,"dis":false,"dpt":false,"dou":false,"fin":true,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":true,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":false,"ret":false,"shk":false,"sil":false,"spc":false,"str":false,"thr":false,"two":false,"ver":false,"vic":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":7150001,"flags":{},"img":"systems/sw5e/packs/Icons/Disguised%20Blade,"effects":[]} {"_id":"xfIWfVXfe5ZfD8S2","name":"IWS (Blaster)","permission":{"default":0,"MmfWtlBdw3ij5wl9":3},"type":"weapon","data":{"description":{"value":"
\n

The IWS is a heavy weapon that can fire in three different modes. On your turn, you can use your object interaction to switch between modes, detailed below.

\n

Antiarmor. While in this mode, rather than traditional power cells, the IWS fires grenades. When firing a grenade at long range, creatures within the radius of the grenade’s explosion have advantage on the saving throw.

\n

Blaster. While in this mode, the weapon uses traditional power cells.

\n

Sniper. While in this mode, the weapon uses traditional power cells.

\n
\n
Antiarmor: Special, Ammunition (range 60/240), reload 1, special
\n
Blaster: 1d8 Energy, Ammunition (range 80/320), reload 12
\n
Sniper: 1d12 Energy, Ammunition (range 120/480), reload 4
\n
 
\n
Special, Strength 13, Two-handed
","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":12,"price":7200,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":"space"},"range":{"value":80,"long":320,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"charges","target":"","amount":20},"ability":"","actionType":"rwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d8 + @mod","energy"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialB","properties":{"amm":true,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":false,"spc":false,"str":true,"thr":false,"two":true,"ver":false,"vic":false,"nodam":false,"fulldam":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":6650001,"flags":{},"img":"systems/sw5e/packs/Icons/Martial%20Blasters/IWS.webp","effects":[]} {"_id":"y6faozksI3Bhwnpq","name":"Bowcaster","permission":{"default":0,"vXYkFWX6qzvOu2jc":3,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"

Burst 4, Reload 4, Strength 11

","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":16,"price":400,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":50,"long":200,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"charges","target":"","amount":1},"ability":"","actionType":"rwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d10 + @mod","energy"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"simpleB","properties":{"amm":true,"aut":false,"bur":true,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":false,"spc":false,"str":true,"thr":false,"two":true,"ver":false,"vic":false,"mgc":false,"nodam":false,"faulldam":false,"fulldam":false},"proficient":false},"folder":"7rtfHBtXhhiRSS8u","sort":800001,"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false}},"img":"systems/sw5e/packs/Icons/Simple%20Blasters/Bowcaster.webp","effects":[]} {"_id":"yVxRMON2OWIGeU4n","name":"Disruptorshiv","permission":{"default":0,"MmfWtlBdw3ij5wl9":3},"type":"weapon","data":{"description":{"value":"

Disruptive, Finesse, Shocking 13

","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":1,"price":900,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"creature"},"range":{"value":5,"long":null,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"mwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d4 + @mod","kinetic"]],"versatile":""},"formula":"1d4","save":{"ability":"dex","dc":13,"scaling":"flat"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialVW","properties":{"amm":false,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":true,"dou":false,"fin":true,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":false,"ret":false,"shk":true,"sil":false,"spc":false,"str":false,"thr":false,"two":false,"ver":false,"vic":false,"nodam":false,"fulldam":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":6750001,"flags":{},"img":"systems/sw5e/packs/Icons/Martial%20Vibroweapons/Disruptorshiv.webp","effects":[]}