foundry-sw5e/module/actor/old_entity.js
2021-07-06 19:57:18 -05:00

2126 lines
88 KiB
JavaScript

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<Item5e[]>} 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<Actor>} 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<Roll>} 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: `<p>${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}</p>`,
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<Roll>} 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<Roll>} 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<Roll|null>} 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<Roll|null>} 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<Roll|null>} 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<Roll|null>} 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<Roll|null>} 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: '<i class="fas fa-backward"></i>',
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();
}
}