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(); } }