diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..592d302e
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,14 @@
+{
+ "printWidth": 120,
+ "tabWidth": 4,
+ "useTabs": false,
+ "semi": true,
+ "singleQuote": false,
+ "quoteProps": "consistent",
+ "jsxSingleQuote": false,
+ "trailingComma": "none",
+ "bracketSpacing": false,
+ "jsxBracketSameLine": false,
+ "arrowParens": "always",
+ "endOfLine": "lf"
+}
diff --git a/lang/en.json b/lang/en.json
index 1decdafc..e3242844 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -19,10 +19,11 @@
"ITEM.TypeLoot": "Loot",
"ITEM.TypePower": "Power",
"ITEM.TypeSpecies": "Species",
- "ITEM.TypeStarshipfeature": "Starship Feature",
- "ITEM.TypeStarshipfeaturePl": "Starship Features",
- "ITEM.TypeStarshipmod": "Starship Modification",
- "ITEM.TypeStarshipmodPl": "Starship Modifications",
+ "ITEM.TypeStarship": "Starship",
+ "ITEM.TypeStarshipfeature": "Starship Feature",
+ "ITEM.TypeStarshipfeaturePl": "Starship Features",
+ "ITEM.TypeStarshipmod": "Starship Modification",
+ "ITEM.TypeStarshipmodPl": "Starship Modifications",
"ITEM.TypeTool": "Tool",
"ITEM.TypeVenture": "Venture",
"ITEM.TypeWeapon": "Weapon",
@@ -193,7 +194,7 @@
"SW5E.BonusSaveForm": "Update Bonuses",
"SW5E.BonusTechPowerDC": "Global Tech Power DC Bonus",
"SW5E.BonusTitle": "Configure Actor Bonuses",
- "SW5E.BurnFuel": "Burn",
+ "SW5E.BurnFuel": "Burn",
"SW5E.CapacityMultiplier": "Capacity Multiplier",
"SW5E.CentStorageCapacity": "Central Storage Capacity",
"SW5E.ChallengeRating": "Challenge Rating",
@@ -330,8 +331,18 @@
"SW5E.DeathSavingThrow": "Death Saving Throw",
"SW5E.Default": "Default",
"SW5E.DefaultAbilityCheck": "Default Ability Check",
- "SW5E.Deployment": "Deployment",
- "SW5E.DeploymentPl": "Deployments",
+ "SW5E.Deployment": "Deployment",
+ "SW5E.DeploymentAcceptSettings": "Deploy Actor",
+ "SW5E.DeploymentPl": "Deployments",
+ "SW5E.DeploymentPromptTitle": "Deploying Actor",
+ "SW5E.DeploymentTypeCoordinator": "Coordinator",
+ "SW5E.DeploymentTypeCrew": "Crew",
+ "SW5E.DeploymentTypeGunner": "Gunner",
+ "SW5E.DeploymentTypeMechanic": "Mechanic",
+ "SW5E.DeploymentTypeOperator": "Operator",
+ "SW5E.DeploymentTypePilot": "Pilot",
+ "SW5E.DeploymentTypePassenger" : "Passenger",
+ "SW5E.DeploymentTypeTechnician": "Technician",
"SW5E.description": "A comprehensive game system for running games of Star Wars 5th Edition in the Foundry VTT environment.",
"SW5E.Description": "Description",
"SW5E.DestructionSave": "Destruction Saves",
@@ -521,12 +532,13 @@
"SW5E.Flaws": "Flaws",
"SW5E.ForcePowerbook": "Force Powers",
"SW5E.Formula": "Formula",
- "SW5E.FuelCapacity": "Fuel Capacity",
- "SW5E.FuelCostPerUnit": "Fuel Cost per Unit",
+ "SW5E.FuelCapacity": "Fuel Capacity",
"SW5E.FuelCostsMod": "Fuel Costs Modifier",
+ "SW5E.FuelCostPerUnit": "Fuel Cost per Unit",
"SW5E.GrantedAbilities": "Granted Abilities",
"SW5E.HalfProficient": "Half Proficient",
"SW5E.HardpointSizeMod": "Hardpoint Size Modifier",
+ "SW5E.HardpointsPerRound": "Max Hardpoints Fired Per Round",
"SW5E.Healing": "Healing",
"SW5E.HealingTemp": "Healing (Temporary)",
"SW5E.Health": "Health",
@@ -542,9 +554,12 @@
"SW5E.HitDiceWarn": "{name} has no available {formula} Hit Dice remaining!",
"SW5E.HP": "Health",
"SW5E.HPFormula": "Health Formula",
- "SW5E.HullDice": "Hull Dice",
- "SW5E.HullPoints": "Hull Points",
- "SW5E.HullPointsFormula": "Hull Points Formula",
+ "SW5E.HullDice": "Hull Dice",
+ "SW5E.HullDiceRoll": "Roll Hull Dice",
+ "SW5E.HullDiceUsed": "Hull Dice Used",
+ "SW5E.HullDiceWarn": "{name} has no available {formula} Hull Dice remaining!",
+ "SW5E.HullPoints": "Hull Points",
+ "SW5E.HullPointsFormula": "Hull Points Formula",
"SW5E.HyperdriveClass": "Hyperdrive Class",
"SW5E.Ideals": "Ideals",
"SW5E.Identified": "Identified",
@@ -618,8 +633,9 @@
"SW5E.ItemTypePowerPl": "Powers",
"SW5E.ItemTypeSpecies": "Species",
"SW5E.ItemTypeSpeciesPl": "Species",
- "SW5E.ItemTypeStarshipMod": "Starship Modification",
- "SW5E.ItemTypeStarshipModPl": "Starship Modifications",
+ "SW5E.ItemTypeStarship": "Starship",
+ "SW5E.ItemTypeStarshipMod": "Starship Modification",
+ "SW5E.ItemTypeStarshipModPl": "Starship Modifications",
"SW5E.ItemTypeTool": "Tool",
"SW5E.ItemTypeToolPl": "Tools",
"SW5E.ItemTypeVenture": "Venture",
@@ -786,8 +802,8 @@
"SW5E.MovementCrawl": "Crawl",
"SW5E.MovementFly": "Fly",
"SW5E.MovementHover": "Hover",
- "SW5E.MovementRoll": "Roll",
- "SW5E.MovementSpace": "Space Flight",
+ "SW5E.MovementRoll": "Roll",
+ "SW5E.MovementSpace": "Space Flight",
"SW5E.MovementSwim": "Swim",
"SW5E.MovementTurn": "Turning",
"SW5E.MovementUnits": "Units",
@@ -847,10 +863,10 @@
"SW5E.PowerCreate": "Create Power",
"SW5E.PowerDC": "Power DC",
"SW5E.PowerDetails": "Power Details",
+ "SW5E.PowerDie": "Power Die",
+ "SW5E.PowerDiePl": "Power Dice",
+ "SW5E.PowerDieAlloc": "Power Die Allocation",
"SW5E.PowerDiceRecovery": "Power Dice Recovery",
- "SW5E.PowerDie": "Power Die",
- "SW5E.PowerDieAlloc": "Power Die Allocation",
- "SW5E.PowerDiePl": "Power Dice",
"SW5E.PowerEffects": "Power Effects",
"SW5E.PowerfulCritical": "Powerful Critical",
"SW5E.PowerLevel": "Power Level",
@@ -900,7 +916,23 @@
"SW5E.Reaction": "Reaction",
"SW5E.ReactionPl": "Reactions",
"SW5E.Recharge": "Recharge",
- "SW5E.Refitting": "Refitting",
+ "SW5E.RechargeRestHint": "Take a recharge rest? On a recharge rest you may spend remaining Hull Dice and recover Shields.",
+ "SW5E.RechargetRestNoHD": "No Hull Dice remaining",
+ "SW5E.RechargeRestNormal": "Recharge Rest (1 hour)",
+ "SW5E.RechargeRestOvernight": "Recharge Rest (New Day)",
+ "SW5E.RechargeRestResult": "{name} takes a recharge rest spending {dice} Hull Dice to recover {health} Hull Points.",
+ "SW5E.RechargeRestResultShort": "{name} takes a recharge rest.",
+ "SW5E.RechargeRestSelect": "Select Dice to Roll",
+ "SW5E.Refitting": "Refitting",
+ "SW5E.RefittingRest": "Refitting Rest",
+ "SW5E.RefittingRestEpic": "Refitting Rest (1 hour)",
+ "SW5E.RefittingRestGritty": "Refitting Rest (7 days)",
+ "SW5E.RefittingRestNormal": "Refitting Rest (8 hours)",
+ "SW5E.RefittingRestOvernight": "Refitting Rest (New Day)",
+ "SW5E.RefittingRestResult": "{name} takes a refitting rest.",
+ "SW5E.RefittingRestResultHD": "{name} takes a refitting rest and recovers {dice} Hull Dice.",
+ "SW5E.RefittingRestResultHP": "{name} takes a refitting rest and recovers {health} Hull Points.",
+ "SW5E.RefittingRestResultHPHD": "{name} takes a refitting rest and recovers {health} Hull Points and {dice} Hull Dice.",
"SW5E.Refuel": "Refuel",
"SW5E.RegenerationRateCoefficient": "Regeneration Rate Coefficient",
"SW5E.RequiredMaterials": "Required Materials",
@@ -948,10 +980,13 @@
"SW5E.SheetClassNPC": "Default NPC Sheet",
"SW5E.SheetClassNPCOld": "Old NPC Sheet",
"SW5E.SheetClassVehicle": "Default Vehicle Sheet",
- "SW5E.ShieldDice": "Shield Dice",
- "SW5E.ShieldPoints": "Shield Points",
- "SW5E.ShieldPointsFormula": "Shield Points Formula",
- "SW5E.ShieldRegen": "Regen",
+ "SW5E.ShieldDice": "Shield Dice",
+ "SW5E.ShieldDiceRoll": "Roll Shield Dice",
+ "SW5E.ShieldDiceUsed": "Shield Dice Used",
+ "SW5E.ShieldDiceWarn": "{name} has no available {formula} Shield Dice remaining!",
+ "SW5E.ShieldPoints": "Shield Points",
+ "SW5E.ShieldPointsFormula": "Shield Points Formula",
+ "SW5E.ShieldRegen": "Regen",
"SW5E.ShortRest": "Short Rest",
"SW5E.ShortRestEpic": "Short Rest (5 minutes)",
"SW5E.ShortRestGritty": "Short Rest (8 hours)",
@@ -1039,7 +1074,7 @@
"SW5E.StarshipSkillDat": "Data",
"SW5E.StarshipSkillHid": "Hide",
"SW5E.StarshipSkillImp": "Impress",
- "SW5E.StarshipSkillInt": "Interfere",
+ "SW5E.StarshipSkillInf": "Interfere",
"SW5E.StarshipSkillMan": "Maneuvering",
"SW5E.StarshipSkillMen": "Menace",
"SW5E.StarshipSkillPat": "Patch",
diff --git a/less/original/actors.less b/less/original/actors.less
index eef86c31..b716fa74 100644
--- a/less/original/actors.less
+++ b/less/original/actors.less
@@ -671,6 +671,77 @@
.editor {
padding: 0 8px;
}
+ .fuel {
+ flex: 0 0 12px;
+ background: #7a7971;
+ margin: 1px 15px 0 1px;
+ border: 1px solid #191813;
+ border-radius: 3px;
+ position: relative;
+ .fuel-bar {
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ background: #6c8aa5;
+ height: 8px;
+ border: 1px solid #cde4ff;
+ border-radius: 2px;
+ }
+ .fuel-label {
+ height: 10px;
+ padding: 0 5px;
+ position: absolute;
+ top: 0;
+ right: 0;
+ font-size: 13px;
+ line-height: 12px;
+ text-align: right;
+ color: #EEE;
+ text-shadow: 0 0 5px #000;
+ }
+ .fuel-breakpoint {
+ display: block;
+ position: absolute;
+ }
+ .fuel-breakpoint.fuel-20 {
+ left: 20%;
+ }
+ .fuel-breakpoint.fuel-40 {
+ left: 40%;
+ }
+ .fuel-breakpoint.fuel-60 {
+ left: 60%;
+ }
+ .fuel-breakpoint.fuel-80 {
+ left: 80%;
+ }
+ .arrow-up {
+ bottom: 0;
+ width: 0;
+ height: 0;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-bottom: 4px solid #000;
+ }
+ .arrow-down {
+ top: 0;
+ width: 0;
+ height: 0;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 4px solid #000;
+ }
+ }
+ .fuel.fueled {
+ .arrow-up {
+ border-bottom: 4px solid #fff;
+ border-bottom: 4px solid #000;
+ }
+ .arrow-down {
+ border-top: 4px solid #fff;
+ border-top: 4px solid #000;
+ }
+ }
}
#actor-flags {
diff --git a/less/update/components/actor-global.less b/less/update/components/actor-global.less
index f32ecc4e..49279ff8 100644
--- a/less/update/components/actor-global.less
+++ b/less/update/components/actor-global.less
@@ -250,7 +250,45 @@
}
}
}
-
+
+ .panel.resources {
+ .traits {
+ .fuel-wrapper {
+ display: grid;
+ grid-template-columns: 300px 100px;
+ width: 400px;
+ justify-self: end;
+ .fuel-label {
+ font-size: 12px;
+ line-height: 14px;
+ width: 100%;
+ text-shadow: none;
+ padding: 0;
+ margin: 0;
+ height: auto;
+ text-align: center;
+ margin-left: -2px;
+ border-radius: 0 4px 4px 0;
+ }
+ .fuel {
+ position: relative;
+ border-radius: 4px;
+ height: 16px;
+ margin: 0;
+ width: 100%;
+ .fuel-bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ border-radius: 4px;
+ border: none;
+ }
+ }
+ }
+ }
+ }
+
nav.sheet-navigation {
display: grid;
grid-template-columns: repeat(7, 1fr);
@@ -1123,4 +1161,44 @@
}
}
}
+ input[type=range][orient=vertical] {
+ -webkit-appearance: slider-vertical;
+ width: 10px;
+ height: 60px !important;
+ padding: 0 0 !important;
+ background-color: #c40f0f !important;
+ box-sizing: border-box;
+ &::-webkit-slider-runnable-track {
+ -webkit-appearance: slider-vertical !important;
+ height: 60px !important;
+ width: 10px !important;
+ line-height: 60px !important;
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+ margin-top: 0 0 !important;
+ border-radius: 3px !important;
+ background: linear-gradient( to top, #c40f0f 50%, #0dce0d 50% );
+ }
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none !important;
+ background-color: #c40f0f !important;
+ margin-right: -4px !important;
+ margin-top: 0px !important;
+ cursor: grab !important;
+ border-radius: 0 0 0 0 !important;
+ width: 10px !important;
+ height: 5px !important;
+ font-size: 10px;
+ }
+ }
+ output {
+ display: block;
+ margin: 5px auto;
+ font-size: 1.75em;
+ }
+ input {
+ .vertslider {
+ height: 60px;
+ }
+ }
}
diff --git a/less/update/sw5e-dark.less b/less/update/sw5e-dark.less
index 1dc380db..3d96ef34 100644
--- a/less/update/sw5e-dark.less
+++ b/less/update/sw5e-dark.less
@@ -34,6 +34,28 @@ body.dark-theme {
border: 1px solid @blockquoteBorder;
box-shadow: @blockquoteShadow;
}
+
+ .sw5e.sheet.actor {
+ .swalt-sheet {
+ .panel.resources {
+ .traits {
+ .fuel-wrapper {
+ .fuel-label {
+ background: #D6D6D6;
+ color: #1C1C1C;
+ border: 1px solid #1C1C1C;
+ }
+ .fuel {
+ background: #c40f0f;
+ .fuel-bar {
+ background: #0dce0d;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
hr {
border-width: 0 0 1px 0;
diff --git a/less/update/sw5e-light.less b/less/update/sw5e-light.less
index 0f898c80..9a9b5f9e 100644
--- a/less/update/sw5e-light.less
+++ b/less/update/sw5e-light.less
@@ -34,6 +34,28 @@ body.light-theme {
border: 1px solid @blockquoteBorder;
box-shadow: @blockquoteShadow;
}
+
+ .sw5e.sheet.actor {
+ .swalt-sheet {
+ .panel.resources {
+ .traits {
+ .fuel-wrapper {
+ .fuel-label {
+ background: #D6D6D6;
+ color: #1C1C1C;
+ border: 1px solid #1C1C1C;
+ }
+ .fuel {
+ background: #c40f0f;
+ .fuel-bar {
+ background: #0dce0d;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
hr {
border-width: 0 0 1px 0;
diff --git a/module/actor/entity.js b/module/actor/entity.js
index ad37ab56..83ab6142 100644
--- a/module/actor/entity.js
+++ b/module/actor/entity.js
@@ -1,8 +1,8 @@
-import { d20Roll, damageRoll } from "../dice.js";
+import {d20Roll, damageRoll} from "../dice.js";
import SelectItemsPrompt from "../apps/select-items-prompt.js";
import ShortRestDialog from "../apps/short-rest.js";
import LongRestDialog from "../apps/long-rest.js";
-import {SW5E} from '../config.js';
+import {SW5E} from "../config.js";
import Item5e from "../item/entity.js";
/**
@@ -10,1806 +10,2335 @@ import Item5e from "../item/entity.js";
* @extends {Actor}
*/
export default class Actor5e extends Actor {
+ /**
+ * The data source for Actor5e.classes allowing it to be lazily computed.
+ * @type {Object}
+ * @private
+ */
+ _classes = undefined;
- /**
- * The data source for Actor5e.classes allowing it to be lazily computed.
- * @type {Object}
- * @private
- */
- _classes = undefined;
+ /* -------------------------------------------- */
+ /* Properties */
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
- /* Properties */
- /* -------------------------------------------- */
-
- /**
- * A mapping of classes belonging to this Actor.
- * @type {Object}
- */
- get classes() {
- if ( this._classes !== undefined ) return this._classes;
- if ( this.data.type !== "character" ) return this._classes = {};
- return this._classes = this.items.filter((item) => item.type === "class").reduce((obj, cls) => {
- obj[cls.name.slugify({strict: true})] = cls;
- return obj;
- }, {});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Is this Actor currently polymorphed into some other creature?
- * @type {boolean}
- */
- get isPolymorphed() {
- return this.getFlag("sw5e", "isPolymorphed") || false;
- }
-
- /* -------------------------------------------- */
- /* Methods */
- /* -------------------------------------------- */
-
- /** @override */
- prepareData() {
- super.prepareData();
-
- // iterate over owned items and recompute attributes that depend on prepared actor data
- this.items.forEach(item => item.prepareFinalAttributes());
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- prepareBaseData() {
- switch ( this.data.type ) {
- case "character":
- return this._prepareCharacterData(this.data);
- case "npc":
- return this._prepareNPCData(this.data);
- case "starship":
- return this._prepareStarshipData(this.data);
- case "vehicle":
- return this._prepareVehicleData(this.data);
+ /**
+ * A mapping of classes belonging to this Actor.
+ * @type {Object}
+ */
+ get classes() {
+ if (this._classes !== undefined) return this._classes;
+ if (this.data.type !== "character") return (this._classes = {});
+ return (this._classes = this.items
+ .filter((item) => item.type === "class")
+ .reduce((obj, cls) => {
+ obj[cls.name.slugify({strict: true})] = cls;
+ return obj;
+ }, {}));
}
- }
- /* -------------------------------------------- */
+ /* -------------------------------------------- */
- /** @override */
- prepareDerivedData() {
- const actorData = this.data;
- const data = actorData.data;
- const flags = actorData.flags.sw5e || {};
- const bonuses = getProperty(data, "bonuses.abilities") || {};
+ /**
+ * Is this Actor currently polymorphed into some other creature?
+ * @type {boolean}
+ */
+ get isPolymorphed() {
+ return this.getFlag("sw5e", "isPolymorphed") || false;
+ }
- // Retrieve data for polymorphed actors
- let originalSaves = null;
- let originalSkills = null;
- if (this.isPolymorphed) {
- const transformOptions = this.getFlag('sw5e', 'transformOptions');
- const original = game.actors?.get(this.getFlag('sw5e', 'originalActor'));
- if (original) {
- if (transformOptions.mergeSaves) {
- originalSaves = original.data.data.abilities;
+ /* -------------------------------------------- */
+ /* Methods */
+ /* -------------------------------------------- */
+
+ /** @override */
+ prepareData() {
+ super.prepareData();
+
+ // iterate over owned items and recompute attributes that depend on prepared actor data
+ this.items.forEach((item) => item.prepareFinalAttributes());
+ }
+
+ /* -------------------------------------------- */
+
+ /** @override */
+ prepareBaseData() {
+ switch (this.data.type) {
+ case "character":
+ return this._prepareCharacterData(this.data);
+ case "npc":
+ return this._prepareNPCData(this.data);
+ case "starship":
+ return this._prepareStarshipData(this.data);
+ case "vehicle":
+ return this._prepareVehicleData(this.data);
}
- if (transformOptions.mergeSkills) {
- originalSkills = original.data.data.skills;
+ }
+
+ /* -------------------------------------------- */
+
+ /** @override */
+ prepareDerivedData() {
+ const actorData = this.data;
+ const data = actorData.data;
+ const flags = actorData.flags.sw5e || {};
+ const bonuses = getProperty(data, "bonuses.abilities") || {};
+
+ // Retrieve data for polymorphed actors
+ let originalSaves = null;
+ let originalSkills = null;
+ if (this.isPolymorphed) {
+ const transformOptions = this.getFlag("sw5e", "transformOptions");
+ const original = game.actors?.get(this.getFlag("sw5e", "originalActor"));
+ if (original) {
+ if (transformOptions.mergeSaves) {
+ originalSaves = original.data.data.abilities;
+ }
+ if (transformOptions.mergeSkills) {
+ originalSkills = original.data.data.skills;
+ }
+ }
}
- }
- }
- // Ability modifiers and saves
- const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0;
- const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0;
- const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0;
- for (let [id, abl] of Object.entries(data.abilities)) {
- abl.mod = Math.floor((abl.value - 10) / 2);
- abl.prof = (abl.proficient || 0) * data.attributes.prof;
- abl.saveBonus = saveBonus;
- abl.checkBonus = checkBonus;
- abl.save = abl.mod + abl.prof + abl.saveBonus;
- abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus;
-
- // If we merged saves when transforming, take the highest bonus here.
- if (originalSaves && abl.proficient) {
- abl.save = Math.max(abl.save, originalSaves[id].save);
- }
- }
-
- // Inventory encumbrance
- data.attributes.encumbrance = this._computeEncumbrance(actorData);
-
- // Prepare skills
- this._prepareSkills(actorData, bonuses, checkBonus, originalSkills);
-
- // Reset class store to ensure it is updated with any changes
- this._classes = undefined;
-
- // Determine Initiative Modifier
- const init = data.attributes.init;
- const athlete = flags.remarkableAthlete;
- const joat = flags.jackOfAllTrades;
- init.mod = data.abilities.dex.mod;
- if ( joat ) init.prof = Math.floor(0.5 * data.attributes.prof);
- else if ( athlete ) init.prof = Math.ceil(0.5 * data.attributes.prof);
- else init.prof = 0;
- init.value = init.value ?? 0;
- init.bonus = init.value + (flags.initiativeAlert ? 5 : 0);
- init.total = init.mod + init.prof + init.bonus;
-
- // Cache labels
- this.labels = {};
- if ( this.type === "npc" ) {
- this.labels["creatureType"] = this.constructor.formatCreatureType(data.details.type);
- }
-
- // Prepare power-casting data
- this._computeDerivedPowercasting(this.data);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return the amount of experience required to gain a certain character level.
- * @param level {Number} The desired level
- * @return {Number} The XP required
- */
- getLevelExp(level) {
- const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS;
- return levels[Math.min(level, levels.length - 1)];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return the amount of experience granted by killing a creature of a certain CR.
- * @param cr {Number} The creature's challenge rating
- * @return {Number} The amount of experience granted per kill
- */
- getCRExp(cr) {
- if (cr < 1.0) return Math.max(200 * cr, 10);
- return CONFIG.SW5E.CR_EXP_LEVELS[cr];
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- getRollData() {
- const data = super.getRollData();
- data.prof = this.data.data.attributes.prof || 0;
- data.classes = Object.entries(this.classes).reduce((obj, e) => {
- const [slug, cls] = e;
- obj[slug] = cls.data.data;
- return obj;
- }, {});
- return data;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Given a list of items to add to the Actor, optionally prompt the
- * user for which they would like to add.
- * @param {Array.} items - The items being added to the Actor.
- * @param {boolean} [prompt=true] - Whether or not to prompt the user.
- * @returns {Promise}
- */
- async addEmbeddedItems(items, prompt=true) {
- let itemsToAdd = items;
- if ( !items.length ) return [];
-
- // Obtain the array of item creation data
- let toCreate = [];
- if (prompt) {
- const itemIdsToAdd = await SelectItemsPrompt.create(items, {
- hint: game.i18n.localize('SW5E.AddEmbeddedItemPromptHint')
- });
- for (let item of items) {
- if (itemIdsToAdd.includes(item.id)) toCreate.push(item.toObject());
- }
- } else {
- toCreate = items.map(item => item.toObject());
- }
-
- // Create the requested items
- if (itemsToAdd.length === 0) return [];
- return Item5e.createDocuments(toCreate, {parent: this});
- }
-
- /* -------------------------------------------- */
-
- /**
- * Get a list of features to add to the Actor when a class item is updated.
- * Optionally prompt the user for which they would like to add.
- */
- async getClassFeatures({className, archetypeName, level}={}) {
- const existing = new Set(this.items.map(i => i.name));
- const features = await Actor5e.loadClassFeatures({className, archetypeName, level});
- return features.filter(f => !existing.has(f.name)) || [];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return the features which a character is awarded for each class level
- * @param {string} className The class name being added
- * @param {string} archetypeName The archetype of the class being added, if any
- * @param {number} level The number of levels in the added class
- * @param {number} priorLevel The previous level of the added class
- * @return {Promise} Array of Item5e entities
- */
- static async loadClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) {
- className = className.toLowerCase();
- archetypeName = archetypeName.slugify();
-
- // Get the configuration of features which may be added
- const clsConfig = CONFIG.SW5E.classFeatures[className];
- if (!clsConfig) return [];
-
- // Acquire class features
- let ids = [];
- for ( let [l, f] of Object.entries(clsConfig.features || {}) ) {
- l = parseInt(l);
- if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f);
- }
-
- // Acquire archetype features
- const archConfig = clsConfig.archetypes[archetypeName] || {};
- for ( let [l, f] of Object.entries(archConfig.features || {}) ) {
- l = parseInt(l);
- if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f);
- }
-
- // Load item data for all identified features
- const features = [];
- for ( let id of ids ) {
- features.push(await fromUuid(id));
- }
-
- // Class powers should always be prepared
- for ( const feature of features ) {
- if ( feature.type === "power" ) {
- const preparation = feature.data.data.preparation;
- preparation.mode = "always";
- preparation.prepared = true;
- }
- }
- return features;
- }
-
- /* -------------------------------------------- */
- /* Data Preparation Helpers */
- /* -------------------------------------------- */
-
- /**
- * Prepare Character type specific data
- */
- _prepareCharacterData(actorData) {
- const data = actorData.data;
-
- // Determine character level and available hit dice based on owned Class items
- const [level, hd] = this.items.reduce((arr, item) => {
- if ( item.type === "class" ) {
- const classLevels = parseInt(item.data.data.levels) || 1;
- arr[0] += classLevels;
- arr[1] += classLevels - (parseInt(item.data.data.hitDiceUsed) || 0);
- }
- return arr;
- }, [0, 0]);
- data.details.level = level;
- data.attributes.hd = hd;
-
- // Character proficiency bonus
- data.attributes.prof = Math.floor((level + 7) / 4);
-
- // Experience required for next level
- const xp = data.details.xp;
- xp.max = this.getLevelExp(level || 1);
- const prior = this.getLevelExp(level - 1 || 0);
- const required = xp.max - prior;
- const pct = Math.round((xp.value - prior) * 100 / required);
- xp.pct = Math.clamped(pct, 0, 100);
-
- // Add base Powercasting attributes
- this._computeBasePowercasting(actorData);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare NPC type specific data
- */
- _prepareNPCData(actorData) {
- const data = actorData.data;
-
- // Kill Experience
- data.details.xp.value = this.getCRExp(data.details.cr);
-
- // Proficiency
- data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4);
-
- this._computeBasePowercasting(actorData);
-
- // Powercaster Level
- if ( data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel) ) {
- data.details.powerLevel = Math.max(data.details.cr, 1);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare vehicle type-specific data
- * @param actorData
- * @private
- */
- _prepareVehicleData(actorData) {}
-
- /* -------------------------------------------- */
-
- /**
- * Prepare starship type-specific data
- * @param actorData
- * @private
- */
- _prepareStarshipData(actorData) {
- const data = actorData.data;
-
- // Proficiency
- data.attributes.prof = Math.floor((Math.max(data.details.tier, 1) + 7) / 4);
-
- // Link hull to hp and shields to temp hp
- data.attributes.hull.value = data.attributes.hp.value;
- data.attributes.hull.max = data.attributes.hp.max;
- data.attributes.shld.value = data.attributes.hp.temp;
- data.attributes.shld.max = data.attributes.hp.tempmax;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare skill checks.
- * @param actorData
- * @param bonuses Global bonus data.
- * @param checkBonus Ability check specific bonus.
- * @param originalSkills A transformed actor's original actor's skills.
- * @private
- */
- _prepareSkills(actorData, bonuses, checkBonus, originalSkills) {
- if (actorData.type === 'vehicle') return;
-
- const data = actorData.data;
- const flags = actorData.flags.sw5e || {};
-
- // Skill modifiers
- const feats = SW5E.characterFlags;
- const athlete = flags.remarkableAthlete;
- const joat = flags.jackOfAllTrades;
- const observant = flags.observantFeat;
- const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0;
- for (let [id, skl] of Object.entries(data.skills)) {
- skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0;
- let round = Math.floor;
-
- // Remarkable
- if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) {
- skl.value = 0.5;
- round = Math.ceil;
- }
-
- // Jack of All Trades
- if ( joat && (skl.value < 0.5) ) {
- skl.value = 0.5;
- }
-
- // Polymorph Skill Proficiencies
- if ( originalSkills ) {
- skl.value = Math.max(skl.value, originalSkills[id].value);
- }
-
- // Compute modifier
- skl.bonus = checkBonus + skillBonus;
- skl.mod = data.abilities[skl.ability].mod;
- skl.prof = round(skl.value * data.attributes.prof);
- skl.total = skl.mod + skl.prof + skl.bonus;
-
- // Compute passive bonus
- const passive = observant && (feats.observantFeat.skills.includes(id)) ? 5 : 0;
- skl.passive = 10 + skl.total + passive;
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare data related to the power-casting capabilities of the Actor
- * @private
- */
- _computeBasePowercasting (actorData) {
- if (actorData.type === 'vehicle' || actorData.type === 'starship') return;
- const ad = actorData.data;
- const powers = ad.powers;
- const isNPC = actorData.type === 'npc';
-
- // Translate the list of classes into force and tech power-casting progression
- const forceProgression = {
- classes: 0,
- levels: 0,
- multi: 0,
- maxClass: "none",
- maxClassPriority: 0,
- maxClassLevels: 0,
- maxClassPowerLevel: 0,
- powersKnown: 0,
- points: 0
- };
- const techProgression = {
- classes: 0,
- levels: 0,
- multi: 0,
- maxClass: "none",
- maxClassPriority: 0,
- maxClassLevels: 0,
- maxClassPowerLevel: 0,
- powersKnown: 0,
- points: 0
- };
-
- // Tabulate the total power-casting progression
- const classes = this.data.items.filter(i => i.type === "class");
- let priority = 0;
- for ( let cls of classes ) {
- const d = cls.data.data;
- if ( d.powercasting.progression === "none" ) continue;
- const levels = d.levels;
- const prog = d.powercasting.progression;
- // TODO: Consider a more dynamic system
- switch (prog) {
- case 'consular':
- priority = 3;
- forceProgression.levels += levels;
- forceProgression.multi += (SW5E.powerMaxLevel['consular'][19]/9)*levels;
- forceProgression.classes++;
- // see if class controls high level forcecasting
- if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){
- forceProgression.maxClass = 'consular';
- forceProgression.maxClassLevels = levels;
- forceProgression.maxClassPriority = priority;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['consular'][Math.clamped((levels - 1), 0, 20)];
- }
- // calculate points and powers known
- forceProgression.powersKnown += SW5E.powersKnown['consular'][Math.clamped((levels - 1), 0, 20)];
- forceProgression.points += SW5E.powerPoints['consular'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'engineer':
- priority = 2
- techProgression.levels += levels;
- techProgression.multi += (SW5E.powerMaxLevel['engineer'][19]/9)*levels;
- techProgression.classes++;
- // see if class controls high level techcasting
- if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){
- techProgression.maxClass = 'engineer';
- techProgression.maxClassLevels = levels;
- techProgression.maxClassPriority = priority;
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['engineer'][Math.clamped((levels - 1), 0, 20)];
- }
- techProgression.powersKnown += SW5E.powersKnown['engineer'][Math.clamped((levels - 1), 0, 20)];
- techProgression.points += SW5E.powerPoints['engineer'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'guardian':
- priority = 1;
- forceProgression.levels += levels;
- forceProgression.multi += (SW5E.powerMaxLevel['guardian'][19]/9)*levels;
- forceProgression.classes++;
- // see if class controls high level forcecasting
- if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){
- forceProgression.maxClass = 'guardian';
- forceProgression.maxClassLevels = levels;
- forceProgression.maxClassPriority = priority;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['guardian'][Math.clamped((levels - 1), 0, 20)];
- }
- forceProgression.powersKnown += SW5E.powersKnown['guardian'][Math.clamped((levels - 1), 0, 20)];
- forceProgression.points += SW5E.powerPoints['guardian'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'scout':
- priority = 1;
- techProgression.levels += levels;
- techProgression.multi += (SW5E.powerMaxLevel['scout'][19]/9)*levels;
- techProgression.classes++;
- // see if class controls high level techcasting
- if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){
- techProgression.maxClass = 'scout';
- techProgression.maxClassLevels = levels;
- techProgression.maxClassPriority = priority;
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['scout'][Math.clamped((levels - 1), 0, 20)];
- }
- techProgression.powersKnown += SW5E.powersKnown['scout'][Math.clamped((levels - 1), 0, 20)];
- techProgression.points += SW5E.powerPoints['scout'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'sentinel':
- priority = 2;
- forceProgression.levels += levels;
- forceProgression.multi += (SW5E.powerMaxLevel['sentinel'][19]/9)*levels;
- forceProgression.classes++;
- // see if class controls high level forcecasting
- if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){
- forceProgression.maxClass = 'sentinel';
- forceProgression.maxClassLevels = levels;
- forceProgression.maxClassPriority = priority;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['sentinel'][Math.clamped((levels - 1), 0, 20)];
- }
- forceProgression.powersKnown += SW5E.powersKnown['sentinel'][Math.clamped((levels - 1), 0, 20)];
- forceProgression.points += SW5E.powerPoints['sentinel'][Math.clamped((levels - 1), 0, 20)];
- break; }
- }
-
- if (isNPC) {
- // EXCEPTION: NPC with an explicit power-caster level
- if (ad.details.powerForceLevel) {
- forceProgression.levels = ad.details.powerForceLevel;
- ad.attributes.force.level = forceProgression.levels;
- forceProgression.maxClass = ad.attributes.powercasting;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped((forceProgression.levels - 1), 0, 20)];
- }
- if (ad.details.powerTechLevel) {
- techProgression.levels = ad.details.powerTechLevel;
- ad.attributes.tech.level = techProgression.levels;
- techProgression.maxClass = ad.attributes.powercasting;
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped((techProgression.levels - 1), 0, 20)];
- }
- } else {
- // EXCEPTION: multi-classed progression uses multi rounded down rather than levels
- if (forceProgression.classes > 1) {
- forceProgression.levels = Math.floor(forceProgression.multi);
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][forceProgression.levels - 1];
- }
- if (techProgression.classes > 1) {
- techProgression.levels = Math.floor(techProgression.multi);
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][techProgression.levels - 1];
- }
- }
-
-
- // Look up the number of slots per level from the powerLimit table
- let forcePowerLimit = Array.from(SW5E.powerLimit['none']);
- for (let i = 0; i < (forceProgression.maxClassPowerLevel); i++) {
- forcePowerLimit[i] = SW5E.powerLimit[forceProgression.maxClass][i];
- }
-
- for ( let [n, lvl] of Object.entries(powers) ) {
- let i = parseInt(n.slice(-1));
- if ( Number.isNaN(i) ) continue;
- if ( Number.isNumeric(lvl.foverride) ) lvl.fmax = Math.max(parseInt(lvl.foverride), 0);
- else lvl.fmax = forcePowerLimit[i-1] || 0;
- if (isNPC){
- lvl.fvalue = lvl.fmax;
- }else{
- lvl.fvalue = Math.min(parseInt(lvl.fvalue || lvl.value || lvl.fmax),lvl.fmax);
- }
- }
-
- let techPowerLimit = Array.from(SW5E.powerLimit['none']);
- for (let i = 0; i < (techProgression.maxClassPowerLevel); i++) {
- techPowerLimit[i] = SW5E.powerLimit[techProgression.maxClass][i];
- }
-
- for ( let [n, lvl] of Object.entries(powers) ) {
- let i = parseInt(n.slice(-1));
- if ( Number.isNaN(i) ) continue;
- if ( Number.isNumeric(lvl.toverride) ) lvl.tmax = Math.max(parseInt(lvl.toverride), 0);
- else lvl.tmax = techPowerLimit[i-1] || 0;
- if (isNPC){
- lvl.tvalue = lvl.tmax;
- }else{
- lvl.tvalue = Math.min(parseInt(lvl.tvalue || lvl.value || lvl.tmax),lvl.tmax);
- }
- }
-
- // Set Force and tech power for PC Actors
- if (!isNPC) {
- if (forceProgression.levels) {
- ad.attributes.force.known.max = forceProgression.powersKnown;
- ad.attributes.force.points.max = forceProgression.points;
- ad.attributes.force.level = forceProgression.levels;
- }
- if (techProgression.levels){
- ad.attributes.tech.known.max = techProgression.powersKnown;
- ad.attributes.tech.points.max = techProgression.points;
- ad.attributes.tech.level = techProgression.levels;
- }
- }
-
-
- // Tally Powers Known and check for migration first to avoid errors
- let hasKnownPowers = actorData?.data?.attributes?.force?.known?.value !== undefined;
- if ( hasKnownPowers ) {
- const knownPowers = this.data.items.filter(i => i.type === "power");
- let knownForcePowers = 0;
- let knownTechPowers = 0;
- for ( let knownPower of knownPowers ) {
- const d = knownPower.data;
- switch (d.data.school){
- case "lgt":
- case "uni":
- case "drk":{
- knownForcePowers++;
- break;
- }
- case "tec":{
- knownTechPowers++;
- break;
- }
+ // Ability modifiers and saves
+ const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0;
+ const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0;
+ const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0;
+ for (let [id, abl] of Object.entries(data.abilities)) {
+ abl.mod = Math.floor((abl.value - 10) / 2);
+ abl.prof = (abl.proficient || 0) * data.attributes.prof;
+ abl.saveBonus = saveBonus;
+ abl.checkBonus = checkBonus;
+ abl.save = abl.mod + abl.prof + abl.saveBonus;
+ abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus;
+
+ // If we merged saves when transforming, take the highest bonus here.
+ if (originalSaves && abl.proficient) {
+ abl.save = Math.max(abl.save, originalSaves[id].save);
+ }
}
- }
- ad.attributes.force.known.value = knownForcePowers;
- ad.attributes.tech.known.value = knownTechPowers;
- }
- }
- /* -------------------------------------------- */
+ // Inventory encumbrance
+ data.attributes.encumbrance = this._computeEncumbrance(actorData);
- /**
- * Prepare data related to the power-casting capabilities of the Actor
- * @private
- */
- _computeDerivedPowercasting (actorData) {
+ // Prepare Starship Data
+ if (actorData.type === "starship") this._computeStarshipData(actorData, data);
- if (!(actorData.type === 'character' || actorData.type === 'npc')) return;
+ // Prepare skills
+ this._prepareSkills(actorData, bonuses, checkBonus, originalSkills);
- const ad = actorData.data;
+ // Reset class store to ensure it is updated with any changes
+ this._classes = undefined;
- // Powercasting DC for Actors and NPCs
- // TODO: Consider an option for using the variant rule of all powers use the same value
- ad.attributes.powerForceLightDC = 8 + ad.abilities.wis.mod + ad.attributes.prof ?? 10;
- ad.attributes.powerForceDarkDC = 8 + ad.abilities.cha.mod + ad.attributes.prof ?? 10;
- ad.attributes.powerForceUnivDC = Math.max(ad.attributes.powerForceLightDC,ad.attributes.powerForceDarkDC) ?? 10;
- ad.attributes.powerTechDC = 8 + ad.abilities.int.mod + ad.attributes.prof ?? 10;
+ // 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;
- if (actorData.type !== 'character') return;
-
- // Set Force and tech bonus points for PC Actors
- if (!!ad.attributes.force.level){
- ad.attributes.force.points.max += Math.max(ad.abilities.wis.mod,ad.abilities.cha.mod);
- }
- if (!!ad.attributes.tech.level){
- ad.attributes.tech.points.max += ad.abilities.int.mod;
- }
-
- }
-
- /* -------------------------------------------- */
-
- /**
- * Compute the level and percentage of encumbrance for an Actor.
- *
- * Optionally include the weight of carried currency across all denominations by applying the standard rule
- * from the PHB pg. 143
- * @param {Object} actorData The data object for the Actor being rendered
- * @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level
- * @private
- */
- _computeEncumbrance(actorData) {
- // TODO: Maybe add an option for variant encumbrance
- // Get the total weight from items
- const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"];
- let weight = actorData.items.reduce((weight, i) => {
- if ( !physicalItems.includes(i.type) ) return weight;
- const q = i.data.data.quantity || 0;
- const w = i.data.data.weight || 0;
- return weight + (q * w);
- }, 0);
-
- // [Optional] add Currency Weight (for non-transformed actors)
- if ( game.settings.get("sw5e", "currencyWeight") && actorData.data.currency ) {
- const currency = actorData.data.currency;
- const numCoins = Object.values(currency).reduce((val, denom) => val += Math.max(denom, 0), 0);
- weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
- }
-
- // Determine the encumbrance size class
- let mod = {
- tiny: 0.5,
- sm: 1,
- med: 1,
- lg: 2,
- huge: 4,
- grg: 8
- }[actorData.data.traits.size] || 1;
- if ( this.getFlag("sw5e", "powerfulBuild") ) mod = Math.min(mod * 2, 8);
-
- // Compute Encumbrance percentage
- weight = weight.toNearest(0.1);
- const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod;
- const pct = Math.clamped((weight * 100) / max, 0, 100);
- return { value: weight.toNearest(0.1), max, pct, encumbered: pct > (2/3) };
- }
-
- /* -------------------------------------------- */
- /* Event Handlers */
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _preCreate(data, options, user) {
- await super._preCreate(data, options, user);
-
- // Token size category
- const s = CONFIG.SW5E.tokenSizes[this.data.data.traits.size || "med"];
- this.data.token.update({width: s, height: s});
-
- // Player character configuration
- if ( this.type === "character" ) {
- this.data.token.update({vision: true, actorLink: true, disposition: 1});
- }
- }
-
- /* -------------------------------------------- */
-
- /** @inheritdoc */
- async _preUpdate(changed, options, user) {
- await super._preUpdate(changed, options, user);
-
- // Apply changes in Actor size to Token width/height
- const newSize = foundry.utils.getProperty(changed, "data.traits.size");
- if ( newSize && (newSize !== foundry.utils.getProperty(this.data, "data.traits.size")) ) {
- let size = CONFIG.SW5E.tokenSizes[newSize];
- if ( !foundry.utils.hasProperty(changed, "token.width") ) {
- changed.token = changed.token || {};
- changed.token.height = size;
- changed.token.width = size;
- }
- }
-
- // Reset death save counters
- const isDead = this.data.data.attributes.hp.value <= 0;
- if ( isDead && (foundry.utils.getProperty(changed, "data.attributes.hp.value") > 0) ) {
- foundry.utils.setProperty(changed, "data.attributes.death.success", 0);
- foundry.utils.setProperty(changed, "data.attributes.death.failure", 0);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Assign a class item as the original class for the Actor based on which class has the most levels
- * @protected
- */
- _assignPrimaryClass() {
- const classes = this.itemTypes.class.sort((a, b) => b.data.data.levels - a.data.data.levels);
- const newPC = classes[0]?.id || "";
- return this.update({"data.details.originalClass": newPC});
- }
-
- /* -------------------------------------------- */
- /* Gameplay Mechanics */
- /* -------------------------------------------- */
-
- /** @override */
- async modifyTokenAttribute(attribute, value, isDelta, isBar) {
- if ( attribute === "attributes.hp" ) {
- const hp = getProperty(this.data.data, attribute);
- const delta = isDelta ? (-1 * value) : (hp.value + hp.temp) - value;
- return this.applyDamage(delta);
- }
- return super.modifyTokenAttribute(attribute, value, isDelta, isBar);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Apply a certain amount of damage or healing to the health pool for Actor
- * @param {number} amount An amount of damage (positive) or healing (negative) to sustain
- * @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing
- * @return {Promise} A Promise which resolves once the damage has been applied
- */
- async applyDamage(amount=0, multiplier=1) {
- amount = Math.floor(parseInt(amount) * multiplier);
- const hp = this.data.data.attributes.hp;
-
- // Deduct damage from temp HP first
- const tmp = parseInt(hp.temp) || 0;
- const dt = amount > 0 ? Math.min(tmp, amount) : 0;
-
- // Remaining goes to health
- const tmpMax = parseInt(hp.tempmax) || 0;
- const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax);
-
- // Update the Actor
- const updates = {
- "data.attributes.hp.temp": tmp - dt,
- "data.attributes.hp.value": dh
- };
-
- // Delegate damage application to a hook
- // TODO replace this in the future with a better modifyTokenAttribute function in the core
- const allowed = Hooks.call("modifyTokenAttribute", {
- attribute: "attributes.hp",
- value: amount,
- isDelta: false,
- isBar: true
- }, updates);
- return allowed !== false ? this.update(updates) : this;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a Skill Check
- * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
- * @param {string} skillId The skill id (e.g. "ins")
- * @param {Object} options Options which configure how the skill check is rolled
- * @return {Promise} A Promise which resolves to the created Roll instance
- */
- rollSkill(skillId, options={}) {
- const skl = this.data.data.skills[skillId];
- const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
-
- // Compose roll parts and data
- const parts = ["@mod"];
- const data = {mod: skl.mod + skl.prof};
-
- // Ability test bonus
- if ( bonuses.check ) {
- data["checkBonus"] = bonuses.check;
- parts.push("@checkBonus");
- }
-
- // Skill check bonus
- if ( bonuses.skill ) {
- data["skillBonus"] = bonuses.skill;
- parts.push("@skillBonus");
- }
-
- // Add provided extra roll parts now because they will get clobbered by mergeObject below
- if (options.parts?.length > 0) {
- parts.push(...options.parts);
- }
-
- // Reliable Talent applies to any skill check we have full or better proficiency in
- const reliableTalent = (skl.value >= 1 && this.getFlag("sw5e", "reliableTalent"));
-
- // Roll and return
- const rollData = foundry.utils.mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.format("SW5E.SkillPromptTitle", {skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId]}),
- halflingLucky: this.getFlag("sw5e", "halflingLucky"),
- reliableTalent: reliableTalent,
- messageData: {
- speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
- "flags.sw5e.roll": {type: "skill", skillId }
- }
- });
- return d20Roll(rollData);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a generic ability test or saving throw.
- * Prompt the user for input on which variety of roll they want to do.
- * @param {String}abilityId The ability id (e.g. "str")
- * @param {Object} options Options which configure how ability tests or saving throws are rolled
- */
- rollAbility(abilityId, options={}) {
- const label = CONFIG.SW5E.abilities[abilityId];
- new Dialog({
- title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}),
- content: `${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}
`,
- buttons: {
- test: {
- label: game.i18n.localize("SW5E.ActionAbil"),
- callback: () => this.rollAbilityTest(abilityId, options)
- },
- save: {
- label: game.i18n.localize("SW5E.ActionSave"),
- callback: () => this.rollAbilitySave(abilityId, options)
+ // Cache labels
+ this.labels = {};
+ if (this.type === "npc") {
+ this.labels["creatureType"] = this.constructor.formatCreatureType(data.details.type);
}
- }
- }).render(true);
- }
- /* -------------------------------------------- */
-
- /**
- * Roll an Ability Test
- * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
- * @param {String} abilityId The ability ID (e.g. "str")
- * @param {Object} options Options which configure how ability tests are rolled
- * @return {Promise} A Promise which resolves to the created Roll instance
- */
- rollAbilityTest(abilityId, options={}) {
- const label = CONFIG.SW5E.abilities[abilityId];
- const abl = this.data.data.abilities[abilityId];
-
- // Construct parts
- const parts = ["@mod"];
- const data = {mod: abl.mod};
-
- // Add feat-related proficiency bonuses
- const feats = this.data.flags.sw5e || {};
- if ( feats.remarkableAthlete && SW5E.characterFlags.remarkableAthlete.abilities.includes(abilityId) ) {
- parts.push("@proficiency");
- data.proficiency = Math.ceil(0.5 * this.data.data.attributes.prof);
- }
- else if ( feats.jackOfAllTrades ) {
- parts.push("@proficiency");
- data.proficiency = Math.floor(0.5 * this.data.data.attributes.prof);
+ // Prepare power-casting data
+ this._computeDerivedPowercasting(this.data);
}
- // Add global actor bonus
- const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
- if ( bonuses.check ) {
- parts.push("@checkBonus");
- data.checkBonus = bonuses.check;
+ /* -------------------------------------------- */
+
+ /**
+ * Return the amount of experience required to gain a certain character level.
+ * @param level {Number} The desired level
+ * @return {Number} The XP required
+ */
+ getLevelExp(level) {
+ const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS;
+ return levels[Math.min(level, levels.length - 1)];
}
- // Add provided extra roll parts now because they will get clobbered by mergeObject below
- if (options.parts?.length > 0) {
- parts.push(...options.parts);
+ /* -------------------------------------------- */
+
+ /**
+ * Return the amount of experience granted by killing a creature of a certain CR.
+ * @param cr {Number} The creature's challenge rating
+ * @return {Number} The amount of experience granted per kill
+ */
+ getCRExp(cr) {
+ if (cr < 1.0) return Math.max(200 * cr, 10);
+ return CONFIG.SW5E.CR_EXP_LEVELS[cr];
}
- // Roll and return
- const rollData = foundry.utils.mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}),
- halflingLucky: feats.halflingLucky,
- messageData: {
- speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
- "flags.sw5e.roll": {type: "ability", abilityId }
- }
- });
- return d20Roll(rollData);
- }
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
-
- /**
- * Roll an Ability Saving Throw
- * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
- * @param {String} abilityId The ability ID (e.g. "str")
- * @param {Object} options Options which configure how ability tests are rolled
- * @return {Promise} A Promise which resolves to the created Roll instance
- */
- rollAbilitySave(abilityId, options={}) {
- const label = CONFIG.SW5E.abilities[abilityId];
- const abl = this.data.data.abilities[abilityId];
-
- // Construct parts
- const parts = ["@mod"];
- const data = {mod: abl.mod};
-
- // Include proficiency bonus
- if ( abl.prof > 0 ) {
- parts.push("@prof");
- data.prof = abl.prof;
+ /** @inheritdoc */
+ getRollData() {
+ const data = super.getRollData();
+ data.prof = this.data.data.attributes.prof || 0;
+ data.classes = Object.entries(this.classes).reduce((obj, e) => {
+ const [slug, cls] = e;
+ obj[slug] = cls.data.data;
+ return obj;
+ }, {});
+ return data;
}
- // Include a global actor ability save bonus
- const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
- if ( bonuses.save ) {
- parts.push("@saveBonus");
- data.saveBonus = bonuses.save;
+ /* -------------------------------------------- */
+
+ /**
+ * Given a list of items to add to the Actor, optionally prompt the
+ * user for which they would like to add.
+ * @param {Array.} items - The items being added to the Actor.
+ * @param {boolean} [prompt=true] - Whether or not to prompt the user.
+ * @returns {Promise}
+ */
+ async addEmbeddedItems(items, prompt = true) {
+ let itemsToAdd = items;
+ if (!items.length) return [];
+
+ // Obtain the array of item creation data
+ let toCreate = [];
+ if (prompt) {
+ const itemIdsToAdd = await SelectItemsPrompt.create(items, {
+ hint: game.i18n.localize("SW5E.AddEmbeddedItemPromptHint")
+ });
+ for (let item of items) {
+ if (itemIdsToAdd.includes(item.id)) toCreate.push(item.toObject());
+ }
+ } else {
+ toCreate = items.map((item) => item.toObject());
+ }
+
+ // Create the requested items
+ if (itemsToAdd.length === 0) return [];
+ return Item5e.createDocuments(toCreate, {parent: this});
}
- // Add provided extra roll parts now because they will get clobbered by mergeObject below
- if (options.parts?.length > 0) {
- parts.push(...options.parts);
- }
+ /* -------------------------------------------- */
- // Roll and return
- const rollData = foundry.utils.mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}),
- halflingLucky: this.getFlag("sw5e", "halflingLucky"),
- messageData: {
- speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
- "flags.sw5e.roll": {type: "save", abilityId }
- }
- });
- return d20Roll(rollData);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Perform a death saving throw, rolling a d20 plus any global save bonuses
- * @param {Object} options Additional options which modify the roll
- * @return {Promise} A Promise which resolves to the Roll instance
- */
- async rollDeathSave(options={}) {
-
- // Display a warning if we are not at zero HP or if we already have reached 3
- const death = this.data.data.attributes.death;
- if ( (this.data.data.attributes.hp.value > 0) || (death.failure >= 3) || (death.success >= 3)) {
- ui.notifications.warn(game.i18n.localize("SW5E.DeathSaveUnnecessary"));
- return null;
- }
-
- // Evaluate a global saving throw bonus
- const parts = [];
- const data = {};
-
- // Include a global actor ability save bonus
- const bonuses = foundry.utils.getProperty(this.data.data, "bonuses.abilities") || {};
- if ( bonuses.save ) {
- parts.push("@saveBonus");
- data.saveBonus = bonuses.save;
- }
-
- // Evaluate the roll
- const rollData = foundry.utils.mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.localize("SW5E.DeathSavingThrow"),
- halflingLucky: this.getFlag("sw5e", "halflingLucky"),
- targetValue: 10,
- messageData: {
- speaker: options.speaker || ChatMessage.getSpeaker({actor: this}),
- "flags.sw5e.roll": {type: "death"}
- }
- });
- const roll = await d20Roll(rollData);
- if ( !roll ) return null;
-
- // Take action depending on the result
- const success = roll.total >= 10;
- const d20 = roll.dice[0].total;
-
- let chatString;
-
- // Save success
- if ( success ) {
- let successes = (death.success || 0) + 1;
-
- // Critical Success = revive with 1hp
- if ( d20 === 20 ) {
- await this.update({
- "data.attributes.death.success": 0,
- "data.attributes.death.failure": 0,
- "data.attributes.hp.value": 1
+ /**
+ * Get a list of features to add to the Actor when a class item is updated.
+ * Optionally prompt the user for which they would like to add.
+ */
+ async getClassFeatures({className, archetypeName, level} = {}) {
+ const existing = new Set(this.items.map((i) => i.name));
+ const features = await Actor5e.loadClassFeatures({
+ className,
+ archetypeName,
+ level
});
- chatString = "SW5E.DeathSaveCriticalSuccess";
- }
+ return features.filter((f) => !existing.has(f.name)) || [];
+ }
- // 3 Successes = survive and reset checks
- else if ( successes === 3 ) {
- await this.update({
- "data.attributes.death.success": 0,
- "data.attributes.death.failure": 0
+ /* -------------------------------------------- */
+
+ /**
+ * Return the features which a character is awarded for each class level
+ * @param {string} className The class name being added
+ * @param {string} archetypeName The archetype of the class being added, if any
+ * @param {number} level The number of levels in the added class
+ * @param {number} priorLevel The previous level of the added class
+ * @return {Promise} Array of Item5e entities
+ */
+ static async loadClassFeatures({className = "", archetypeName = "", level = 1, priorLevel = 0} = {}) {
+ className = className.toLowerCase();
+ archetypeName = archetypeName.slugify();
+
+ // Get the configuration of features which may be added
+ const clsConfig = CONFIG.SW5E.classFeatures[className];
+ if (!clsConfig) return [];
+
+ // Acquire class features
+ let ids = [];
+ for (let [l, f] of Object.entries(clsConfig.features || {})) {
+ l = parseInt(l);
+ if (l <= level && l > priorLevel) ids = ids.concat(f);
+ }
+
+ // Acquire archetype features
+ const archConfig = clsConfig.archetypes[archetypeName] || {};
+ for (let [l, f] of Object.entries(archConfig.features || {})) {
+ l = parseInt(l);
+ if (l <= level && l > priorLevel) ids = ids.concat(f);
+ }
+
+ // Load item data for all identified features
+ const features = [];
+ for (let id of ids) {
+ features.push(await fromUuid(id));
+ }
+
+ // Class powers should always be prepared
+ for (const feature of features) {
+ if (feature.type === "power") {
+ const preparation = feature.data.data.preparation;
+ preparation.mode = "always";
+ preparation.prepared = true;
+ }
+ }
+ return features;
+ }
+
+ /* -------------------------------------------- */
+ /* Data Preparation Helpers */
+ /* -------------------------------------------- */
+
+ /**
+ * Prepare Character type specific data
+ */
+ _prepareCharacterData(actorData) {
+ const data = actorData.data;
+
+ // Determine character level and available hit dice based on owned Class items
+ const [level, hd] = this.items.reduce(
+ (arr, item) => {
+ if (item.type === "class") {
+ const classLevels = parseInt(item.data.data.levels) || 1;
+ arr[0] += classLevels;
+ arr[1] += classLevels - (parseInt(item.data.data.hitDiceUsed) || 0);
+ }
+ return arr;
+ },
+ [0, 0]
+ );
+ data.details.level = level;
+ data.attributes.hd = hd;
+
+ // Character proficiency bonus
+ data.attributes.prof = Math.floor((level + 7) / 4);
+
+ // Experience required for next level
+ const xp = data.details.xp;
+ xp.max = this.getLevelExp(level || 1);
+ const prior = this.getLevelExp(level - 1 || 0);
+ const required = xp.max - prior;
+ const pct = Math.round(((xp.value - prior) * 100) / required);
+ xp.pct = Math.clamped(pct, 0, 100);
+
+ // Add base Powercasting attributes
+ this._computeBasePowercasting(actorData);
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Prepare NPC type specific data
+ */
+ _prepareNPCData(actorData) {
+ const data = actorData.data;
+
+ // Kill Experience
+ data.details.xp.value = this.getCRExp(data.details.cr);
+
+ // Proficiency
+ data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4);
+
+ this._computeBasePowercasting(actorData);
+
+ // Powercaster Level
+ if (data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel)) {
+ data.details.powerLevel = Math.max(data.details.cr, 1);
+ }
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Prepare vehicle type-specific data
+ * @param actorData
+ * @private
+ */
+ _prepareVehicleData(actorData) {}
+
+ /* -------------------------------------------- */
+
+ /**
+ * Prepare starship type-specific data
+ * @param actorData
+ * @private
+ */
+ _prepareStarshipData(actorData) {
+ const data = actorData.data;
+ data.attributes.prof = 0;
+ // Determine Starship size-based properties based on owned Starship item
+ const size = actorData.items.filter((i) => i.type === "starship");
+ if (size.length !== 0) {
+ const sizeData = size[0].data.data;
+ const tiers = parseInt(sizeData.tier) || 0;
+ data.traits.size = sizeData.size; // needs to be the short code
+ data.details.tier = tiers;
+ data.attributes.ac.value = 10 + Math.max(tiers - 1, 0);
+ data.attributes.hull.die = sizeData.hullDice;
+ data.attributes.hull.dicemax = sizeData.hullDiceStart + tiers;
+ data.attributes.hull.dice = sizeData.hullDiceStart + tiers - (parseInt(sizeData.hullDiceUsed) || 0);
+ data.attributes.shld.die = sizeData.shldDice;
+ data.attributes.shld.dicemax = sizeData.shldDiceStart + tiers;
+ data.attributes.shld.dice = sizeData.shldDiceStart + tiers - (parseInt(sizeData.shldDiceUsed) || 0);
+ sizeData.pwrDice = SW5E.powerDieTypes[tiers];
+ data.attributes.power.die = sizeData.pwrDice;
+ data.attributes.cost.baseBuild = sizeData.buildBaseCost;
+ data.attributes.workforce.minBuild = sizeData.buildMinWorkforce;
+ data.attributes.workforce.max = data.attributes.workforce.minBuild * 5;
+ data.attributes.cost.baseUpgrade = SW5E.baseUpgradeCost[tiers];
+ data.attributes.cost.multUpgrade = sizeData.upgrdCostMult;
+ data.attributes.workforce.minUpgrade = sizeData.upgrdMinWorkforce;
+ data.attributes.equip.size.crewMinWorkforce = parseInt(sizeData.crewMinWorkforce) || 1;
+ data.attributes.mods.capLimit = sizeData.modBaseCap;
+ data.attributes.mods.suites.cap = sizeData.modMaxSuiteCap;
+ data.attributes.cost.multModification = sizeData.modCostMult;
+ data.attributes.workforce.minModification = sizeData.modMinWorkforce;
+ data.attributes.cost.multEquip = sizeData.equipCostMult;
+ data.attributes.workforce.minEquip = sizeData.equipMinWorkforce;
+ data.attributes.equip.size.cargoCap = sizeData.cargoCap;
+ data.attributes.fuel.cost = sizeData.fuelCost;
+ data.attributes.fuel.cap = sizeData.fuelCap;
+ data.attributes.equip.size.foodCap = sizeData.foodCap;
+ }
+
+ // Determine Starship armor-based properties based on owned Starship item
+ const armor = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "ssarmor"); // && (i.data.equipped === true)));
+ if (armor.length !== 0) {
+ const armorData = armor[0].data;
+ data.attributes.equip.armor.dr = parseInt(armorData.dmgred.value) || 0;
+ data.attributes.equip.armor.maxDex = armorData.armor.dex;
+ data.attributes.equip.armor.stealthDisadv = armorData.stealth;
+ }
+
+ // Determine Starship hyperdrive-based properties based on owned Starship item
+ const hyperdrive = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "hyper"); // && (i.data.equipped === true)));
+ if (hyperdrive.length !== 0) {
+ const hdData = hyperdrive[0].data;
+ data.attributes.equip.hyperdrive.class = parseFloat(hdData.hdclass.value) || null;
+ }
+
+ // Determine Starship power coupling-based properties based on owned Starship item
+ const pwrcpl = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "powerc"); // && (i.data.equipped === true)));
+ if (pwrcpl.length !== 0) {
+ const pwrcplData = pwrcpl[0].data;
+ data.attributes.equip.powerCoupling.centralCap = parseInt(pwrcplData.cscap.value) || 0;
+ data.attributes.equip.powerCoupling.systemCap = parseInt(pwrcplData.sscap.value) || 0;
+ data.attributes.power.central.max = 0;
+ data.attributes.power.comms.max = 0;
+ data.attributes.power.engines.max = 0;
+ data.attributes.power.shields.max = 0;
+ data.attributes.power.sensors.max = 0;
+ data.attributes.power.weapons.max = 0;
+ }
+
+ // Determine Starship reactor-based properties based on owned Starship item
+ const reactor = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "reactor"); // && (i.data.equipped === true)));
+ if (reactor.length !== 0) {
+ const reactorData = reactor[0].data;
+ data.attributes.equip.reactor.fuelMult = parseFloat(reactorData.fuelcostsmod.value) || 0;
+ data.attributes.equip.reactor.powerRecDie = reactorData.powdicerec.value;
+ }
+
+ // Determine Starship shield-based properties based on owned Starship item
+ const shields = actorData.items.filter((i) => i.type === "equipment" && i.data.armor.type === "ssshield"); // && (i.data.equipped === true)));
+ if (shields.length !== 0) {
+ const shieldsData = shields[0].data;
+ data.attributes.equip.shields.capMult = parseFloat(shieldsData.capx.value) || 1;
+ data.attributes.equip.shields.regenRateMult = parseFloat(shieldsData.regrateco.value) || 1;
+ }
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Prepare skill checks.
+ * @param actorData
+ * @param bonuses Global bonus data.
+ * @param checkBonus Ability check specific bonus.
+ * @param originalSkills A transformed actor's original actor's skills.
+ * @private
+ */
+ _prepareSkills(actorData, bonuses, checkBonus, originalSkills) {
+ if (actorData.type === "vehicle") return;
+
+ const data = actorData.data;
+ const flags = actorData.flags.sw5e || {};
+
+ // Skill modifiers
+ const feats = SW5E.characterFlags;
+ const athlete = flags.remarkableAthlete;
+ const joat = flags.jackOfAllTrades;
+ const observant = flags.observantFeat;
+ const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0;
+ for (let [id, skl] of Object.entries(data.skills)) {
+ skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0;
+ let round = Math.floor;
+
+ // Remarkable
+ if (athlete && skl.value < 0.5 && feats.remarkableAthlete.abilities.includes(skl.ability)) {
+ skl.value = 0.5;
+ round = Math.ceil;
+ }
+
+ // Jack of All Trades
+ if (joat && skl.value < 0.5) {
+ skl.value = 0.5;
+ }
+
+ // Polymorph Skill Proficiencies
+ if (originalSkills) {
+ skl.value = Math.max(skl.value, originalSkills[id].value);
+ }
+
+ // Compute modifier
+ skl.bonus = checkBonus + skillBonus;
+ skl.mod = data.abilities[skl.ability].mod;
+ skl.prof = round(skl.value * data.attributes.prof);
+ skl.total = skl.mod + skl.prof + skl.bonus;
+
+ // Compute passive bonus
+ const passive = observant && feats.observantFeat.skills.includes(id) ? 5 : 0;
+ skl.passive = 10 + skl.total + passive;
+ }
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Prepare data related to the power-casting capabilities of the Actor
+ * @private
+ */
+ _computeBasePowercasting(actorData) {
+ if (actorData.type === "vehicle" || actorData.type === "starship") return;
+ const ad = actorData.data;
+ const powers = ad.powers;
+ const isNPC = actorData.type === "npc";
+
+ // Translate the list of classes into force and tech power-casting progression
+ const forceProgression = {
+ classes: 0,
+ levels: 0,
+ multi: 0,
+ maxClass: "none",
+ maxClassPriority: 0,
+ maxClassLevels: 0,
+ maxClassPowerLevel: 0,
+ powersKnown: 0,
+ points: 0
+ };
+ const techProgression = {
+ classes: 0,
+ levels: 0,
+ multi: 0,
+ maxClass: "none",
+ maxClassPriority: 0,
+ maxClassLevels: 0,
+ maxClassPowerLevel: 0,
+ powersKnown: 0,
+ points: 0
+ };
+
+ // Tabulate the total power-casting progression
+ const classes = this.data.items.filter((i) => i.type === "class");
+ let priority = 0;
+ for (let cls of classes) {
+ const d = cls.data.data;
+ if (d.powercasting.progression === "none") continue;
+ const levels = d.levels;
+ const prog = d.powercasting.progression;
+ // TODO: Consider a more dynamic system
+ switch (prog) {
+ case "consular":
+ priority = 3;
+ forceProgression.levels += levels;
+ forceProgression.multi += (SW5E.powerMaxLevel["consular"][19] / 9) * levels;
+ forceProgression.classes++;
+ // see if class controls high level forcecasting
+ if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) {
+ forceProgression.maxClass = "consular";
+ forceProgression.maxClassLevels = levels;
+ forceProgression.maxClassPriority = priority;
+ forceProgression.maxClassPowerLevel =
+ SW5E.powerMaxLevel["consular"][Math.clamped(levels - 1, 0, 20)];
+ }
+ // calculate points and powers known
+ forceProgression.powersKnown += SW5E.powersKnown["consular"][Math.clamped(levels - 1, 0, 20)];
+ forceProgression.points += SW5E.powerPoints["consular"][Math.clamped(levels - 1, 0, 20)];
+ break;
+ case "engineer":
+ priority = 2;
+ techProgression.levels += levels;
+ techProgression.multi += (SW5E.powerMaxLevel["engineer"][19] / 9) * levels;
+ techProgression.classes++;
+ // see if class controls high level techcasting
+ if (levels >= techProgression.maxClassLevels && priority > techProgression.maxClassPriority) {
+ techProgression.maxClass = "engineer";
+ techProgression.maxClassLevels = levels;
+ techProgression.maxClassPriority = priority;
+ techProgression.maxClassPowerLevel =
+ SW5E.powerMaxLevel["engineer"][Math.clamped(levels - 1, 0, 20)];
+ }
+ techProgression.powersKnown += SW5E.powersKnown["engineer"][Math.clamped(levels - 1, 0, 20)];
+ techProgression.points += SW5E.powerPoints["engineer"][Math.clamped(levels - 1, 0, 20)];
+ break;
+ case "guardian":
+ priority = 1;
+ forceProgression.levels += levels;
+ forceProgression.multi += (SW5E.powerMaxLevel["guardian"][19] / 9) * levels;
+ forceProgression.classes++;
+ // see if class controls high level forcecasting
+ if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) {
+ forceProgression.maxClass = "guardian";
+ forceProgression.maxClassLevels = levels;
+ forceProgression.maxClassPriority = priority;
+ forceProgression.maxClassPowerLevel =
+ SW5E.powerMaxLevel["guardian"][Math.clamped(levels - 1, 0, 20)];
+ }
+ forceProgression.powersKnown += SW5E.powersKnown["guardian"][Math.clamped(levels - 1, 0, 20)];
+ forceProgression.points += SW5E.powerPoints["guardian"][Math.clamped(levels - 1, 0, 20)];
+ break;
+ case "scout":
+ priority = 1;
+ techProgression.levels += levels;
+ techProgression.multi += (SW5E.powerMaxLevel["scout"][19] / 9) * levels;
+ techProgression.classes++;
+ // see if class controls high level techcasting
+ if (levels >= techProgression.maxClassLevels && priority > techProgression.maxClassPriority) {
+ techProgression.maxClass = "scout";
+ techProgression.maxClassLevels = levels;
+ techProgression.maxClassPriority = priority;
+ techProgression.maxClassPowerLevel =
+ SW5E.powerMaxLevel["scout"][Math.clamped(levels - 1, 0, 20)];
+ }
+ techProgression.powersKnown += SW5E.powersKnown["scout"][Math.clamped(levels - 1, 0, 20)];
+ techProgression.points += SW5E.powerPoints["scout"][Math.clamped(levels - 1, 0, 20)];
+ break;
+ case "sentinel":
+ priority = 2;
+ forceProgression.levels += levels;
+ forceProgression.multi += (SW5E.powerMaxLevel["sentinel"][19] / 9) * levels;
+ forceProgression.classes++;
+ // see if class controls high level forcecasting
+ if (levels >= forceProgression.maxClassLevels && priority > forceProgression.maxClassPriority) {
+ forceProgression.maxClass = "sentinel";
+ forceProgression.maxClassLevels = levels;
+ forceProgression.maxClassPriority = priority;
+ forceProgression.maxClassPowerLevel =
+ SW5E.powerMaxLevel["sentinel"][Math.clamped(levels - 1, 0, 20)];
+ }
+ forceProgression.powersKnown += SW5E.powersKnown["sentinel"][Math.clamped(levels - 1, 0, 20)];
+ forceProgression.points += SW5E.powerPoints["sentinel"][Math.clamped(levels - 1, 0, 20)];
+ break;
+ }
+ }
+
+ if (isNPC) {
+ // EXCEPTION: NPC with an explicit power-caster level
+ if (ad.details.powerForceLevel) {
+ forceProgression.levels = ad.details.powerForceLevel;
+ ad.attributes.force.level = forceProgression.levels;
+ forceProgression.maxClass = ad.attributes.powercasting;
+ forceProgression.maxClassPowerLevel =
+ SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped(forceProgression.levels - 1, 0, 20)];
+ }
+ if (ad.details.powerTechLevel) {
+ techProgression.levels = ad.details.powerTechLevel;
+ ad.attributes.tech.level = techProgression.levels;
+ techProgression.maxClass = ad.attributes.powercasting;
+ techProgression.maxClassPowerLevel =
+ SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped(techProgression.levels - 1, 0, 20)];
+ }
+ } else {
+ // EXCEPTION: multi-classed progression uses multi rounded down rather than levels
+ if (forceProgression.classes > 1) {
+ forceProgression.levels = Math.floor(forceProgression.multi);
+ forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel["multi"][forceProgression.levels - 1];
+ }
+ if (techProgression.classes > 1) {
+ techProgression.levels = Math.floor(techProgression.multi);
+ techProgression.maxClassPowerLevel = SW5E.powerMaxLevel["multi"][techProgression.levels - 1];
+ }
+ }
+
+ // Look up the number of slots per level from the powerLimit table
+ let forcePowerLimit = Array.from(SW5E.powerLimit["none"]);
+ for (let i = 0; i < forceProgression.maxClassPowerLevel; i++) {
+ forcePowerLimit[i] = SW5E.powerLimit[forceProgression.maxClass][i];
+ }
+
+ for (let [n, lvl] of Object.entries(powers)) {
+ let i = parseInt(n.slice(-1));
+ if (Number.isNaN(i)) continue;
+ if (Number.isNumeric(lvl.foverride)) lvl.fmax = Math.max(parseInt(lvl.foverride), 0);
+ else lvl.fmax = forcePowerLimit[i - 1] || 0;
+ if (isNPC) {
+ lvl.fvalue = lvl.fmax;
+ } else {
+ lvl.fvalue = Math.min(parseInt(lvl.fvalue || lvl.value || lvl.fmax), lvl.fmax);
+ }
+ }
+
+ let techPowerLimit = Array.from(SW5E.powerLimit["none"]);
+ for (let i = 0; i < techProgression.maxClassPowerLevel; i++) {
+ techPowerLimit[i] = SW5E.powerLimit[techProgression.maxClass][i];
+ }
+
+ for (let [n, lvl] of Object.entries(powers)) {
+ let i = parseInt(n.slice(-1));
+ if (Number.isNaN(i)) continue;
+ if (Number.isNumeric(lvl.toverride)) lvl.tmax = Math.max(parseInt(lvl.toverride), 0);
+ else lvl.tmax = techPowerLimit[i - 1] || 0;
+ if (isNPC) {
+ lvl.tvalue = lvl.tmax;
+ } else {
+ lvl.tvalue = Math.min(parseInt(lvl.tvalue || lvl.value || lvl.tmax), lvl.tmax);
+ }
+ }
+
+ // Set Force and tech power for PC Actors
+ if (!isNPC) {
+ if (forceProgression.levels) {
+ ad.attributes.force.known.max = forceProgression.powersKnown;
+ ad.attributes.force.points.max = forceProgression.points;
+ ad.attributes.force.level = forceProgression.levels;
+ }
+ if (techProgression.levels) {
+ ad.attributes.tech.known.max = techProgression.powersKnown;
+ ad.attributes.tech.points.max = techProgression.points;
+ ad.attributes.tech.level = techProgression.levels;
+ }
+ }
+
+ // Tally Powers Known and check for migration first to avoid errors
+ let hasKnownPowers = actorData?.data?.attributes?.force?.known?.value !== undefined;
+ if (hasKnownPowers) {
+ const knownPowers = this.data.items.filter((i) => i.type === "power");
+ let knownForcePowers = 0;
+ let knownTechPowers = 0;
+ for (let knownPower of knownPowers) {
+ const d = knownPower.data;
+ switch (d.data.school) {
+ case "lgt":
+ case "uni":
+ case "drk": {
+ knownForcePowers++;
+ break;
+ }
+ case "tec": {
+ knownTechPowers++;
+ break;
+ }
+ }
+ }
+ ad.attributes.force.known.value = knownForcePowers;
+ ad.attributes.tech.known.value = knownTechPowers;
+ }
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Prepare data related to the power-casting capabilities of the Actor
+ * @private
+ */
+ _computeDerivedPowercasting(actorData) {
+ if (!(actorData.type === "character" || actorData.type === "npc")) return;
+
+ const ad = actorData.data;
+
+ // Powercasting DC for Actors and NPCs
+ // TODO: Consider an option for using the variant rule of all powers use the same value
+ ad.attributes.powerForceLightDC = 8 + ad.abilities.wis.mod + ad.attributes.prof ?? 10;
+ ad.attributes.powerForceDarkDC = 8 + ad.abilities.cha.mod + ad.attributes.prof ?? 10;
+ ad.attributes.powerForceUnivDC =
+ Math.max(ad.attributes.powerForceLightDC, ad.attributes.powerForceDarkDC) ?? 10;
+ ad.attributes.powerTechDC = 8 + ad.abilities.int.mod + ad.attributes.prof ?? 10;
+
+ if (actorData.type !== "character") return;
+
+ // Set Force and tech bonus points for PC Actors
+ if (!!ad.attributes.force.level) {
+ ad.attributes.force.points.max += Math.max(ad.abilities.wis.mod, ad.abilities.cha.mod);
+ }
+ if (!!ad.attributes.tech.level) {
+ ad.attributes.tech.points.max += ad.abilities.int.mod;
+ }
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Compute the level and percentage of encumbrance for an Actor.
+ *
+ * Optionally include the weight of carried currency across all denominations by applying the standard rule
+ * from the PHB pg. 143
+ * @param {Object} actorData The data object for the Actor being rendered
+ * @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level
+ * @private
+ */
+ _computeEncumbrance(actorData) {
+ // TODO: Maybe add an option for variant encumbrance
+ // Get the total weight from items
+ const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"];
+ let weight = actorData.items.reduce((weight, i) => {
+ if (!physicalItems.includes(i.type)) return weight;
+ const q = i.data.data.quantity || 0;
+ const w = i.data.data.weight || 0;
+ return weight + q * w;
+ }, 0);
+
+ // [Optional] add Currency Weight (for non-transformed actors)
+ if (game.settings.get("sw5e", "currencyWeight") && actorData.data.currency) {
+ const currency = actorData.data.currency;
+ const numCoins = Object.values(currency).reduce((val, denom) => (val += Math.max(denom, 0)), 0);
+ weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
+ }
+
+ // Determine the encumbrance size class
+ let mod =
+ {
+ tiny: 0.5,
+ sm: 1,
+ med: 1,
+ lg: 2,
+ huge: 4,
+ grg: 8
+ }[actorData.data.traits.size] || 1;
+ if (this.getFlag("sw5e", "powerfulBuild")) mod = Math.min(mod * 2, 8);
+
+ // Compute Encumbrance percentage
+ weight = weight.toNearest(0.1);
+ const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod;
+ const pct = Math.clamped((weight * 100) / max, 0, 100);
+ return {value: weight.toNearest(0.1), max, pct, encumbered: pct > 2 / 3};
+ }
+
+ _computeStarshipData(actorData, data) {
+ // Calculate AC
+ data.attributes.ac.value += Math.min(data.abilities.dex.mod, data.attributes.equip.armor.maxDex);
+
+ // Set Power Die Storage
+ data.attributes.power.central.max += data.attributes.equip.powerCoupling.centralCap;
+ data.attributes.power.comms.max += data.attributes.equip.powerCoupling.systemCap;
+ data.attributes.power.engines.max += data.attributes.equip.powerCoupling.systemCap;
+ data.attributes.power.shields.max += data.attributes.equip.powerCoupling.systemCap;
+ data.attributes.power.sensors.max += data.attributes.equip.powerCoupling.systemCap;
+ data.attributes.power.weapons.max += data.attributes.equip.powerCoupling.systemCap;
+
+ // Find Size info of Starship
+ const size = actorData.items.filter((i) => i.type === "starship");
+ if (size.length === 0) return;
+ const sizeData = size[0].data.data;
+
+ // Prepare Hull Points
+ data.attributes.hp.max =
+ sizeData.hullDiceRolled.reduce((a, b) => a + b, 0) + data.abilities.con.mod * data.attributes.hull.dicemax;
+ if (data.attributes.hp.value === null) data.attributes.hp.value = data.attributes.hp.max;
+
+ // Prepare Shield Points
+ data.attributes.hp.tempmax =
+ (sizeData.shldDiceRolled.reduce((a, b) => a + b, 0) +
+ data.abilities.str.mod * data.attributes.shld.dicemax) *
+ data.attributes.equip.shields.capMult;
+ if (data.attributes.hp.temp === null) data.attributes.hp.temp = data.attributes.hp.tempmax;
+
+ // Prepare Speeds
+ data.attributes.movement.space =
+ sizeData.baseSpaceSpeed + 50 * (data.abilities.str.mod - data.abilities.con.mod);
+ data.attributes.movement.turn = Math.min(
+ data.attributes.movement.space,
+ Math.max(50, sizeData.baseTurnSpeed - 50 * (data.abilities.dex.mod - data.abilities.con.mod))
+ );
+
+ // Prepare Max Suites
+ data.attributes.mods.suites.max =
+ sizeData.modMaxSuitesBase + sizeData.modMaxSuitesMult * data.abilities.con.mod;
+
+ // Prepare Hardpoints
+ data.attributes.mods.hardpoints.max = sizeData.hardpointMult * Math.max(1, data.abilities.str.mod);
+
+ //Prepare Fuel
+ data.attributes.fuel = this._computeFuel(actorData);
+ }
+
+ /* -------------------------------------------- */
+ /* Event Handlers */
+ /* -------------------------------------------- */
+
+ _computeFuel(actorData) {
+ const fuel = actorData.data.attributes.fuel;
+ // Compute Fuel percentage
+ const pct = Math.clamped((fuel.value.toNearest(0.1) * 100) / fuel.cap, 0, 100);
+ return {...fuel, pct, fueled: pct > 0};
+ }
+
+ /** @inheritdoc */
+ async _preCreate(data, options, user) {
+ await super._preCreate(data, options, user);
+
+ // Token size category
+ const s = CONFIG.SW5E.tokenSizes[this.data.data.traits.size || "med"];
+ this.data.token.update({width: s, height: s});
+
+ // Player character configuration
+ if (this.type === "character") {
+ this.data.token.update({vision: true, actorLink: true, disposition: 1});
+ }
+ }
+
+ /* -------------------------------------------- */
+
+ /** @inheritdoc */
+ async _preUpdate(changed, options, user) {
+ await super._preUpdate(changed, options, user);
+
+ // Apply changes in Actor size to Token width/height
+ const newSize = foundry.utils.getProperty(changed, "data.traits.size");
+ if (newSize && newSize !== foundry.utils.getProperty(this.data, "data.traits.size")) {
+ let size = CONFIG.SW5E.tokenSizes[newSize];
+ if (!foundry.utils.hasProperty(changed, "token.width")) {
+ changed.token = changed.token || {};
+ changed.token.height = size;
+ changed.token.width = size;
+ }
+ }
+
+ // Reset death save counters
+ const isDead = this.data.data.attributes.hp.value <= 0;
+ if (isDead && foundry.utils.getProperty(changed, "data.attributes.hp.value") > 0) {
+ foundry.utils.setProperty(changed, "data.attributes.death.success", 0);
+ foundry.utils.setProperty(changed, "data.attributes.death.failure", 0);
+ }
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Assign a class item as the original class for the Actor based on which class has the most levels
+ * @protected
+ */
+ _assignPrimaryClass() {
+ const classes = this.itemTypes.class.sort((a, b) => b.data.data.levels - a.data.data.levels);
+ const newPC = classes[0]?.id || "";
+ return this.update({"data.details.originalClass": newPC});
+ }
+
+ /* -------------------------------------------- */
+ /* Gameplay Mechanics */
+ /* -------------------------------------------- */
+
+ /** @override */
+ async modifyTokenAttribute(attribute, value, isDelta, isBar) {
+ if (attribute === "attributes.hp") {
+ const hp = getProperty(this.data.data, attribute);
+ const delta = isDelta ? -1 * value : hp.value + hp.temp - value;
+ return this.applyDamage(delta);
+ }
+ return super.modifyTokenAttribute(attribute, value, isDelta, isBar);
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Apply a certain amount of damage or healing to the health pool for Actor
+ * @param {number} amount An amount of damage (positive) or healing (negative) to sustain
+ * @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing
+ * @return {Promise} A Promise which resolves once the damage has been applied
+ */
+ async applyDamage(amount = 0, multiplier = 1) {
+ amount = Math.floor(parseInt(amount) * multiplier);
+ const hp = this.data.data.attributes.hp;
+
+ // Deduct damage from temp HP first
+ const tmp = parseInt(hp.temp) || 0;
+ const dt = amount > 0 ? Math.min(tmp, amount) : 0;
+
+ // Remaining goes to health
+ const tmpMax = parseInt(hp.tempmax) || 0;
+ const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax);
+
+ // Update the Actor
+ const updates = {
+ "data.attributes.hp.temp": tmp - dt,
+ "data.attributes.hp.value": dh
+ };
+
+ // Delegate damage application to a hook
+ // TODO replace this in the future with a better modifyTokenAttribute function in the core
+ const allowed = Hooks.call(
+ "modifyTokenAttribute",
+ {
+ attribute: "attributes.hp",
+ value: amount,
+ isDelta: false,
+ isBar: true
+ },
+ updates
+ );
+ return allowed !== false ? this.update(updates) : this;
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Roll a Skill Check
+ * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
+ * @param {string} skillId The skill id (e.g. "ins")
+ * @param {Object} options Options which configure how the skill check is rolled
+ * @return {Promise} A Promise which resolves to the created Roll instance
+ */
+ rollSkill(skillId, options = {}) {
+ const skl = this.data.data.skills[skillId];
+ const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
+
+ // Compose roll parts and data
+ const parts = ["@mod"];
+ const data = {mod: skl.mod + skl.prof};
+
+ // Ability test bonus
+ if (bonuses.check) {
+ data["checkBonus"] = bonuses.check;
+ parts.push("@checkBonus");
+ }
+
+ // Skill check bonus
+ if (bonuses.skill) {
+ data["skillBonus"] = bonuses.skill;
+ parts.push("@skillBonus");
+ }
+
+ // Add provided extra roll parts now because they will get clobbered by mergeObject below
+ if (options.parts?.length > 0) {
+ parts.push(...options.parts);
+ }
+
+ // Reliable Talent applies to any skill check we have full or better proficiency in
+ const reliableTalent = skl.value >= 1 && this.getFlag("sw5e", "reliableTalent");
+
+ // Roll and return
+ const rollData = foundry.utils.mergeObject(options, {
+ parts: parts,
+ data: data,
+ title: game.i18n.format("SW5E.SkillPromptTitle", {
+ skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId]
+ }),
+ halflingLucky: this.getFlag("sw5e", "halflingLucky"),
+ reliableTalent: reliableTalent,
+ messageData: {
+ "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}),
+ "flags.sw5e.roll": {type: "skill", skillId}
+ }
});
- chatString = "SW5E.DeathSaveSuccess";
- }
-
- // Increment successes
- else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)});
+ return d20Roll(rollData);
}
- // Save failure
- else {
- let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1);
- await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)});
- if ( failures >= 3 ) { // 3 Failures = death
- chatString = "SW5E.DeathSaveFailure";
- }
+ /* -------------------------------------------- */
+
+ /**
+ * 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);
}
- // Display success/failure chat message
- if ( chatString ) {
- let chatData = { content: game.i18n.format(chatString, {name: this.name}), speaker };
- ChatMessage.applyRollMode(chatData, roll.options.rollMode);
- await ChatMessage.create(chatData);
- }
+ /* -------------------------------------------- */
- // Return the rolled result
- return roll;
- }
+ /**
+ * Roll 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};
- /**
- * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier
- * @param {string} [denomination] The hit denomination of hit die to roll. Example "d8".
- * If no denomination is provided, the first available HD will be used
- * @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll?
- * @return {Promise} The created Roll instance, or null if no hit die was rolled
- */
- async rollHitDie(denomination, {dialog=true}={}) {
-
- // If no denomination was provided, choose the first available
- let cls = null;
- if ( !denomination ) {
- cls = this.itemTypes.class.find(c => c.data.data.hitDiceUsed < c.data.data.levels);
- if ( !cls ) return null;
- denomination = cls.data.data.hitDice;
- }
-
- // Otherwise locate a class (if any) which has an available hit die of the requested denomination
- else {
- cls = this.items.find(i => {
- const d = i.data.data;
- return (d.hitDice === denomination) && ((d.hitDiceUsed || 0) < (d.levels || 1));
- });
- }
-
- // If no class is available, display an error notification
- if ( !cls ) {
- ui.notifications.error(game.i18n.format("SW5E.HitDiceWarn", {name: this.name, formula: denomination}));
- return null;
- }
-
- // Prepare roll data
- const parts = [`1${denomination}`, "@abilities.con.mod"];
- const title = game.i18n.localize("SW5E.HitDiceRoll");
- const rollData = foundry.utils.deepClone(this.data.data);
-
- // Call the roll helper utility
- const roll = await damageRoll({
- event: new Event("hitDie"),
- parts: parts,
- data: rollData,
- title: title,
- allowCritical: false,
- fastForward: !dialog,
- dialogOptions: {width: 350},
- messageData: {
- speaker: ChatMessage.getSpeaker({actor: this}),
- "flags.sw5e.roll": {type: "hitDie"}
- }
- });
- if ( !roll ) return null;
-
- // Adjust actor data
- await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1});
- const hp = this.data.data.attributes.hp;
- const dhp = Math.min(hp.max + (hp.tempmax ?? 0) - hp.value, roll.total);
- await this.update({"data.attributes.hp.value": hp.value + dhp});
- return roll;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Results from a rest operation.
- *
- * @typedef {object} RestResult
- * @property {number} dhp Hit points recovered during the rest.
- * @property {number} dhd Hit dice recovered or spent during the rest.
- * @property {object} updateData Updates applied to the actor.
- * @property {Array.} updateItems Updates applied to actor's items.
- * @property {boolean} newDay Whether a new day occurred during the rest.
- */
-
- /* -------------------------------------------- */
-
- /**
- * Take a short rest, possibly spending hit dice and recovering resources, item uses, and tech slots & points.
- *
- * @param {object} [options]
- * @param {boolean} [options.dialog=true] Present a dialog window which allows for rolling hit dice as part
- * of the Short Rest and selecting whether a new day has occurred.
- * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message.
- * @param {boolean} [options.autoHD=false] Automatically spend Hit Dice if you are missing 3 or more hit points.
- * @param {boolean} [options.autoHDThreshold=3] A number of missing hit points which would trigger an automatic HD roll.
- * @return {Promise.} A Promise which resolves once the short rest workflow has completed.
- */
- async shortRest({dialog=true, chat=true, autoHD=false, autoHDThreshold=3}={}) {
-
- // Take note of the initial hit points and number of hit dice the Actor has
- const hd0 = this.data.data.attributes.hd;
- const hp0 = this.data.data.attributes.hp.value;
- let newDay = false;
-
- // Display a Dialog for rolling hit dice
- if ( dialog ) {
- try {
- newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0});
- } catch(err) {
- return;
- }
- }
-
- // Automatically spend hit dice
- else if ( autoHD ) {
- await this.autoSpendHitDice({ threshold: autoHDThreshold });
- }
-
- return this._rest(chat, newDay, false, this.data.data.attributes.hd - hd0, this.data.data.attributes.hp.value - hp0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Take a long rest, recovering hit points, hit dice, resources, item uses, and tech & force power points & slots.
- *
- * @param {object} [options]
- * @param {boolean} [options.dialog=true] Present a confirmation dialog window whether or not to take a long rest.
- * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message.
- * @param {boolean} [options.newDay=true] Whether the long rest carries over to a new day.
- * @return {Promise.} A Promise which resolves once the long rest workflow has completed.
- */
- async longRest({dialog=true, chat=true, newDay=true}={}) {
- // Maybe present a confirmation dialog
- if ( dialog ) {
- try {
- newDay = await LongRestDialog.longRestDialog({actor: this});
- } catch(err) {
- return;
- }
- }
-
- return this._rest(chat, newDay, true, 0, 0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value, this.data.data.attributes.force.points.max - this.data.data.attributes.force.points.value);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Perform all of the changes needed for a short or long rest.
- *
- * @param {boolean} chat Summarize the results of the rest workflow as a chat message.
- * @param {boolean} newDay Has a new day occurred during this rest?
- * @param {boolean} longRest Is this a long rest?
- * @param {number} [dhd=0] Number of hit dice spent during so far during the rest.
- * @param {number} [dhp=0] Number of hit points recovered so far during the rest.
- * @param {number} [dtp=0] Number of tech points recovered so far during the rest.
- * @param {number} [dfp=0] Number of force points recovered so far during the rest.
- * @return {Promise.} Consolidated results of the rest workflow.
- * @private
- */
- async _rest(chat, newDay, longRest, dhd=0, dhp=0, dtp=0, dfp=0) {
- // TODO: Turn gritty realism into the SW5e longer rests variant rule https://sw5e.com/rules/variantRules/Longer%20Rests
- let hitPointsRecovered = 0;
- let hitPointUpdates = {};
- let hitDiceRecovered = 0;
- let hitDiceUpdates = [];
-
- // Recover hit points & hit dice on long rest
- if ( longRest ) {
- ({ updates: hitPointUpdates, hitPointsRecovered } = this._getRestHitPointRecovery());
- ({ updates: hitDiceUpdates, hitDiceRecovered } = this._getRestHitDiceRecovery());
- }
-
- // Figure out the rest of the changes
- const result = {
- dhd: dhd + hitDiceRecovered,
- dhp: dhp + hitPointsRecovered,
- dtp: dtp,
- dfp: dfp,
- updateData: {
- ...hitPointUpdates,
- ...this._getRestResourceRecovery({ recoverShortRestResources: !longRest, recoverLongRestResources: longRest }),
- ...this._getRestPowerRecovery({ recoverForcePowers: longRest })
- },
- updateItems: [
- ...hitDiceUpdates,
- ...this._getRestItemUsesRecovery({ recoverLongRestUses: longRest, recoverDailyUses: newDay })
- ],
- newDay: newDay
- }
-
- // Perform updates
- await this.update(result.updateData);
- await this.updateEmbeddedDocuments("Item", result.updateItems);
-
- // Display a Chat Message summarizing the rest effects
- if ( chat ) await this._displayRestResultMessage(result, longRest);
-
- // Return data summarizing the rest effects
- return result;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Display a chat message with the result of a rest.
- *
- * @param {RestResult} result Result of the rest operation.
- * @param {boolean} [longRest=false] Is this a long rest?
- * @return {Promise.} Chat message that was created.
- * @protected
- */
- async _displayRestResultMessage(result, longRest=false) {
- const { dhd, dhp, dtp, dfp, newDay } = result;
- const diceRestored = dhd !== 0;
- const healthRestored = dhp !== 0;
- const length = longRest ? "Long" : "Short";
-
- let restFlavor, message;
-
- // Summarize the rest duration
- switch (game.settings.get("sw5e", "restVariant")) {
- case 'normal': restFlavor = (longRest && newDay) ? "SW5E.LongRestOvernight" : `SW5E.${length}RestNormal`; break;
- case 'gritty': restFlavor = (!longRest && newDay) ? "SW5E.ShortRestOvernight" : `SW5E.${length}RestGritty`; break;
- case 'epic': restFlavor = `SW5E.${length}RestEpic`; break;
- }
-
- // Determine the chat message to display
- if (longRest) {
- message = "SW5E.LongRestResult";
- if (dhp !== 0) message += "HP";
- if (dfp !== 0) message += "FP";
- if (dtp !== 0) message += "TP";
- if (dhd !== 0) message += "HD";
- } else {
- message = "SW5E.ShortRestResultShort";
- if ((dhd !== 0) && (dhp !== 0)){
- if (dtp !== 0){
- message = "SW5E.ShortRestResultWithTech";
- }else{
- message = "SW5E.ShortRestResult";
+ // 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);
}
- }else{
- if (dtp !== 0){
- message = "SW5E.ShortRestResultOnlyTech";
+
+ // Add global actor bonus
+ const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
+ if (bonuses.check) {
+ parts.push("@checkBonus");
+ data.checkBonus = bonuses.check;
}
- }
+
+ // Add provided extra roll parts now because they will get clobbered by mergeObject below
+ if (options.parts?.length > 0) {
+ parts.push(...options.parts);
+ }
+
+ // Roll and return
+ const rollData = foundry.utils.mergeObject(options, {
+ parts: parts,
+ data: data,
+ title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}),
+ halflingLucky: feats.halflingLucky,
+ messageData: {
+ "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}),
+ "flags.sw5e.roll": {type: "ability", abilityId}
+ }
+ });
+ return d20Roll(rollData);
}
- // Create a chat message
- let chatData = {
- user: game.user.id,
- speaker: {actor: this, alias: this.name},
- flavor: game.i18n.localize(restFlavor),
- content: game.i18n.format(message, {
- name: this.name,
- dice: longRest ? dhd : -dhd,
- health: dhp,
- tech: dtp,
- force: dfp
- })
- };
- ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode"));
- return ChatMessage.create(chatData);
- }
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
+ /**
+ * 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];
- /**
- * Automatically spend hit dice to recover hit points up to a certain threshold.
- *
- * @param {object} [options]
- * @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll.
- * @return {Promise.} Number of hit dice spent.
- */
- async autoSpendHitDice({ threshold=3 }={}) {
- const max = this.data.data.attributes.hp.max + this.data.data.attributes.hp.tempmax;
+ // Construct parts
+ const parts = ["@mod"];
+ const data = {mod: abl.mod};
- let diceRolled = 0;
- while ( (this.data.data.attributes.hp.value + threshold) <= max ) {
- const r = await this.rollHitDie(undefined, {dialog: false});
- if ( r === null ) break;
- diceRolled += 1;
+ // Include proficiency bonus
+ if (abl.prof > 0) {
+ parts.push("@prof");
+ data.prof = abl.prof;
+ }
+
+ // Include a global actor ability save bonus
+ const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
+ if (bonuses.save) {
+ parts.push("@saveBonus");
+ data.saveBonus = bonuses.save;
+ }
+
+ // Add provided extra roll parts now because they will get clobbered by mergeObject below
+ if (options.parts?.length > 0) {
+ parts.push(...options.parts);
+ }
+
+ // Roll and return
+ const rollData = foundry.utils.mergeObject(options, {
+ parts: parts,
+ data: data,
+ title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}),
+ halflingLucky: this.getFlag("sw5e", "halflingLucky"),
+ messageData: {
+ "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}),
+ "flags.sw5e.roll": {type: "save", abilityId}
+ }
+ });
+ return d20Roll(rollData);
}
- return diceRolled;
- }
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
+ /**
+ * 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;
+ }
- /**
- * Recovers actor hit points and eliminates any temp HP.
- *
- * @param {object} [options]
- * @param {boolean} [options.recoverTemp=true] Reset temp HP to zero.
- * @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero.
- * @return {object} Updates to the actor and change in hit points.
- * @protected
- */
- _getRestHitPointRecovery({ recoverTemp=true, recoverTempMax=true }={}) {
- const data = this.data.data;
- let updates = {};
- let max = data.attributes.hp.max;
+ // Evaluate a global saving throw bonus
+ const parts = [];
+ const data = {};
- if ( recoverTempMax ) {
- updates["data.attributes.hp.tempmax"] = 0;
- } else {
- max += data.attributes.hp.tempmax;
- }
- updates["data.attributes.hp.value"] = max;
- if ( recoverTemp ) {
- updates["data.attributes.hp.temp"] = 0;
+ // Include a global actor ability save bonus
+ const bonuses = foundry.utils.getProperty(this.data.data, "bonuses.abilities") || {};
+ if (bonuses.save) {
+ parts.push("@saveBonus");
+ data.saveBonus = bonuses.save;
+ }
+
+ // Evaluate the roll
+ const rollData = foundry.utils.mergeObject(options, {
+ parts: parts,
+ data: data,
+ title: game.i18n.localize("SW5E.DeathSavingThrow"),
+ halflingLucky: this.getFlag("sw5e", "halflingLucky"),
+ targetValue: 10,
+ messageData: {
+ "speaker": options.speaker || ChatMessage.getSpeaker({actor: this}),
+ "flags.sw5e.roll": {type: "death"}
+ }
+ });
+ const roll = await d20Roll(rollData);
+ if (!roll) return null;
+
+ // Take action depending on the result
+ const success = roll.total >= 10;
+ const d20 = roll.dice[0].total;
+
+ let chatString;
+
+ // Save success
+ if (success) {
+ let successes = (death.success || 0) + 1;
+
+ // Critical Success = revive with 1hp
+ if (d20 === 20) {
+ await this.update({
+ "data.attributes.death.success": 0,
+ "data.attributes.death.failure": 0,
+ "data.attributes.hp.value": 1
+ });
+ chatString = "SW5E.DeathSaveCriticalSuccess";
+ }
+
+ // 3 Successes = survive and reset checks
+ else if (successes === 3) {
+ await this.update({
+ "data.attributes.death.success": 0,
+ "data.attributes.death.failure": 0
+ });
+ chatString = "SW5E.DeathSaveSuccess";
+ }
+
+ // Increment successes
+ else
+ await this.update({
+ "data.attributes.death.success": Math.clamped(successes, 0, 3)
+ });
+ }
+
+ // Save failure
+ else {
+ let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1);
+ await this.update({
+ "data.attributes.death.failure": Math.clamped(failures, 0, 3)
+ });
+ if (failures >= 3) {
+ // 3 Failures = death
+ chatString = "SW5E.DeathSaveFailure";
+ }
+ }
+
+ // Display success/failure chat message
+ if (chatString) {
+ let chatData = {
+ content: game.i18n.format(chatString, {name: this.name}),
+ speaker
+ };
+ ChatMessage.applyRollMode(chatData, roll.options.rollMode);
+ await ChatMessage.create(chatData);
+ }
+
+ // Return the rolled result
+ return roll;
}
- return { updates, hitPointsRecovered: max - data.attributes.hp.value };
- }
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
+ /**
+ * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier
+ * @param {string} [denomination] The hit denomination of hit die to roll. Example "d8".
+ * If no denomination is provided, the first available HD will be used
+ * @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll?
+ * @return {Promise} The created Roll instance, or null if no hit die was rolled
+ */
+ async rollHitDie(denomination, {dialog = true} = {}) {
+ // If no denomination was provided, choose the first available
+ let cls = null;
+ if (!denomination) {
+ cls = this.itemTypes.class.find((c) => c.data.data.hitDiceUsed < c.data.data.levels);
+ if (!cls) return null;
+ denomination = cls.data.data.hitDice;
+ }
- /**
- * Recovers actor resources.
- * @param {object} [options]
- * @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest.
- * @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest.
- * @return {object} Updates to the actor.
- * @protected
- */
- _getRestResourceRecovery({recoverShortRestResources=true, recoverLongRestResources=true}={}) {
- let updates = {};
- for ( let [k, r] of Object.entries(this.data.data.resources) ) {
- if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) {
- updates[`data.resources.${k}.value`] = Number(r.max);
- }
- }
- return updates;
- }
+ // 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;
+ }
- /**
- * Recovers power slots.
- *
- * @param longRest = true It's a long rest
- * @return {object} Updates to the actor.
- * @protected
- */
- _getRestPowerRecovery({ recoverTechPowers=true, recoverForcePowers=true }={}) {
- let updates = {};
+ // Prepare roll data
+ const parts = [`1${denomination}`, "@abilities.con.mod"];
+ const title = game.i18n.localize("SW5E.HitDiceRoll");
+ const rollData = foundry.utils.deepClone(this.data.data);
- if (recoverTechPowers) {
- updates["data.attributes.tech.points.value"] = this.data.data.attributes.tech.points.max;
- updates["data.attributes.tech.points.temp"] = 0;
- updates["data.attributes.tech.points.tempmax"] = 0;
+ // Call the roll helper utility
+ const roll = await damageRoll({
+ event: new Event("hitDie"),
+ parts: parts,
+ data: rollData,
+ title: title,
+ allowCritical: false,
+ fastForward: !dialog,
+ dialogOptions: {width: 350},
+ messageData: {
+ "speaker": ChatMessage.getSpeaker({actor: this}),
+ "flags.sw5e.roll": {type: "hitDie"}
+ }
+ });
+ if (!roll) return null;
- for (let [k, v] of Object.entries(this.data.data.powers)) {
- updates[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0);
- }
+ // 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;
}
- if (recoverForcePowers) {
- updates["data.attributes.force.points.value"] = this.data.data.attributes.force.points.max;
- updates["data.attributes.force.points.temp"] = 0;
- updates["data.attributes.force.points.tempmax"] = 0;
+ /* -------------------------------------------- */
- for ( let [k, v] of Object.entries(this.data.data.powers) ) {
- updates[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0);
- }
+ /**
+ * 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;
}
- return updates;
- }
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
+ /**
+ * 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;
+ }
- /**
- * Recovers class hit dice during a long rest.
- *
- * @param {object} [options]
- * @param {number} [options.maxHitDice] Maximum number of hit dice to recover.
- * @return {object} Array of item updates and number of hit dice recovered.
- * @protected
- */
- _getRestHitDiceRecovery({ maxHitDice=undefined }={}) {
- // Determine the number of hit dice which may be recovered
- if ( maxHitDice === undefined ) {
- maxHitDice = Math.max(Math.floor(this.data.data.details.level / 2), 1);
+ // 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;
}
- // Sort classes which can recover HD, assuming players prefer recovering larger HD first.
- const sortedClasses = Object.values(this.classes).sort((a, b) => {
- return (parseInt(b.data.data.hitDice.slice(1)) || 0) - (parseInt(a.data.data.hitDice.slice(1)) || 0);
- });
+ /* -------------------------------------------- */
- let updates = [];
- let hitDiceRecovered = 0;
- for ( let item of sortedClasses ) {
- const d = item.data.data;
- if ( (hitDiceRecovered < maxHitDice) && (d.hitDiceUsed > 0) ) {
- let delta = Math.min(d.hitDiceUsed || 0, maxHitDice - hitDiceRecovered);
- hitDiceRecovered += delta;
- updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta});
- }
+ /**
+ * 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;
}
- return { updates, hitDiceRecovered };
- }
+ /**
+ * Results from a rest operation.
+ *
+ * @typedef {object} RestResult
+ * @property {number} dhp Hit points recovered during the rest.
+ * @property {number} dhd Hit dice recovered or spent during the rest.
+ * @property {object} updateData Updates applied to the actor.
+ * @property {Array.} updateItems Updates applied to actor's items.
+ * @property {boolean} newDay Whether a new day occurred during the rest.
+ */
- /* -------------------------------------------- */
+ /* -------------------------------------------- */
- /**
- * Recovers item uses during short or long rests.
- *
- * @param {object} [options]
- * @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest.
- * @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest.
- * @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day.
- * @return {Array.} Array of item updates.
- * @protected
- */
- _getRestItemUsesRecovery({ recoverShortRestUses=true, recoverLongRestUses=true, recoverDailyUses=true }={}) {
- let recovery = [];
- if ( recoverShortRestUses ) recovery.push("sr");
- if ( recoverLongRestUses ) recovery.push("lr");
- if ( recoverDailyUses ) recovery.push("day");
+ /**
+ * Take a short rest, possibly spending hit dice and recovering resources, item uses, and tech slots & points.
+ *
+ * @param {object} [options]
+ * @param {boolean} [options.dialog=true] Present a dialog window which allows for rolling hit dice as part
+ * of the Short Rest and selecting whether a new day has occurred.
+ * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message.
+ * @param {boolean} [options.autoHD=false] Automatically spend Hit Dice if you are missing 3 or more hit points.
+ * @param {boolean} [options.autoHDThreshold=3] A number of missing hit points which would trigger an automatic HD roll.
+ * @return {Promise.} A Promise which resolves once the short rest workflow has completed.
+ */
+ async shortRest({dialog = true, chat = true, autoHD = false, autoHDThreshold = 3} = {}) {
+ // Take note of the initial hit points and number of hit dice the Actor has
+ const hd0 = this.data.data.attributes.hd;
+ const hp0 = this.data.data.attributes.hp.value;
+ let newDay = false;
- let updates = [];
- for ( let item of this.items ) {
- const d = item.data.data;
- if ( d.uses && recovery.includes(d.uses.per) ) {
- updates.push({_id: item.id, "data.uses.value": d.uses.max});
- }
- if ( recoverLongRestUses && d.recharge && d.recharge.value ) {
- updates.push({_id: item.id, "data.recharge.charged": true});
- }
+ // Display a Dialog for rolling hit dice
+ if (dialog) {
+ try {
+ newDay = await ShortRestDialog.shortRestDialog({
+ actor: this,
+ canRoll: hd0 > 0
+ });
+ } catch (err) {
+ return;
+ }
+ }
+
+ // Automatically spend hit dice
+ else if (autoHD) {
+ await this.autoSpendHitDice({threshold: autoHDThreshold});
+ }
+
+ return this._rest(
+ chat,
+ newDay,
+ false,
+ this.data.data.attributes.hd - hd0,
+ this.data.data.attributes.hp.value - hp0,
+ this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value
+ );
}
- return updates;
- }
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
+ /**
+ * Take a long rest, recovering hit points, hit dice, resources, item uses, and tech & force power points & slots.
+ *
+ * @param {object} [options]
+ * @param {boolean} [options.dialog=true] Present a confirmation dialog window whether or not to take a long rest.
+ * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message.
+ * @param {boolean} [options.newDay=true] Whether the long rest carries over to a new day.
+ * @return {Promise.} A Promise which resolves once the long rest workflow has completed.
+ */
+ async longRest({dialog = true, chat = true, newDay = true} = {}) {
+ // Maybe present a confirmation dialog
+ if (dialog) {
+ try {
+ newDay = await LongRestDialog.longRestDialog({actor: this});
+ } catch (err) {
+ return;
+ }
+ }
- /**
- * 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"));
+ return this._rest(
+ chat,
+ newDay,
+ true,
+ 0,
+ 0,
+ this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value,
+ this.data.data.attributes.force.points.max - this.data.data.attributes.force.points.value
+ );
}
- // Get the original Actor data and the new source data
- const o = this.toJSON();
- o.flags.sw5e = o.flags.sw5e || {};
- o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves};
- const source = target.toJSON();
+ /* -------------------------------------------- */
- // Prepare new data to merge from the source
- const d = {
- type: o.type, // Remain the same actor type
- name: `${o.name} (${source.name})`, // Append the new shape to your old name
- data: source.data, // Get the data model of your new form
- items: source.items, // Get the items of your new form
- effects: o.effects.concat(source.effects), // Combine active effects from both forms
- img: source.img, // New appearance
- permission: o.permission, // Use the original actor permissions
- folder: o.folder, // Be displayed in the same sidebar folder
- flags: o.flags // Use the original actor flags
- };
+ /**
+ * Perform all of the changes needed for a short or long rest.
+ *
+ * @param {boolean} chat Summarize the results of the rest workflow as a chat message.
+ * @param {boolean} newDay Has a new day occurred during this rest?
+ * @param {boolean} longRest Is this a long rest?
+ * @param {number} [dhd=0] Number of hit dice spent during so far during the rest.
+ * @param {number} [dhp=0] Number of hit points recovered so far during the rest.
+ * @param {number} [dtp=0] Number of tech points recovered so far during the rest.
+ * @param {number} [dfp=0] Number of force points recovered so far during the rest.
+ * @return {Promise.} Consolidated results of the rest workflow.
+ * @private
+ */
+ async _rest(chat, newDay, longRest, dhd = 0, dhp = 0, dtp = 0, dfp = 0) {
+ // TODO: Turn gritty realism into the SW5e longer rests variant rule https://sw5e.com/rules/variantRules/Longer%20Rests
+ let hitPointsRecovered = 0;
+ let hitPointUpdates = {};
+ let hitDiceRecovered = 0;
+ let hitDiceUpdates = [];
- // Specifically delete some data attributes
- delete d.data.resources; // Don't change your resource pools
- delete d.data.currency; // Don't lose currency
- delete d.data.bonuses; // Don't lose global bonuses
+ // Recover hit points & hit dice on long rest
+ if (longRest) {
+ ({updates: hitPointUpdates, hitPointsRecovered} = this._getRestHitPointRecovery());
+ ({updates: hitDiceUpdates, hitDiceRecovered} = this._getRestHitDiceRecovery());
+ }
- // Specific additional adjustments
- d.data.details.alignment = o.data.details.alignment; // Don't change alignment
- d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level
- d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration
- d.data.powers = o.data.powers; // Keep power slots
+ // Figure out the rest of the changes
+ const result = {
+ dhd: dhd + hitDiceRecovered,
+ dhp: dhp + hitPointsRecovered,
+ dtp: dtp,
+ dfp: dfp,
+ updateData: {
+ ...hitPointUpdates,
+ ...this._getRestResourceRecovery({
+ recoverShortRestResources: !longRest,
+ recoverLongRestResources: longRest
+ }),
+ ...this._getRestPowerRecovery({recoverForcePowers: longRest})
+ },
+ updateItems: [
+ ...hitDiceUpdates,
+ ...this._getRestItemUsesRecovery({
+ recoverLongRestUses: longRest,
+ recoverDailyUses: newDay
+ })
+ ],
+ newDay: newDay
+ };
- // Token appearance updates
- d.token = {name: d.name};
- for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) {
- d.token[k] = source.token[k];
- }
- if ( !keepVision ) {
- for ( let k of ['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle'] ) {
- d.token[k] = source.token[k];
- }
- }
- if ( source.token.randomImg ) {
- const images = await target.getTokenImages();
- d.token.img = images[Math.floor(Math.random() * images.length)];
+ // Perform updates
+ await this.update(result.updateData);
+ await this.updateEmbeddedDocuments("Item", result.updateItems);
+
+ // Display a Chat Message summarizing the rest effects
+ if (chat) await this._displayRestResultMessage(result, longRest);
+
+ // Return data summarizing the rest effects
+ return result;
}
- // 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);
+ /* -------------------------------------------- */
+
+ /**
+ * Display a chat message with the result of a rest.
+ *
+ * @param {RestResult} result Result of the rest operation.
+ * @param {boolean} [longRest=false] Is this a long rest?
+ * @return {Promise.} Chat message that was created.
+ * @protected
+ */
+ async _displayRestResultMessage(result, longRest = false) {
+ const {dhd, dhp, dtp, dfp, newDay} = result;
+ const diceRestored = dhd !== 0;
+ const healthRestored = dhp !== 0;
+ const length = longRest ? "Long" : "Short";
+
+ let restFlavor, message;
+
+ // Summarize the rest duration
+ switch (game.settings.get("sw5e", "restVariant")) {
+ case "normal":
+ restFlavor = longRest && newDay ? "SW5E.LongRestOvernight" : `SW5E.${length}RestNormal`;
+ break;
+ case "gritty":
+ restFlavor = !longRest && newDay ? "SW5E.ShortRestOvernight" : `SW5E.${length}RestGritty`;
+ break;
+ case "epic":
+ restFlavor = `SW5E.${length}RestEpic`;
+ break;
+ }
+
+ // Determine the chat message to display
+ if (longRest) {
+ message = "SW5E.LongRestResult";
+ if (dhp !== 0) message += "HP";
+ if (dfp !== 0) message += "FP";
+ if (dtp !== 0) message += "TP";
+ if (dhd !== 0) message += "HD";
+ } else {
+ message = "SW5E.ShortRestResultShort";
+ if (dhd !== 0 && dhp !== 0) {
+ if (dtp !== 0) {
+ message = "SW5E.ShortRestResultWithTech";
+ } else {
+ message = "SW5E.ShortRestResult";
+ }
+ } else {
+ if (dtp !== 0) {
+ message = "SW5E.ShortRestResultOnlyTech";
+ }
+ }
+ }
+
+ // Create a chat message
+ let chatData = {
+ user: game.user.id,
+ speaker: {actor: this, alias: this.name},
+ flavor: game.i18n.localize(restFlavor),
+ content: game.i18n.format(message, {
+ name: this.name,
+ dice: longRest ? dhd : -dhd,
+ health: dhp,
+ tech: dtp,
+ force: dfp
+ })
+ };
+ ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode"));
+ return ChatMessage.create(chatData);
}
- // 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);
- }
+ /* -------------------------------------------- */
+
+ /**
+ * Automatically spend hit dice to recover hit points up to a certain threshold.
+ *
+ * @param {object} [options]
+ * @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll.
+ * @return {Promise.} Number of hit dice spent.
+ */
+ async autoSpendHitDice({threshold = 3} = {}) {
+ const max = this.data.data.attributes.hp.max + this.data.data.attributes.hp.tempmax;
+
+ let diceRolled = 0;
+ while (this.data.data.attributes.hp.value + threshold <= max) {
+ const r = await this.rollHitDie(undefined, {dialog: false});
+ if (r === null) break;
+ diceRolled += 1;
+ }
+
+ return diceRolled;
}
- // Keep specific items from the original data
- d.items = d.items.concat(o.items.filter(i => {
- if ( i.type === "class" ) return keepClass;
- else if ( i.type === "feat" ) return keepFeats;
- else if ( i.type === "power" ) return keepPowers;
- else return keepItems;
- }));
+ /* -------------------------------------------- */
- // Transfer classes for NPCs
- if (!keepClass && d.data.details.cr) {
- d.items.push({
- type: 'class',
- name: game.i18n.localize('SW5E.PolymorphTmpClass'),
- data: { levels: d.data.details.cr }
- });
+ /**
+ * Recovers actor hit points and eliminates any temp HP.
+ *
+ * @param {object} [options]
+ * @param {boolean} [options.recoverTemp=true] Reset temp HP to zero.
+ * @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero.
+ * @return {object} Updates to the actor and change in hit points.
+ * @protected
+ */
+ _getRestHitPointRecovery({recoverTemp = true, recoverTempMax = true} = {}) {
+ const data = this.data.data;
+ let updates = {};
+ let max = data.attributes.hp.max;
+
+ if (recoverTempMax) {
+ updates["data.attributes.hp.tempmax"] = 0;
+ } else {
+ max += data.attributes.hp.tempmax;
+ }
+ updates["data.attributes.hp.value"] = max;
+ if (recoverTemp) {
+ updates["data.attributes.hp.temp"] = 0;
+ }
+
+ return {updates, hitPointsRecovered: max - data.attributes.hp.value};
}
- // 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);
+ /**
+ * Recovers actor resources.
+ * @param {object} [options]
+ * @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest.
+ * @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest.
+ * @return {object} Updates to the actor.
+ * @protected
+ */
+ _getRestResourceRecovery({recoverShortRestResources = true, recoverLongRestResources = true} = {}) {
+ let updates = {};
+ for (let [k, r] of Object.entries(this.data.data.resources)) {
+ if (
+ Number.isNumeric(r.max) &&
+ ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr))
+ ) {
+ updates[`data.resources.${k}.value`] = Number(r.max);
+ }
+ }
+ return updates;
}
- // Update regular Actors by creating a new Actor with the Polymorphed data
- await this.sheet.close();
- Hooks.callAll('sw5e.transformActor', this, target, d, {
- keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills,
- keepClass, keepFeats, keepPowers, keepItems, keepBio, keepVision, transformTokens
- });
- const newActor = await this.constructor.create(d, {renderSheet: true});
+ /* -------------------------------------------- */
- // Update placed Token instances
- if ( !transformTokens ) return;
- const tokens = this.getActiveTokens(true);
- const updates = tokens.map(t => {
- const newTokenData = foundry.utils.deepClone(d.token);
- if ( !t.data.actorLink ) newTokenData.actorData = newActor.data;
- newTokenData._id = t.data._id;
- newTokenData.actorId = newActor.id;
- return newTokenData;
- });
- return canvas.scene?.updateEmbeddedDocuments("Token", updates);
- }
+ /**
+ * Recovers power slots.
+ *
+ * @param longRest = true It's a long rest
+ * @return {object} Updates to the actor.
+ * @protected
+ */
+ _getRestPowerRecovery({recoverTechPowers = true, recoverForcePowers = true} = {}) {
+ let updates = {};
- /* -------------------------------------------- */
+ if (recoverTechPowers) {
+ updates["data.attributes.tech.points.value"] = this.data.data.attributes.tech.points.max;
+ updates["data.attributes.tech.points.temp"] = 0;
+ updates["data.attributes.tech.points.tempmax"] = 0;
- /**
- * If this actor was transformed with transformTokens enabled, then its
- * active tokens need to be returned to their original state. If not, then
- * we can safely just delete this actor.
- */
- async revertOriginalForm() {
- if ( !this.isPolymorphed ) return;
- if ( !this.isOwner ) {
- return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn"));
+ for (let [k, v] of Object.entries(this.data.data.powers)) {
+ updates[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : v.tmax ?? 0;
+ }
+ }
+
+ if (recoverForcePowers) {
+ updates["data.attributes.force.points.value"] = this.data.data.attributes.force.points.max;
+ updates["data.attributes.force.points.temp"] = 0;
+ updates["data.attributes.force.points.tempmax"] = 0;
+
+ for (let [k, v] of Object.entries(this.data.data.powers)) {
+ updates[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : v.fmax ?? 0;
+ }
+ }
+
+ return updates;
}
- // If we are reverting an unlinked token, simply replace it with the base actor prototype
- if ( this.isToken ) {
- const baseActor = game.actors.get(this.token.data.actorId);
- const prototypeTokenData = await baseActor.getTokenData();
- const tokenUpdate = {actorData: {}};
- for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) {
- tokenUpdate[k] = prototypeTokenData[k];
- }
- return this.token.update(tokenUpdate, {recursive: false});
+ /* -------------------------------------------- */
+
+ /**
+ * Recovers class hit dice during a long rest.
+ *
+ * @param {object} [options]
+ * @param {number} [options.maxHitDice] Maximum number of hit dice to recover.
+ * @return {object} Array of item updates and number of hit dice recovered.
+ * @protected
+ */
+ _getRestHitDiceRecovery({maxHitDice = undefined} = {}) {
+ // Determine the number of hit dice which may be recovered
+ if (maxHitDice === undefined) {
+ maxHitDice = Math.max(Math.floor(this.data.data.details.level / 2), 1);
+ }
+
+ // Sort classes which can recover HD, assuming players prefer recovering larger HD first.
+ const sortedClasses = Object.values(this.classes).sort((a, b) => {
+ return (parseInt(b.data.data.hitDice.slice(1)) || 0) - (parseInt(a.data.data.hitDice.slice(1)) || 0);
+ });
+
+ let updates = [];
+ let hitDiceRecovered = 0;
+ for (let item of sortedClasses) {
+ const d = item.data.data;
+ if (hitDiceRecovered < maxHitDice && d.hitDiceUsed > 0) {
+ let delta = Math.min(d.hitDiceUsed || 0, maxHitDice - hitDiceRecovered);
+ hitDiceRecovered += delta;
+ updates.push({
+ "_id": item.id,
+ "data.hitDiceUsed": d.hitDiceUsed - delta
+ });
+ }
+ }
+
+ return {updates, hitDiceRecovered};
}
- // Obtain a reference to the original actor
- const original = game.actors.get(this.getFlag('sw5e', 'originalActor'));
- if ( !original ) return;
+ /* -------------------------------------------- */
- // Get the Tokens which represent this actor
- if ( canvas.ready ) {
- const tokens = this.getActiveTokens(true);
- const tokenData = await original.getTokenData();
- const tokenUpdates = tokens.map(t => {
- const update = duplicate(tokenData);
- update._id = t.id;
- delete update.x;
- delete update.y;
- return update;
- });
- canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates);
+ /**
+ * Recovers item uses during short or long rests.
+ *
+ * @param {object} [options]
+ * @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest.
+ * @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest.
+ * @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day.
+ * @return {Array.} Array of item updates.
+ * @protected
+ */
+ _getRestItemUsesRecovery({recoverShortRestUses = true, recoverLongRestUses = true, recoverDailyUses = true} = {}) {
+ let recovery = [];
+ if (recoverShortRestUses) recovery.push("sr");
+ if (recoverLongRestUses) recovery.push("lr");
+ if (recoverDailyUses) recovery.push("day");
+
+ let updates = [];
+ for (let item of this.items) {
+ const d = item.data.data;
+ if (d.uses && recovery.includes(d.uses.per)) {
+ updates.push({"_id": item.id, "data.uses.value": d.uses.max});
+ }
+ if (recoverLongRestUses && d.recharge && d.recharge.value) {
+ updates.push({"_id": item.id, "data.recharge.charged": true});
+ }
+ }
+
+ return updates;
}
- // Delete the polymorphed version of the actor, if possible
- const isRendered = this.sheet.rendered;
- if ( game.user.isGM ) await this.delete();
- else if ( isRendered ) this.sheet.close();
- if ( isRendered ) original.sheet.render(isRendered);
- return original;
- }
+ /* -------------------------------------------- */
- /* -------------------------------------------- */
+ /**
+ * 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 === undefined || charRank.total > 0) {
+ charProf = char.data.data.attributes.prof;
+ }
- /**
- * 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 => {
+ if (coord) {
+ ssDeploy.coord.uuid = charUUID;
+ ssDeploy.coord.name = charName;
+ ssDeploy.coord.rank = charRank ? charRank.coord : 0;
+ ssDeploy.coord.prof = charProf;
+ }
+
+ if (gunner) {
+ ssDeploy.gunner.uuid = charUUID;
+ ssDeploy.gunner.name = charName;
+ ssDeploy.gunner.rank = charRank ? charRank.gunner : 0;
+ ssDeploy.gunner.prof = charProf;
+ }
+
+ if (mech) {
+ ssDeploy.mechanic.uuid = charUUID;
+ ssDeploy.mechanic.name = charName;
+ ssDeploy.mechanic.rank = charRank ? charRank.mechanic : 0;
+ ssDeploy.mechanic.prof = charProf;
+ }
+
+ if (oper) {
+ ssDeploy.operator.uuid = charUUID;
+ ssDeploy.operator.name = charName;
+ ssDeploy.operator.rank = charRank ? charRank.operator : 0;
+ ssDeploy.operator.prof = charProf;
+ }
+
+ if (pilot) {
+ ssDeploy.pilot.uuid = charUUID;
+ ssDeploy.pilot.name = charName;
+ ssDeploy.pilot.rank = charRank ? charRank.pilot : 0;
+ ssDeploy.pilot.prof = charProf;
+ }
+
+ if (tech) {
+ ssDeploy.technician.uuid = charUUID;
+ ssDeploy.technician.name = charName;
+ ssDeploy.technician.rank = charRank ? charRank.technician : 0;
+ ssDeploy.technician.prof = charProf;
+ }
+
+ if (crew) {
+ ssDeploy.crew.push({uuid: charUUID, name: charName, rank: charRank, prof: charProf});
+ }
+
+ if (pass) {
+ ssDeploy.passenger.push({uuid: charUUID, name: charName, rank: charRank, prof: charProf});
+ }
+ this.update({"data.attributes.deployment": ssDeploy});
+ }
+
+ /**
+ * Transform this Actor into another one.
+ *
+ * @param {Actor} target The target Actor.
+ * @param {boolean} [keepPhysical] Keep physical abilities (str, dex, con)
+ * @param {boolean} [keepMental] Keep mental abilities (int, wis, cha)
+ * @param {boolean} [keepSaves] Keep saving throw proficiencies
+ * @param {boolean} [keepSkills] Keep skill proficiencies
+ * @param {boolean} [mergeSaves] Take the maximum of the save proficiencies
+ * @param {boolean} [mergeSkills] Take the maximum of the skill proficiencies
+ * @param {boolean} [keepClass] Keep proficiency bonus
+ * @param {boolean} [keepFeats] Keep features
+ * @param {boolean} [keepPowers] Keep powers
+ * @param {boolean} [keepItems] Keep items
+ * @param {boolean} [keepBio] Keep biography
+ * @param {boolean} [keepVision] Keep vision
+ * @param {boolean} [transformTokens] Transform linked tokens too
+ */
+ async transformInto(
+ target,
+ {
+ keepPhysical = false,
+ keepMental = false,
+ keepSaves = false,
+ keepSkills = false,
+ mergeSaves = false,
+ mergeSkills = false,
+ keepClass = false,
+ keepFeats = false,
+ keepPowers = false,
+ keepItems = false,
+ keepBio = false,
+ keepVision = false,
+ transformTokens = true
+ } = {}
+ ) {
+ // Ensure the player is allowed to polymorph
const allowed = game.settings.get("sw5e", "allowPolymorphing");
- if ( !allowed && !game.user.isGM ) return false;
- const actor = game.actors.get(li.data('entityId'));
- return actor && actor.isPolymorphed;
- }
- });
- }
+ if (!allowed && !game.user.isGM) {
+ return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphWarn"));
+ }
- /* -------------------------------------------- */
+ // Get the original Actor data and the new source data
+ const o = this.toJSON();
+ o.flags.sw5e = o.flags.sw5e || {};
+ o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves};
+ const source = target.toJSON();
- /**
- * Format a type object into a string.
- * @param {object} typeData The type data to convert to a string.
- * @returns {string}
- */
- static formatCreatureType(typeData) {
- if ( typeof typeData === "string" ) return typeData; // backwards compatibility
- let localizedType;
- if ( typeData.value === "custom" ) {
- localizedType = typeData.custom;
- } else {
- let code = CONFIG.SW5E.creatureTypes[typeData.value];
- localizedType = game.i18n.localize(!!typeData.swarm ? `${code}Pl` : code);
+ // Prepare new data to merge from the source
+ const d = {
+ type: o.type, // Remain the same actor type
+ name: `${o.name} (${source.name})`, // Append the new shape to your old name
+ data: source.data, // Get the data model of your new form
+ items: source.items, // Get the items of your new form
+ effects: o.effects.concat(source.effects), // Combine active effects from both forms
+ img: source.img, // New appearance
+ permission: o.permission, // Use the original actor permissions
+ folder: o.folder, // Be displayed in the same sidebar folder
+ flags: o.flags // Use the original actor flags
+ };
+
+ // Specifically delete some data attributes
+ delete d.data.resources; // Don't change your resource pools
+ delete d.data.currency; // Don't lose currency
+ delete d.data.bonuses; // Don't lose global bonuses
+
+ // Specific additional adjustments
+ d.data.details.alignment = o.data.details.alignment; // Don't change alignment
+ d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level
+ d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration
+ d.data.powers = o.data.powers; // Keep power slots
+
+ // Token appearance updates
+ d.token = {name: d.name};
+ for (let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"]) {
+ d.token[k] = source.token[k];
+ }
+ if (!keepVision) {
+ for (let k of ["dimSight", "brightSight", "dimLight", "brightLight", "vision", "sightAngle"]) {
+ d.token[k] = source.token[k];
+ }
+ }
+ if (source.token.randomImg) {
+ const images = await target.getTokenImages();
+ d.token.img = images[Math.floor(Math.random() * images.length)];
+ }
+
+ // Transfer ability scores
+ const abilities = d.data.abilities;
+ for (let k of Object.keys(abilities)) {
+ const oa = o.data.abilities[k];
+ const prof = abilities[k].proficient;
+ if (keepPhysical && ["str", "dex", "con"].includes(k)) abilities[k] = oa;
+ else if (keepMental && ["int", "wis", "cha"].includes(k)) abilities[k] = oa;
+ if (keepSaves) abilities[k].proficient = oa.proficient;
+ else if (mergeSaves) abilities[k].proficient = Math.max(prof, oa.proficient);
+ }
+
+ // Transfer skills
+ if (keepSkills) d.data.skills = o.data.skills;
+ else if (mergeSkills) {
+ for (let [k, s] of Object.entries(d.data.skills)) {
+ s.value = Math.max(s.value, o.data.skills[k].value);
+ }
+ }
+
+ // Keep specific items from the original data
+ d.items = d.items.concat(
+ o.items.filter((i) => {
+ if (i.type === "class") return keepClass;
+ else if (i.type === "feat") return keepFeats;
+ else if (i.type === "power") return keepPowers;
+ else return keepItems;
+ })
+ );
+
+ // Transfer classes for NPCs
+ if (!keepClass && d.data.details.cr) {
+ d.items.push({
+ type: "class",
+ name: game.i18n.localize("SW5E.PolymorphTmpClass"),
+ data: {levels: d.data.details.cr}
+ });
+ }
+
+ // Keep biography
+ if (keepBio) d.data.details.biography = o.data.details.biography;
+
+ // Keep senses
+ if (keepVision) d.data.traits.senses = o.data.traits.senses;
+
+ // Set new data flags
+ if (!this.isPolymorphed || !d.flags.sw5e.originalActor) d.flags.sw5e.originalActor = this.id;
+ d.flags.sw5e.isPolymorphed = true;
+
+ // Update unlinked Tokens in place since they can simply be re-dropped from the base actor
+ if (this.isToken) {
+ const tokenData = d.token;
+ tokenData.actorData = d;
+ delete tokenData.actorData.token;
+ return this.token.update(tokenData);
+ }
+
+ // Update regular Actors by creating a new Actor with the Polymorphed data
+ await this.sheet.close();
+ Hooks.callAll("sw5e.transformActor", this, target, d, {
+ keepPhysical,
+ keepMental,
+ keepSaves,
+ keepSkills,
+ mergeSaves,
+ mergeSkills,
+ keepClass,
+ keepFeats,
+ keepPowers,
+ keepItems,
+ keepBio,
+ keepVision,
+ transformTokens
+ });
+ const newActor = await this.constructor.create(d, {renderSheet: true});
+
+ // Update placed Token instances
+ if (!transformTokens) return;
+ const tokens = this.getActiveTokens(true);
+ const updates = tokens.map((t) => {
+ const newTokenData = foundry.utils.deepClone(d.token);
+ if (!t.data.actorLink) newTokenData.actorData = newActor.data;
+ newTokenData._id = t.data._id;
+ newTokenData.actorId = newActor.id;
+ return newTokenData;
+ });
+ return canvas.scene?.updateEmbeddedDocuments("Token", updates);
}
- let type = localizedType;
- if ( !!typeData.swarm ) {
- type = game.i18n.format('SW5E.CreatureSwarmPhrase', {
- size: game.i18n.localize(CONFIG.SW5E.actorSizes[typeData.swarm]),
- type: localizedType
- });
+
+ /* -------------------------------------------- */
+
+ /**
+ * If this actor was transformed with transformTokens enabled, then its
+ * active tokens need to be returned to their original state. If not, then
+ * we can safely just delete this actor.
+ */
+ async revertOriginalForm() {
+ if (!this.isPolymorphed) return;
+ if (!this.isOwner) {
+ return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn"));
+ }
+
+ // If we are reverting an unlinked token, simply replace it with the base actor prototype
+ if (this.isToken) {
+ const baseActor = game.actors.get(this.token.data.actorId);
+ const prototypeTokenData = await baseActor.getTokenData();
+ const tokenUpdate = {actorData: {}};
+ for (let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"]) {
+ tokenUpdate[k] = prototypeTokenData[k];
+ }
+ return this.token.update(tokenUpdate, {recursive: false});
+ }
+
+ // Obtain a reference to the original actor
+ const original = game.actors.get(this.getFlag("sw5e", "originalActor"));
+ if (!original) return;
+
+ // Get the Tokens which represent this actor
+ if (canvas.ready) {
+ const tokens = this.getActiveTokens(true);
+ const tokenData = await original.getTokenData();
+ const tokenUpdates = tokens.map((t) => {
+ const update = duplicate(tokenData);
+ update._id = t.id;
+ delete update.x;
+ delete update.y;
+ return update;
+ });
+ canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates);
+ }
+
+ // Delete the polymorphed version of the actor, if possible
+ const isRendered = this.sheet.rendered;
+ if (game.user.isGM) await this.delete();
+ else if (isRendered) this.sheet.close();
+ if (isRendered) original.sheet.render(isRendered);
+ return original;
}
- if (typeData.subtype) type = `${type} (${typeData.subtype})`;
- return type;
- }
- /* -------------------------------------------- */
- /* DEPRECATED METHODS */
- /* -------------------------------------------- */
+ /* -------------------------------------------- */
- /**
- * @deprecated since sw5e 0.97
- */
- getPowerDC(ability) {
- console.warn(`The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc`);
- return this.data.data.abilities[ability]?.dc;
- }
+ /**
+ * Add additional system-specific sidebar directory context menu options for SW5e Actor entities
+ * @param {jQuery} html The sidebar HTML
+ * @param {Array} entryOptions The default array of context menu options
+ */
+ static addDirectoryContextOptions(html, entryOptions) {
+ entryOptions.push({
+ name: "SW5E.PolymorphRestoreTransformation",
+ icon: ' ',
+ callback: (li) => {
+ const actor = game.actors.get(li.data("entityId"));
+ return actor.revertOriginalForm();
+ },
+ condition: (li) => {
+ const allowed = game.settings.get("sw5e", "allowPolymorphing");
+ if (!allowed && !game.user.isGM) return false;
+ const actor = game.actors.get(li.data("entityId"));
+ return actor && actor.isPolymorphed;
+ }
+ });
+ }
- /* -------------------------------------------- */
+ /* -------------------------------------------- */
- /**
- * Cast a Power, consuming a power slot of a certain level
- * @param {Item5e} item The power being cast by the actor
- * @param {Event} event The originating user interaction which triggered the cast
- * @deprecated since sw5e 1.2.0
- */
- async usePower(item, {configureDialog=true}={}) {
- console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`);
- if ( item.data.type !== "power" ) throw new Error("Wrong Item type");
- return item.roll();
- }
-}
\ No newline at end of file
+ /**
+ * Format a type object into a string.
+ * @param {object} typeData The type data to convert to a string.
+ * @returns {string}
+ */
+ static formatCreatureType(typeData) {
+ if (typeof typeData === "string") return typeData; // backwards compatibility
+ let localizedType;
+ if (typeData.value === "custom") {
+ localizedType = typeData.custom;
+ } else {
+ let code = CONFIG.SW5E.creatureTypes[typeData.value];
+ localizedType = game.i18n.localize(!!typeData.swarm ? `${code}Pl` : code);
+ }
+ let type = localizedType;
+ if (!!typeData.swarm) {
+ type = game.i18n.format("SW5E.CreatureSwarmPhrase", {
+ size: game.i18n.localize(CONFIG.SW5E.actorSizes[typeData.swarm]),
+ type: localizedType
+ });
+ }
+ if (typeData.subtype) type = `${type} (${typeData.subtype})`;
+ return type;
+ }
+
+ /* -------------------------------------------- */
+ /* DEPRECATED METHODS */
+ /* -------------------------------------------- */
+
+ /**
+ * @deprecated since sw5e 0.97
+ */
+ getPowerDC(ability) {
+ console.warn(
+ `The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc`
+ );
+ return this.data.data.abilities[ability]?.dc;
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Cast a Power, consuming a power slot of a certain level
+ * @param {Item5e} item The power being cast by the actor
+ * @param {Event} event The originating user interaction which triggered the cast
+ * @deprecated since sw5e 1.2.0
+ */
+ async usePower(item, {configureDialog = true} = {}) {
+ console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`);
+ if (item.data.type !== "power") throw new Error("Wrong Item type");
+ return item.roll();
+ }
+}
diff --git a/module/actor/old_entity.js b/module/actor/old_entity.js
deleted file mode 100644
index 23080d27..00000000
--- a/module/actor/old_entity.js
+++ /dev/null
@@ -1,2040 +0,0 @@
-import { d20Roll, damageRoll } from "../dice.js";
-import ShortRestDialog from "../apps/short-rest.js";
-import LongRestDialog from "../apps/long-rest.js";
-import {SW5E} from '../config.js';
-
-/**
- * Extend the base Actor class to implement additional system-specific logic for SW5e.
- */
-export default class Actor5e extends Actor {
-
- /**
- * Is this Actor currently polymorphed into some other creature?
- * @return {boolean}
- */
- get isPolymorphed() {
- return this.getFlag("sw5e", "isPolymorphed") || false;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- prepareBaseData() {
- switch ( this.data.type ) {
- case "character":
- return this._prepareCharacterData(this.data);
- case "npc":
- return this._prepareNPCData(this.data);
- case "starship":
- return this._prepareStarshipData(this.data);
- case "vehicle":
- return this._prepareVehicleData(this.data);
- }
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- prepareDerivedData() {
- const actorData = this.data;
- const data = actorData.data;
- const flags = actorData.flags.sw5e || {};
- const bonuses = getProperty(data, "bonuses.abilities") || {};
-
- // Retrieve data for polymorphed actors
- let originalSaves = null;
- let originalSkills = null;
- if (this.isPolymorphed) {
- const transformOptions = this.getFlag('sw5e', 'transformOptions');
- const original = game.actors?.get(this.getFlag('sw5e', 'originalActor'));
- if (original) {
- if (transformOptions.mergeSaves) {
- originalSaves = original.data.data.abilities;
- }
- if (transformOptions.mergeSkills) {
- originalSkills = original.data.data.skills;
- }
- }
- }
-
- // Ability modifiers and saves
- const dcBonus = Number.isNumeric(data.bonuses?.power?.dc) ? parseInt(data.bonuses.power.dc) : 0;
- const saveBonus = Number.isNumeric(bonuses.save) ? parseInt(bonuses.save) : 0;
- const checkBonus = Number.isNumeric(bonuses.check) ? parseInt(bonuses.check) : 0;
- for (let [id, abl] of Object.entries(data.abilities)) {
- abl.mod = Math.floor((abl.value - 10) / 2);
- abl.prof = (abl.proficient || 0) * data.attributes.prof;
- abl.saveBonus = saveBonus;
- abl.checkBonus = checkBonus;
- abl.save = abl.mod + abl.prof + abl.saveBonus;
- abl.dc = 8 + abl.mod + data.attributes.prof + dcBonus;
-
- // If we merged saves when transforming, take the highest bonus here.
- if (originalSaves && abl.proficient) {
- abl.save = Math.max(abl.save, originalSaves[id].save);
- }
- }
-
- // Inventory encumbrance
- data.attributes.encumbrance = this._computeEncumbrance(actorData);
-
- if (actorData.type === "starship") {
-
- // Calculate AC
- data.attributes.ac.value += Math.min(data.abilities.dex.mod, data.attributes.equip.armor.maxDex);
-
- // Set Power Die Storage
- data.attributes.power.central.max += data.attributes.equip.powerCoupling.centralCap;
- data.attributes.power.comms.max += data.attributes.equip.powerCoupling.systemCap;
- data.attributes.power.engines.max += data.attributes.equip.powerCoupling.systemCap;
- data.attributes.power.shields.max += data.attributes.equip.powerCoupling.systemCap;
- data.attributes.power.sensors.max += data.attributes.equip.powerCoupling.systemCap;
- data.attributes.power.weapons.max += data.attributes.equip.powerCoupling.systemCap;
-
- // Find Size info of Starship
- const size = actorData.items.filter(i => i.type === "starship");
- if (size.length === 0) return;
- const sizeData = size[0].data;
-
- // Prepare Hull Points
- data.attributes.hp.max = sizeData.hullDiceRolled.reduce((a, b) => a + b, 0) + data.abilities.con.mod * data.attributes.hull.dicemax;
- if (data.attributes.hp.value === null) data.attributes.hp.value = data.attributes.hp.max;
-
- // Prepare Shield Points
- data.attributes.hp.tempmax = (sizeData.shldDiceRolled.reduce((a, b) => a + b, 0) + data.abilities.str.mod * data.attributes.shld.dicemax) * data.attributes.equip.shields.capMult;
- if (data.attributes.hp.temp === null) data.attributes.hp.temp = data.attributes.hp.tempmax;
-
- // Prepare Speeds
- data.attributes.movement.space = sizeData.baseSpaceSpeed + (50 * (data.abilities.str.mod - data.abilities.con.mod));
- data.attributes.movement.turn = Math.min(data.attributes.movement.space, Math.max(50,(sizeData.baseTurnSpeed - (50 * (data.abilities.dex.mod - data.abilities.con.mod)))));
-
- // Prepare Max Suites
- data.attributes.mods.suites.max = sizeData.modMaxSuitesBase + (sizeData.modMaxSuitesMult * data.abilities.con.mod);
-
- // Prepare Hardpoints
- data.attributes.mods.hardpoints.max = sizeData.hardpointMult * Math.max(1,data.abilities.str.mod);
-
- //Prepare Fuel
- data.attributes.fuel = this._computeFuel(actorData);
- }
-
- // Prepare skills
- this._prepareSkills(actorData, bonuses, checkBonus, originalSkills);
-
- // Determine Initiative Modifier
- const init = data.attributes.init;
- const athlete = flags.remarkableAthlete;
- const joat = flags.jackOfAllTrades;
- init.mod = data.abilities.dex.mod;
- if ( joat ) init.prof = Math.floor(0.5 * data.attributes.prof);
- else if ( athlete ) init.prof = Math.ceil(0.5 * data.attributes.prof);
- else init.prof = 0;
- init.value = init.value ?? 0;
- init.bonus = init.value + (flags.initiativeAlert ? 5 : 0);
- init.total = init.mod + init.prof + init.bonus;
-
- // Prepare power-casting data
- data.attributes.powerForceLightDC = 8 + data.abilities.wis.mod + data.attributes.prof ?? 10;
- data.attributes.powerForceDarkDC = 8 + data.abilities.cha.mod + data.attributes.prof ?? 10;
- data.attributes.powerForceUnivDC = Math.max(data.attributes.powerForceLightDC,data.attributes.powerForceDarkDC) ?? 10;
- data.attributes.powerTechDC = 8 + data.abilities.int.mod + data.attributes.prof ?? 10;
- this._computeDerivedPowercasting(this.data);
-
- // Compute owned item attributes which depend on prepared Actor data
- this.items.forEach(item => {
- item.getSaveDC();
- item.getAttackToHit();
- });
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return the amount of experience required to gain a certain character level.
- * @param level {Number} The desired level
- * @return {Number} The XP required
- */
- getLevelExp(level) {
- const levels = CONFIG.SW5E.CHARACTER_EXP_LEVELS;
- return levels[Math.min(level, levels.length - 1)];
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return the amount of experience granted by killing a creature of a certain CR.
- * @param cr {Number} The creature's challenge rating
- * @return {Number} The amount of experience granted per kill
- */
- getCRExp(cr) {
- if (cr < 1.0) return Math.max(200 * cr, 10);
- return CONFIG.SW5E.CR_EXP_LEVELS[cr];
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- getRollData() {
- const data = super.getRollData();
- data.classes = this.data.items.reduce((obj, i) => {
- if ( i.type === "class" ) {
- obj[i.name.slugify({strict: true})] = i.data;
- }
- return obj;
- }, {});
- data.prof = this.data.data.attributes.prof || 0;
- return data;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Return the features which a character is awarded for each class level
- * @param {string} className The class name being added
- * @param {string} archetypeName The archetype of the class being added, if any
- * @param {number} level The number of levels in the added class
- * @param {number} priorLevel The previous level of the added class
- * @return {Promise} Array of Item5e entities
- */
- static async getClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) {
- className = className.toLowerCase();
- archetypeName = archetypeName.slugify();
-
- // Get the configuration of features which may be added
- const clsConfig = CONFIG.SW5E.classFeatures[className];
- if (!clsConfig) return [];
-
- // Acquire class features
- let ids = [];
- for ( let [l, f] of Object.entries(clsConfig.features || {}) ) {
- l = parseInt(l);
- if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f);
- }
-
- // Acquire archetype features
- const archConfig = clsConfig.archetypes[archetypeName] || {};
- for ( let [l, f] of Object.entries(archConfig.features || {}) ) {
- l = parseInt(l);
- if ( (l <= level) && (l > priorLevel) ) ids = ids.concat(f);
- }
-
- // Load item data for all identified features
- const features = [];
- for ( let id of ids ) {
- features.push(await fromUuid(id));
- }
-
- // Class powers should always be prepared
- for ( const feature of features ) {
- if ( feature.type === "power" ) {
- const preparation = feature.data.data.preparation;
- preparation.mode = "always";
- preparation.prepared = true;
- }
- }
- return features;
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- async updateEmbeddedEntity(embeddedName, data, options={}) {
- const createItems = embeddedName === "OwnedItem" ? await this._createClassFeatures(data) : [];
- let updated = await super.updateEmbeddedEntity(embeddedName, data, options);
- if ( createItems.length ) await this.createEmbeddedEntity("OwnedItem", createItems);
- return updated;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Create additional class features in the Actor when a class item is updated.
- * @private
- */
- async _createClassFeatures(updated) {
- let toCreate = [];
- for (let u of updated instanceof Array ? updated : [updated]) {
- const item = this.items.get(u._id);
- if (!item || (item.data.type !== "class")) continue;
- const updateData = expandObject(u);
- const config = {
- className: updateData.name || item.data.name,
- archetypeName: getProperty(updateData, "data.archetype") || item.data.data.archetype,
- level: getProperty(updateData, "data.levels"),
- priorLevel: item ? item.data.data.levels : 0
- }
-
- // Get and create features for an increased class level
- let changed = false;
- if ( config.level && (config.level > config.priorLevel)) changed = true;
- if ( config.archetypeName !== item.data.data.archetype ) changed = true;
-
- // Get features to create
- if ( changed ) {
- const existing = new Set(this.items.map(i => i.name));
- const features = await Actor5e.getClassFeatures(config);
- for ( let f of features ) {
- if ( !existing.has(f.name) ) toCreate.push(f);
- }
- }
- }
- return toCreate
- }
-
- /* -------------------------------------------- */
- /* Data Preparation Helpers */
- /* -------------------------------------------- */
-
- /**
- * Prepare Character type specific data
- */
- _prepareCharacterData(actorData) {
- const data = actorData.data;
-
- // Determine character level and available hit dice based on owned Class items
- const [level, hd] = actorData.items.reduce((arr, item) => {
- if ( item.type === "class" ) {
- const classLevels = parseInt(item.data.levels) || 1;
- arr[0] += classLevels;
- arr[1] += classLevels - (parseInt(item.data.hitDiceUsed) || 0);
- }
- return arr;
- }, [0, 0]);
- data.details.level = level;
- data.attributes.hd = hd;
-
- // Character proficiency bonus
- data.attributes.prof = Math.floor((level + 7) / 4);
-
- // Experience required for next level
- const xp = data.details.xp;
- xp.max = this.getLevelExp(level || 1);
- const prior = this.getLevelExp(level - 1 || 0);
- const required = xp.max - prior;
- const pct = Math.round((xp.value - prior) * 100 / required);
- xp.pct = Math.clamped(pct, 0, 100);
-
- // Add base Powercasting attributes
- this._computeBasePowercasting(actorData);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare NPC type specific data
- */
- _prepareNPCData(actorData) {
- const data = actorData.data;
-
- // Kill Experience
- data.details.xp.value = this.getCRExp(data.details.cr);
-
- // Proficiency
- data.attributes.prof = Math.floor((Math.max(data.details.cr, 1) + 7) / 4);
-
- this._computeBasePowercasting(actorData);
-
- // Powercaster Level
- if ( data.attributes.powercasting && !Number.isNumeric(data.details.powerLevel) ) {
- data.details.powerLevel = Math.max(data.details.cr, 1);
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare vehicle type-specific data
- * @param actorData
- * @private
- */
- _prepareVehicleData(actorData) {}
-
- /* -------------------------------------------- */
-
- /* -------------------------------------------- */
-
- /**
- * Prepare starship type-specific data
- * @param actorData
- * @private
- */
- _prepareStarshipData(actorData) {
-
- const data = actorData.data;
- data.attributes.prof = 0;
- // Determine Starship size-based properties based on owned Starship item
- const size = actorData.items.filter(i => i.type === "starship");
- if (size.length !== 0) {
- const sizeData = size[0].data;
- const tiers = parseInt(sizeData.tier) || 0;
- data.traits.size = sizeData.size; // needs to be the short code
- data.details.tier = tiers;
- data.attributes.ac.value = 10 + Math.max(tiers - 1, 0);
- data.attributes.hull.die = sizeData.hullDice;
- data.attributes.hull.dicemax = sizeData.hullDiceStart + tiers;
- data.attributes.hull.dice = sizeData.hullDiceStart + tiers - (parseInt(sizeData.hullDiceUsed) || 0);
- data.attributes.shld.die = sizeData.shldDice;
- data.attributes.shld.dicemax = sizeData.shldDiceStart + tiers;
- data.attributes.shld.dice = sizeData.shldDiceStart + tiers - (parseInt(sizeData.shldDiceUsed) || 0);
- sizeData.pwrDice = SW5E.powerDieTypes[tiers];
- data.attributes.power.die = sizeData.pwrDice;
- data.attributes.cost.baseBuild = sizeData.buildBaseCost;
- data.attributes.workforce.minBuild = sizeData.buildMinWorkforce;
- data.attributes.workforce.max = data.attributes.workforce.minBuild * 5;
- data.attributes.cost.baseUpgrade = SW5E.baseUpgradeCost[tiers];
- data.attributes.cost.multUpgrade = sizeData.upgrdCostMult;
- data.attributes.workforce.minUpgrade = sizeData.upgrdMinWorkforce;
- data.attributes.equip.size.crewMinWorkforce = (parseInt(sizeData.crewMinWorkforce) || 1);
- data.attributes.mods.capLimit = sizeData.modBaseCap;
- data.attributes.mods.suites.cap = sizeData.modMaxSuiteCap;
- data.attributes.cost.multModification = sizeData.modCostMult;
- data.attributes.workforce.minModification = sizeData.modMinWorkforce;
- data.attributes.cost.multEquip = sizeData.equipCostMult;
- data.attributes.workforce.minEquip = sizeData.equipMinWorkforce;
- data.attributes.equip.size.cargoCap = sizeData.cargoCap;
- data.attributes.fuel.cost = sizeData.fuelCost;
- data.attributes.fuel.cap = sizeData.fuelCap;
- data.attributes.equip.size.foodCap = sizeData.foodCap;
- }
-
- // Determine Starship armor-based properties based on owned Starship item
- const armor = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "ssarmor"))); // && (i.data.equipped === true)));
- if (armor.length !== 0) {
- const armorData = armor[0].data;
- data.attributes.equip.armor.dr = (parseInt(armorData.dmgred.value) || 0);
- data.attributes.equip.armor.maxDex = armorData.armor.dex;
- data.attributes.equip.armor.stealthDisadv = armorData.stealth;
- }
-
- // Determine Starship hyperdrive-based properties based on owned Starship item
- const hyperdrive = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "hyper"))); // && (i.data.equipped === true)));
- if (hyperdrive.length !== 0) {
- const hdData = hyperdrive[0].data;
- data.attributes.equip.hyperdrive.class = (parseFloat(hdData.hdclass.value) || null);
- }
-
- // Determine Starship power coupling-based properties based on owned Starship item
- const pwrcpl = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "powerc"))); // && (i.data.equipped === true)));
- if (pwrcpl.length !== 0) {
- const pwrcplData = pwrcpl[0].data;
- data.attributes.equip.powerCoupling.centralCap = (parseInt(pwrcplData.cscap.value) || 0);
- data.attributes.equip.powerCoupling.systemCap = (parseInt(pwrcplData.sscap.value) || 0);
- data.attributes.power.central.max = 0;
- data.attributes.power.comms.max = 0;
- data.attributes.power.engines.max = 0;
- data.attributes.power.shields.max = 0;
- data.attributes.power.sensors.max = 0;
- data.attributes.power.weapons.max = 0;
- }
-
- // Determine Starship reactor-based properties based on owned Starship item
- const reactor = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "reactor"))); // && (i.data.equipped === true)));
- if (reactor.length !== 0) {
- const reactorData = reactor[0].data;
- data.attributes.equip.reactor.fuelMult = (parseFloat(reactorData.fuelcostsmod.value) || 0);
- data.attributes.equip.reactor.powerRecDie = reactorData.powdicerec.value;
- }
-
- // Determine Starship shield-based properties based on owned Starship item
- const shields = actorData.items.filter(i => ((i.type === "equipment") && (i.data.armor.type === "ssshield"))); // && (i.data.equipped === true)));
- if (shields.length !== 0) {
- const shieldsData = shields[0].data;
- data.attributes.equip.shields.capMult = (parseFloat(shieldsData.capx.value) || 1);
- data.attributes.equip.shields.regenRateMult = (parseFloat(shieldsData.regrateco.value) || 1);
- }
-
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare skill checks.
- * @param actorData
- * @param bonuses Global bonus data.
- * @param checkBonus Ability check specific bonus.
- * @param originalSkills A transformed actor's original actor's skills.
- * @private
- */
- _prepareSkills(actorData, bonuses, checkBonus, originalSkills) {
- if (actorData.type === 'vehicle') return;
-
- const data = actorData.data;
- const flags = actorData.flags.sw5e || {};
-
- // Skill modifiers
- const feats = SW5E.characterFlags;
- const athlete = flags.remarkableAthlete;
- const joat = flags.jackOfAllTrades;
- const observant = flags.observantFeat;
- const skillBonus = Number.isNumeric(bonuses.skill) ? parseInt(bonuses.skill) : 0;
- for (let [id, skl] of Object.entries(data.skills)) {
- skl.value = Math.clamped(Number(skl.value).toNearest(0.5), 0, 2) ?? 0;
- let round = Math.floor;
-
- // Remarkable
- if ( athlete && (skl.value < 0.5) && feats.remarkableAthlete.abilities.includes(skl.ability) ) {
- skl.value = 0.5;
- round = Math.ceil;
- }
-
- // Jack of All Trades
- if ( joat && (skl.value < 0.5) ) {
- skl.value = 0.5;
- }
-
- // Polymorph Skill Proficiencies
- if ( originalSkills ) {
- skl.value = Math.max(skl.value, originalSkills[id].value);
- }
-
- // Compute modifier
- skl.bonus = checkBonus + skillBonus;
- skl.mod = data.abilities[skl.ability].mod;
- skl.prof = round(skl.value * data.attributes.prof);
- skl.total = skl.mod + skl.prof + skl.bonus;
-
- // Compute passive bonus
- const passive = observant && (feats.observantFeat.skills.includes(id)) ? 5 : 0;
- skl.passive = 10 + skl.total + passive;
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare data related to the power-casting capabilities of the Actor
- * @private
- */
- _computeBasePowercasting (actorData) {
- if (actorData.type === 'vehicle' || actorData.type === 'starship') return;
- const powers = actorData.data.powers;
- const isNPC = actorData.type === 'npc';
-
- // Translate the list of classes into force and tech power-casting progression
- const forceProgression = {
- classes: 0,
- levels: 0,
- multi: 0,
- maxClass: "none",
- maxClassPriority: 0,
- maxClassLevels: 0,
- maxClassPowerLevel: 0,
- powersKnown: 0,
- points: 0
- };
- const techProgression = {
- classes: 0,
- levels: 0,
- multi: 0,
- maxClass: "none",
- maxClassPriority: 0,
- maxClassLevels: 0,
- maxClassPowerLevel: 0,
- powersKnown: 0,
- points: 0
- };
-
- // Tabulate the total power-casting progression
- const classes = this.data.items.filter(i => i.type === "class");
- let priority = 0;
- for ( let cls of classes ) {
- const d = cls.data;
- if ( d.powercasting === "none" ) continue;
- const levels = d.levels;
- const prog = d.powercasting;
-
- switch (prog) {
- case 'consular':
- priority = 3;
- forceProgression.levels += levels;
- forceProgression.multi += (SW5E.powerMaxLevel['consular'][19]/9)*levels;
- forceProgression.classes++;
- // see if class controls high level forcecasting
- if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){
- forceProgression.maxClass = 'consular';
- forceProgression.maxClassLevels = levels;
- forceProgression.maxClassPriority = priority;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['consular'][Math.clamped((levels - 1), 0, 20)];
- }
- // calculate points and powers known
- forceProgression.powersKnown += SW5E.powersKnown['consular'][Math.clamped((levels - 1), 0, 20)];
- forceProgression.points += SW5E.powerPoints['consular'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'engineer':
- priority = 2
- techProgression.levels += levels;
- techProgression.multi += (SW5E.powerMaxLevel['engineer'][19]/9)*levels;
- techProgression.classes++;
- // see if class controls high level techcasting
- if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){
- techProgression.maxClass = 'engineer';
- techProgression.maxClassLevels = levels;
- techProgression.maxClassPriority = priority;
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['engineer'][Math.clamped((levels - 1), 0, 20)];
- }
- techProgression.powersKnown += SW5E.powersKnown['engineer'][Math.clamped((levels - 1), 0, 20)];
- techProgression.points += SW5E.powerPoints['engineer'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'guardian':
- priority = 1;
- forceProgression.levels += levels;
- forceProgression.multi += (SW5E.powerMaxLevel['guardian'][19]/9)*levels;
- forceProgression.classes++;
- // see if class controls high level forcecasting
- if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){
- forceProgression.maxClass = 'guardian';
- forceProgression.maxClassLevels = levels;
- forceProgression.maxClassPriority = priority;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['guardian'][Math.clamped((levels - 1), 0, 20)];
- }
- forceProgression.powersKnown += SW5E.powersKnown['guardian'][Math.clamped((levels - 1), 0, 20)];
- forceProgression.points += SW5E.powerPoints['guardian'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'scout':
- priority = 1;
- techProgression.levels += levels;
- techProgression.multi += (SW5E.powerMaxLevel['scout'][19]/9)*levels;
- techProgression.classes++;
- // see if class controls high level techcasting
- if ((levels >= techProgression.maxClassLevels) && (priority > techProgression.maxClassPriority)){
- techProgression.maxClass = 'scout';
- techProgression.maxClassLevels = levels;
- techProgression.maxClassPriority = priority;
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['scout'][Math.clamped((levels - 1), 0, 20)];
- }
- techProgression.powersKnown += SW5E.powersKnown['scout'][Math.clamped((levels - 1), 0, 20)];
- techProgression.points += SW5E.powerPoints['scout'][Math.clamped((levels - 1), 0, 20)];
- break;
- case 'sentinel':
- priority = 2;
- forceProgression.levels += levels;
- forceProgression.multi += (SW5E.powerMaxLevel['sentinel'][19]/9)*levels;
- forceProgression.classes++;
- // see if class controls high level forcecasting
- if ((levels >= forceProgression.maxClassLevels) && (priority > forceProgression.maxClassPriority)){
- forceProgression.maxClass = 'sentinel';
- forceProgression.maxClassLevels = levels;
- forceProgression.maxClassPriority = priority;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['sentinel'][Math.clamped((levels - 1), 0, 20)];
- }
- forceProgression.powersKnown += SW5E.powersKnown['sentinel'][Math.clamped((levels - 1), 0, 20)];
- forceProgression.points += SW5E.powerPoints['sentinel'][Math.clamped((levels - 1), 0, 20)];
- break; }
- }
-
- // EXCEPTION: multi-classed progression uses multi rounded down rather than levels
- if (!isNPC && forceProgression.classes > 1) {
- forceProgression.levels = Math.floor(forceProgression.multi);
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][forceProgression.levels - 1];
- }
- if (!isNPC && techProgression.classes > 1) {
- techProgression.levels = Math.floor(techProgression.multi);
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][techProgression.levels - 1];
- }
-
- // EXCEPTION: NPC with an explicit power-caster level
- if (isNPC && actorData.data.details.powerForceLevel) {
- forceProgression.levels = actorData.data.details.powerForceLevel;
- actorData.data.attributes.force.level = forceProgression.levels;
- forceProgression.maxClass = actorData.data.attributes.powercasting;
- forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped((forceProgression.levels - 1), 0, 20)];
- }
- if (isNPC && actorData.data.details.powerTechLevel) {
- techProgression.levels = actorData.data.details.powerTechLevel;
- actorData.data.attributes.tech.level = techProgression.levels;
- techProgression.maxClass = actorData.data.attributes.powercasting;
- techProgression.maxClassPowerLevel = SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped((techProgression.levels - 1), 0, 20)];
- }
-
- // Look up the number of slots per level from the powerLimit table
- let forcePowerLimit = Array.from(SW5E.powerLimit['none']);
- for (let i = 0; i < (forceProgression.maxClassPowerLevel); i++) {
- forcePowerLimit[i] = SW5E.powerLimit[forceProgression.maxClass][i];
- }
-
- for ( let [n, lvl] of Object.entries(powers) ) {
- let i = parseInt(n.slice(-1));
- if ( Number.isNaN(i) ) continue;
- if ( Number.isNumeric(lvl.foverride) ) lvl.fmax = Math.max(parseInt(lvl.foverride), 0);
- else lvl.fmax = forcePowerLimit[i-1] || 0;
- if (isNPC){
- lvl.fvalue = lvl.fmax;
- }else{
- lvl.fvalue = Math.min(parseInt(lvl.fvalue || lvl.value || lvl.fmax),lvl.fmax);
- }
- }
-
- let techPowerLimit = Array.from(SW5E.powerLimit['none']);
- for (let i = 0; i < (techProgression.maxClassPowerLevel); i++) {
- techPowerLimit[i] = SW5E.powerLimit[techProgression.maxClass][i];
- }
-
- for ( let [n, lvl] of Object.entries(powers) ) {
- let i = parseInt(n.slice(-1));
- if ( Number.isNaN(i) ) continue;
- if ( Number.isNumeric(lvl.toverride) ) lvl.tmax = Math.max(parseInt(lvl.toverride), 0);
- else lvl.tmax = techPowerLimit[i-1] || 0;
- if (isNPC){
- lvl.tvalue = lvl.tmax;
- }else{
- lvl.tvalue = Math.min(parseInt(lvl.tvalue || lvl.value || lvl.tmax),lvl.tmax);
- }
- }
-
- // Set Force and tech power for PC Actors
- if (!isNPC && forceProgression.levels){
- actorData.data.attributes.force.known.max = forceProgression.powersKnown;
- actorData.data.attributes.force.points.max = forceProgression.points; // + Math.max(actorData.data.abilities.wis.mod,actorData.data.abilities.cha.mod);
- actorData.data.attributes.force.level = forceProgression.levels;
- }
- if (!isNPC && techProgression.levels){
- actorData.data.attributes.tech.known.max = techProgression.powersKnown;
- actorData.data.attributes.tech.points.max = techProgression.points; // + actorData.data.abilities.int.mod;
- actorData.data.attributes.tech.level = techProgression.levels;
- }
-
- // Tally Powers Known and check for migration first to avoid errors
- let hasKnownPowers = actorData?.data?.attributes?.force?.known?.value !== undefined;
- if ( hasKnownPowers ) {
- const knownPowers = this.data.items.filter(i => i.type === "power");
- let knownForcePowers = 0;
- let knownTechPowers = 0;
- for ( let knownPower of knownPowers ) {
- const d = knownPower.data;
- switch (knownPower.data.school){
- case "lgt":
- case "uni":
- case "drk":{
- knownForcePowers++;
- break;
- }
- case "tec":{
- knownTechPowers++;
- break;
- }
- }
- continue;
- }
- actorData.data.attributes.force.known.value = knownForcePowers;
- actorData.data.attributes.tech.known.value = knownTechPowers;
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Prepare data related to the power-casting capabilities of the Actor
- * @private
- */
- _computeDerivedPowercasting (actorData) {
- if (actorData.type !== 'actor') return;
-
- // Set Force and tech power for PC Actors
- if (!!actorData.data.attributes.force.level){
- actorData.data.attributes.force.points.max += Math.max(actorData.data.abilities.wis.mod,actorData.data.abilities.cha.mod);
- }
- if (!!actorData.data.attributes.tech.level){
- actorData.data.attributes.tech.points.max += actorData.data.abilities.int.mod;
- }
-
- }
-
- /* -------------------------------------------- */
-
- /**
- * Compute the level and percentage of encumbrance for an Actor.
- *
- * Optionally include the weight of carried currency across all denominations by applying the standard rule
- * from the PHB pg. 143
- * @param {Object} actorData The data object for the Actor being rendered
- * @returns {{max: number, value: number, pct: number}} An object describing the character's encumbrance level
- * @private
- */
- _computeEncumbrance(actorData) {
-
- // Get the total weight from items
- const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"];
- let weight = actorData.items.reduce((weight, i) => {
- if ( !physicalItems.includes(i.type) ) return weight;
- const q = i.data.quantity || 0;
- const w = i.data.weight || 0;
- return weight + (q * w);
- }, 0);
-
- // [Optional] add Currency Weight (for non-transformed actors)
- if ( game.settings.get("sw5e", "currencyWeight") && actorData.data.currency ) {
- const currency = actorData.data.currency;
- const numCoins = Object.values(currency).reduce((val, denom) => val += Math.max(denom, 0), 0);
- weight += numCoins / CONFIG.SW5E.encumbrance.currencyPerWeight;
- }
-
- // Determine the encumbrance size class
- let mod = {
- tiny: 0.5,
- sm: 1,
- med: 1,
- lg: 2,
- huge: 4,
- grg: 8
- }[actorData.data.traits.size] || 1;
- if ( this.getFlag("sw5e", "powerfulBuild") ) mod = Math.min(mod * 2, 8);
-
- // Compute Encumbrance percentage
- weight = weight.toNearest(0.1);
- const max = actorData.data.abilities.str.value * CONFIG.SW5E.encumbrance.strMultiplier * mod;
- const pct = Math.clamped((weight * 100) / max, 0, 100);
- return { value: weight.toNearest(0.1), max, pct, encumbered: pct > (2/3) };
- }
-
- _computeFuel(actorData) {
- const fuel = actorData.data.attributes.fuel;
- // Compute Fuel percentage
- const pct = Math.clamped((fuel.value.toNearest(0.1) * 100) / fuel.cap, 0, 100);
- return { ...fuel, pct, fueled: pct > 0 };
- }
-
- /* -------------------------------------------- */
- /* Socket Listeners and Handlers
- /* -------------------------------------------- */
-
- /** @override */
- static async create(data, options={}) {
- data.token = data.token || {};
- if ( data.type === "character" ) {
- mergeObject(data.token, {
- vision: true,
- dimSight: 30,
- brightSight: 0,
- actorLink: true,
- disposition: 1
- }, {overwrite: false});
- }
- return super.create(data, options);
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- async update(data, options={}) {
-
- // Apply changes in Actor size to Token width/height
- const newSize = getProperty(data, "data.traits.size");
- if ( newSize && (newSize !== getProperty(this.data, "data.traits.size")) ) {
- let size = CONFIG.SW5E.tokenSizes[newSize];
- if ( this.isToken ) this.token.update({height: size, width: size});
- else if ( !data["token.width"] && !hasProperty(data, "token.width") ) {
- data["token.height"] = size;
- data["token.width"] = size;
- }
- }
-
- // Reset death save counters
- if ( (this.data.data.attributes.hp.value <= 0) && (getProperty(data, "data.attributes.hp.value") > 0) ) {
- setProperty(data, "data.attributes.death.success", 0);
- setProperty(data, "data.attributes.death.failure", 0);
- }
-
- // Perform the update
- return super.update(data, options);
- }
-
- /* -------------------------------------------- */
-
- /** @override */
- async createEmbeddedEntity(embeddedName, itemData, options={}) {
-
- // Pre-creation steps for owned items
- if ( embeddedName === "OwnedItem" ) this._preCreateOwnedItem(itemData, options);
-
- // Standard embedded entity creation
- return super.createEmbeddedEntity(embeddedName, itemData, options);
- }
-
- /* -------------------------------------------- */
-
- /**
- * A temporary shim function which will eventually (in core fvtt version 0.8.0+) be migrated to the new abstraction layer
- * @param itemData
- * @param options
- * @private
- */
- _preCreateOwnedItem(itemData, options) {
- if ( this.data.type === "vehicle" ) return;
- const isNPC = this.data.type === 'npc';
- let initial = {};
- switch ( itemData.type ) {
-
- case "weapon":
- if ( getProperty(itemData, "data.equipped") === undefined ) {
- initial["data.equipped"] = isNPC; // NPCs automatically equip weapons
- }
- if ( getProperty(itemData, "data.proficient") === undefined ) {
- if ( isNPC ) {
- initial["data.proficient"] = true; // NPCs automatically have equipment proficiency
- } else {
- const weaponProf = {
- "natural": true,
- "simpleVW": "sim",
- "simpleB": "sim",
- "simpleLW": "sim",
- "martialVW": "mar",
- "martialB": "mar",
- "martialLW": "mar"
- }[itemData.data?.weaponType]; // Player characters check proficiency
- const actorWeaponProfs = this.data.data.traits?.weaponProf?.value || [];
- const hasWeaponProf = (weaponProf === true) || actorWeaponProfs.includes(weaponProf);
- initial["data.proficient"] = hasWeaponProf;
- }
- }
- break;
-
- case "equipment":
- if ( getProperty(itemData, "data.equipped") === undefined ) {
- initial["data.equipped"] = isNPC; // NPCs automatically equip equipment
- }
- if ( getProperty(itemData, "data.proficient") === undefined ) {
- if ( isNPC ) {
- initial["data.proficient"] = true; // NPCs automatically have equipment proficiency
- } else {
- const armorProf = {
- "natural": true,
- "clothing": true,
- "light": "lgt",
- "medium": "med",
- "heavy": "hvy",
- "shield": "shl"
- }[itemData.data?.armor?.type]; // Player characters check proficiency
- const actorArmorProfs = this.data.data.traits?.armorProf?.value || [];
- const hasEquipmentProf = (armorProf === true) || actorArmorProfs.includes(armorProf);
- initial["data.proficient"] = hasEquipmentProf;
- }
- }
- break;
-
- case "power":
- initial["data.prepared"] = true; // automatically prepare powers for everyone
- break;
- }
- mergeObject(itemData, initial);
- }
-
- /* -------------------------------------------- */
- /* Gameplay Mechanics */
- /* -------------------------------------------- */
-
- /** @override */
- async modifyTokenAttribute(attribute, value, isDelta, isBar) {
- if ( attribute === "attributes.hp" ) {
- const hp = getProperty(this.data.data, attribute);
- const delta = isDelta ? (-1 * value) : (hp.value + hp.temp) - value;
- return this.applyDamage(delta);
- }
- return super.modifyTokenAttribute(attribute, value, isDelta, isBar);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Apply a certain amount of damage or healing to the health pool for Actor
- * @param {number} amount An amount of damage (positive) or healing (negative) to sustain
- * @param {number} multiplier A multiplier which allows for resistance, vulnerability, or healing
- * @return {Promise} A Promise which resolves once the damage has been applied
- */
- async applyDamage(amount=0, multiplier=1) {
- amount = Math.floor(parseInt(amount) * multiplier);
- const hp = this.data.data.attributes.hp;
-
- // Deduct damage from temp HP first
- const tmp = parseInt(hp.temp) || 0;
- const dt = amount > 0 ? Math.min(tmp, amount) : 0;
-
- // Remaining goes to health
- const tmpMax = parseInt(hp.tempmax) || 0;
- const dh = Math.clamped(hp.value - (amount - dt), 0, hp.max + tmpMax);
-
- // Update the Actor
- const updates = {
- "data.attributes.hp.temp": tmp - dt,
- "data.attributes.hp.value": dh
- };
-
- // Delegate damage application to a hook
- // TODO replace this in the future with a better modifyTokenAttribute function in the core
- const allowed = Hooks.call("modifyTokenAttribute", {
- attribute: "attributes.hp",
- value: amount,
- isDelta: false,
- isBar: true
- }, updates);
- return allowed !== false ? this.update(updates) : this;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a Skill Check
- * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
- * @param {string} skillId The skill id (e.g. "ins")
- * @param {Object} options Options which configure how the skill check is rolled
- * @return {Promise} A Promise which resolves to the created Roll instance
- */
- rollSkill(skillId, options={}) {
- const skl = this.data.data.skills[skillId];
- const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
-
- // Compose roll parts and data
- const parts = ["@mod"];
- const data = {mod: skl.mod + skl.prof};
-
- // Ability test bonus
- if ( bonuses.check ) {
- data["checkBonus"] = bonuses.check;
- parts.push("@checkBonus");
- }
-
- // Skill check bonus
- if ( bonuses.skill ) {
- data["skillBonus"] = bonuses.skill;
- parts.push("@skillBonus");
- }
-
- // Add provided extra roll parts now because they will get clobbered by mergeObject below
- if (options.parts?.length > 0) {
- parts.push(...options.parts);
- }
-
- // Reliable Talent applies to any skill check we have full or better proficiency in
- const reliableTalent = (skl.value >= 1 && this.getFlag("sw5e", "reliableTalent"));
-
- // Roll and return
- const rollData = mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.format("SW5E.SkillPromptTitle", {skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId]}),
- halflingLucky: this.getFlag("sw5e", "halflingLucky"),
- reliableTalent: reliableTalent,
- messageData: {"flags.sw5e.roll": {type: "skill", skillId }}
- });
- rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this});
- return d20Roll(rollData);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a generic ability test or saving throw.
- * Prompt the user for input on which variety of roll they want to do.
- * @param {String}abilityId The ability id (e.g. "str")
- * @param {Object} options Options which configure how ability tests or saving throws are rolled
- */
- rollAbility(abilityId, options={}) {
- const label = CONFIG.SW5E.abilities[abilityId];
- new Dialog({
- title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}),
- content: `${game.i18n.format("SW5E.AbilityPromptText", {ability: label})}
`,
- buttons: {
- test: {
- label: game.i18n.localize("SW5E.ActionAbil"),
- callback: () => this.rollAbilityTest(abilityId, options)
- },
- save: {
- label: game.i18n.localize("SW5E.ActionSave"),
- callback: () => this.rollAbilitySave(abilityId, options)
- }
- }
- }).render(true);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll an Ability Test
- * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
- * @param {String} abilityId The ability ID (e.g. "str")
- * @param {Object} options Options which configure how ability tests are rolled
- * @return {Promise} A Promise which resolves to the created Roll instance
- */
- rollAbilityTest(abilityId, options={}) {
- const label = CONFIG.SW5E.abilities[abilityId];
- const abl = this.data.data.abilities[abilityId];
-
- // Construct parts
- const parts = ["@mod"];
- const data = {mod: abl.mod};
-
- // Add feat-related proficiency bonuses
- const feats = this.data.flags.sw5e || {};
- if ( feats.remarkableAthlete && SW5E.characterFlags.remarkableAthlete.abilities.includes(abilityId) ) {
- parts.push("@proficiency");
- data.proficiency = Math.ceil(0.5 * this.data.data.attributes.prof);
- }
- else if ( feats.jackOfAllTrades ) {
- parts.push("@proficiency");
- data.proficiency = Math.floor(0.5 * this.data.data.attributes.prof);
- }
-
- // Add global actor bonus
- const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
- if ( bonuses.check ) {
- parts.push("@checkBonus");
- data.checkBonus = bonuses.check;
- }
-
- // Add provided extra roll parts now because they will get clobbered by mergeObject below
- if (options.parts?.length > 0) {
- parts.push(...options.parts);
- }
-
- // Roll and return
- const rollData = mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}),
- halflingLucky: feats.halflingLucky,
- messageData: {"flags.sw5e.roll": {type: "ability", abilityId }}
- });
- rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this});
- return d20Roll(rollData);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll an Ability Saving Throw
- * Prompt the user for input regarding Advantage/Disadvantage and any Situational Bonus
- * @param {String} abilityId The ability ID (e.g. "str")
- * @param {Object} options Options which configure how ability tests are rolled
- * @return {Promise} A Promise which resolves to the created Roll instance
- */
- rollAbilitySave(abilityId, options={}) {
- const label = CONFIG.SW5E.abilities[abilityId];
- const abl = this.data.data.abilities[abilityId];
-
- // Construct parts
- const parts = ["@mod"];
- const data = {mod: abl.mod};
-
- // Include proficiency bonus
- if ( abl.prof > 0 ) {
- parts.push("@prof");
- data.prof = abl.prof;
- }
-
- // Include a global actor ability save bonus
- const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
- if ( bonuses.save ) {
- parts.push("@saveBonus");
- data.saveBonus = bonuses.save;
- }
-
- // Add provided extra roll parts now because they will get clobbered by mergeObject below
- if (options.parts?.length > 0) {
- parts.push(...options.parts);
- }
-
- // Roll and return
- const rollData = mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}),
- halflingLucky: this.getFlag("sw5e", "halflingLucky"),
- messageData: {"flags.sw5e.roll": {type: "save", abilityId }}
- });
- rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this});
- return d20Roll(rollData);
- }
-
- /* -------------------------------------------- */
-
- /**
- * Perform a death saving throw, rolling a d20 plus any global save bonuses
- * @param {Object} options Additional options which modify the roll
- * @return {Promise} A Promise which resolves to the Roll instance
- */
- async rollDeathSave(options={}) {
-
- // Display a warning if we are not at zero HP or if we already have reached 3
- const death = this.data.data.attributes.death;
- if ( (this.data.data.attributes.hp.value > 0) || (death.failure >= 3) || (death.success >= 3)) {
- ui.notifications.warn(game.i18n.localize("SW5E.DeathSaveUnnecessary"));
- return null;
- }
-
- // Evaluate a global saving throw bonus
- const parts = [];
- const data = {};
- const speaker = options.speaker || ChatMessage.getSpeaker({actor: this});
-
- // Include a global actor ability save bonus
- const bonuses = getProperty(this.data.data, "bonuses.abilities") || {};
- if ( bonuses.save ) {
- parts.push("@saveBonus");
- data.saveBonus = bonuses.save;
- }
-
- // Evaluate the roll
- const rollData = mergeObject(options, {
- parts: parts,
- data: data,
- title: game.i18n.localize("SW5E.DeathSavingThrow"),
- speaker: speaker,
- halflingLucky: this.getFlag("sw5e", "halflingLucky"),
- targetValue: 10,
- messageData: {"flags.sw5e.roll": {type: "death"}}
- });
- rollData.speaker = speaker;
- const roll = await d20Roll(rollData);
- if ( !roll ) return null;
-
- // Take action depending on the result
- const success = roll.total >= 10;
- const d20 = roll.dice[0].total;
-
- // Save success
- if ( success ) {
- let successes = (death.success || 0) + 1;
-
- // Critical Success = revive with 1hp
- if ( d20 === 20 ) {
- await this.update({
- "data.attributes.death.success": 0,
- "data.attributes.death.failure": 0,
- "data.attributes.hp.value": 1
- });
- await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveCriticalSuccess", {name: this.name}), speaker});
- }
-
- // 3 Successes = survive and reset checks
- else if ( successes === 3 ) {
- await this.update({
- "data.attributes.death.success": 0,
- "data.attributes.death.failure": 0
- });
- await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveSuccess", {name: this.name}), speaker});
- }
-
- // Increment successes
- else await this.update({"data.attributes.death.success": Math.clamped(successes, 0, 3)});
- }
-
- // Save failure
- else {
- let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1);
- await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)});
- if ( failures >= 3 ) { // 3 Failures = death
- await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveFailure", {name: this.name}), speaker});
- }
- }
-
- // Return the rolled result
- return roll;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier
- * @param {string} [denomination] The hit denomination of hit die to roll. Example "d8".
- * If no denomination is provided, the first available HD will be used
- * @param {boolean} [dialog] Show a dialog prompt for configuring the hit die roll?
- * @return {Promise} The created Roll instance, or null if no hit die was rolled
- */
- async rollHitDie(denomination, {dialog=true}={}) {
-
- // If no denomination was provided, choose the first available
- let cls = null;
- if ( !denomination ) {
- cls = this.itemTypes.class.find(c => c.data.data.hitDiceUsed < c.data.data.levels);
- if ( !cls ) return null;
- denomination = cls.data.data.hitDice;
- }
-
- // Otherwise locate a class (if any) which has an available hit die of the requested denomination
- else {
- cls = this.items.find(i => {
- const d = i.data.data;
- return (d.hitDice === denomination) && ((d.hitDiceUsed || 0) < (d.levels || 1));
- });
- }
-
- // If no class is available, display an error notification
- if ( !cls ) {
- ui.notifications.error(game.i18n.format("SW5E.HitDiceWarn", {name: this.name, formula: denomination}));
- return null;
- }
-
- // Prepare roll data
- const parts = [`1${denomination}`, "@abilities.con.mod"];
- const title = game.i18n.localize("SW5E.HitDiceRoll");
- const rollData = duplicate(this.data.data);
-
- // Call the roll helper utility
- const roll = await damageRoll({
- event: new Event("hitDie"),
- parts: parts,
- data: rollData,
- title: title,
- speaker: ChatMessage.getSpeaker({actor: this}),
- allowcritical: false,
- fastForward: !dialog,
- dialogOptions: {width: 350},
- messageData: {"flags.sw5e.roll": {type: "hitDie"}}
- });
- if ( !roll ) return null;
-
- // Adjust actor data
- await cls.update({"data.hitDiceUsed": cls.data.data.hitDiceUsed + 1});
- const hp = this.data.data.attributes.hp;
- const dhp = Math.min(hp.max + (hp.tempmax ?? 0) - hp.value, roll.total);
- await this.update({"data.attributes.hp.value": hp.value + dhp});
- return roll;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a hull die of the appropriate type, gaining hull points equal to the die roll plus your CON modifier
- * @param {string} [denomination] The hit denomination of hull die to roll. Example "d8".
- * If no denomination is provided, the first available HD will be used
- * @param {string} [numDice] How many damage dice to roll?
- * @param {string} [keep] Which dice to keep? Example "kh1".
- * @param {boolean} [dialog] Show a dialog prompt for configuring the hull die roll?
- * @return {Promise} The created Roll instance, or null if no hull die was rolled
- */
- async rollHullDie(denomination, numDice="1", keep="",{dialog=true}={}) {
-
- // If no denomination was provided, choose the first available
- let sship = null;
- if ( !denomination ) {
- sship = this.itemTypes.class.find(s => s.data.data.hullDiceUsed < (s.data.data.tier + s.data.data.hullDiceStart));
- if ( !sship ) return null;
- denomination = sship.data.data.hullDice;
- }
-
- // Otherwise locate a starship (if any) which has an available hit die of the requested denomination
- else {
- sship = this.items.find(i => {
- const d = i.data.data;
- return (d.hullDice === denomination) && ((d.hitDiceUsed || 0) < ((d.tier || 0) + d.hullDiceStart));
- });
- }
-
- // If no class is available, display an error notification
- if ( !sship ) {
- ui.notifications.error(game.i18n.format("SW5E.HullDiceWarn", {name: this.name, formula: denomination}));
- return null;
- }
-
- // Prepare roll data
- const parts = [`${numDice}${denomination}${keep}`, "@abilities.con.mod"];
- const title = game.i18n.localize("SW5E.HullDiceRoll");
- const rollData = duplicate(this.data.data);
-
- // Call the roll helper utility
- const roll = await damageRoll({
- event: new Event("hitDie"),
- parts: parts,
- data: rollData,
- title: title,
- speaker: ChatMessage.getSpeaker({actor: this}),
- allowcritical: false,
- fastForward: !dialog,
- dialogOptions: {width: 350},
- messageData: {"flags.sw5e.roll": {type: "hullDie"}}
- });
- if ( !roll ) return null;
-
- // Adjust actor data
- await sship.update({"data.hullDiceUsed": sship.data.data.hullDiceUsed + 1});
- const hp = this.data.data.attributes.hp;
- const dhp = Math.min(hp.max - hp.value, roll.total);
- await this.update({"data.attributes.hp.value": hp.value + dhp});
- return roll;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a hull die of the appropriate type, gaining hull points equal to the die roll plus your CON modifier
- * @return {Promise} The created Roll instance, or null if no hull die was rolled
- */
- async rollHullDieCheck() {
-
- // If no denomination was provided, choose the first available
- let sship = null;
- if ( !denomination ) {
- sship = this.itemTypes.class.find(s => s.data.data.hullDiceUsed < (s.data.data.tier + s.data.data.hullDiceStart));
- if ( !sship ) return null;
- denomination = sship.data.data.hullDice;
- }
-
- // Otherwise locate a starship (if any) which has an available hit die of the requested denomination
- else {
- sship = this.items.find(i => {
- const d = i.data.data;
- return (d.hullDice === denomination) && ((d.hitDiceUsed || 0) < ((d.tier || 0) + d.hullDiceStart));
- });
- }
-
- // If no class is available, display an error notification
- if ( !sship ) {
- ui.notifications.error(game.i18n.format("SW5E.HullDiceWarn", {name: this.name, formula: denomination}));
- return null;
- }
-
- // Prepare roll data
- const parts = [`${numDice}${denomination}${keep}`, "@abilities.con.mod"];
- const title = game.i18n.localize("SW5E.HullDiceRoll");
- const rollData = duplicate(this.data.data);
-
- // Call the roll helper utility
- const roll = await damageRoll({
- event: new Event("hitDie"),
- parts: parts,
- data: rollData,
- title: title,
- speaker: ChatMessage.getSpeaker({actor: this}),
- allowcritical: false,
- fastForward: !dialog,
- dialogOptions: {width: 350},
- messageData: {"flags.sw5e.roll": {type: "hullDie"}}
- });
- if ( !roll ) return null;
-
- // Adjust actor data
- await sship.update({"data.hullDiceUsed": sship.data.data.hullDiceUsed + 1});
- const hp = this.data.data.attributes.hp;
- const dhp = Math.min(hp.max - hp.value, roll.total);
- await this.update({"data.attributes.hp.value": hp.value + dhp});
- return roll;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Roll a shield die of the appropriate type, gaining shield points equal to the die roll
- * multiplied by the shield regeneration coefficient
- * @param {string} [denomination] The denomination of shield die to roll. Example "d8".
- * If no denomination is provided, the first available SD will be used
- * @param {boolean} [natural] Natural ship shield regeneration (true) or user action (false)?
- * @param {string} [numDice] How many damage dice to roll?
- * @param {string} [keep] Which dice to keep? Example "kh1".
- * @param {boolean} [dialog] Show a dialog prompt for configuring the shield die roll?
- * @return {Promise} The created Roll instance, or null if no shield die was rolled
- */
- async rollShieldDie(denomination, natural=false, numDice="1", keep="", {dialog=true}={}) {
-
- // If no denomination was provided, choose the first available
- let sship = null;
- if ( !denomination ) {
- sship = this.itemTypes.class.find(s => s.data.data.shldDiceUsed < (s.data.data.tier + s.data.data.shldDiceStart));
- if ( !sship ) return null;
- denomination = sship.data.data.shldDice;
- }
-
- // Otherwise locate a starship (if any) which has an available hit die of the requested denomination
- else {
- sship = this.items.find(i => {
- const d = i.data.data;
- return (d.shldDice === denomination) && ((d.shldDiceUsed || 0) < ((d.tier || 0) + d.shldDiceStart));
- });
- }
-
- // If no starship is available, display an error notification
- if ( !sship ) {
- ui.notifications.error(game.i18n.format("SW5E.ShldDiceWarn", {name: this.name, formula: denomination}));
- return null;
- }
-
- // if natural regeneration roll max
- if (natural) {
- numdice = denomination.substring(1);
- denomination = "";
- keep = "";
- }
-
- // Prepare roll data
- const parts = [`${numDice}${denomination}${keep} * @attributes.regenRate`];
- const title = game.i18n.localize("SW5E.ShieldDiceRoll");
- const rollData = duplicate(this.data.data);
-
- // Call the roll helper utility
- roll = await damageRoll({
- event: new Event("shldDie"),
- parts: parts,
- data: rollData,
- title: title,
- speaker: ChatMessage.getSpeaker({actor: this}),
- allowcritical: false,
- fastForward: !dialog,
- dialogOptions: {width: 350},
- messageData: {"flags.sw5e.roll": {type: "shldDie"}}
- });
- if ( !roll ) return null;
-
- // Adjust actor data
- await sship.update({"data.shldDiceUsed": sship.data.data.shldDiceUsed + 1});
- const hp = this.data.data.attributes.hp;
- const dhp = Math.min(hp.tempmax - hp.temp, roll.total);
- await this.update({"data.attributes.hp.temp": hp.temp + dhp});
- return roll;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Cause this Actor to take a Short Rest and regain all Tech Points
- * During a Short Rest resources and limited item uses may be recovered
- * @param {boolean} dialog Present a dialog window which allows for rolling hit dice as part of the Short Rest
- * @param {boolean} chat Summarize the results of the rest workflow as a chat message
- * @param {boolean} autoHD Automatically spend Hit Dice if you are missing 3 or more hit points
- * @param {boolean} autoHDThreshold A number of missing hit points which would trigger an automatic HD roll
- * @return {Promise} A Promise which resolves once the short rest workflow has completed
- */
- async shortRest({dialog=true, chat=true, autoHD=false, autoHDThreshold=3}={}) {
-
- // Take note of the initial hit points and number of hit dice the Actor has
- const hp = this.data.data.attributes.hp;
- const hd0 = this.data.data.attributes.hd;
- const hp0 = hp.value;
- let newDay = false;
-
- // Display a Dialog for rolling hit dice
- if ( dialog ) {
- try {
- newDay = await ShortRestDialog.shortRestDialog({actor: this, canRoll: hd0 > 0});
- } catch(err) {
- return;
- }
- }
-
- // Automatically spend hit dice
- else if ( autoHD ) {
- while ( (hp.value + autoHDThreshold) <= hp.max ) {
- const r = await this.rollHitDie(undefined, {dialog: false});
- if ( r === null ) break;
- }
- }
-
- // Note the change in HP and HD and TP which occurred
- const dhd = this.data.data.attributes.hd - hd0;
- const dhp = this.data.data.attributes.hp.value - hp0;
- const dtp = this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value;
-
- // Automatically Retore Tech Points
- this.update({"data.attributes.tech.points.value": this.data.data.attributes.tech.points.max});
-
- // Recover character resources
- const updateData = {};
- for ( let [k, r] of Object.entries(this.data.data.resources) ) {
- if ( r.max && r.sr ) {
- updateData[`data.resources.${k}.value`] = r.max;
- }
- }
-
- // Recover item uses
- const recovery = newDay ? ["sr", "day"] : ["sr"];
- const items = this.items.filter(item => item.data.data.uses && recovery.includes(item.data.data.uses.per));
- const updateItems = items.map(item => {
- return {
- _id: item._id,
- "data.uses.value": item.data.data.uses.max
- };
- });
- await this.updateEmbeddedEntity("OwnedItem", updateItems);
-
- // Display a Chat Message summarizing the rest effects
- if ( chat ) {
-
- // Summarize the rest duration
- let restFlavor;
- switch (game.settings.get("sw5e", "restVariant")) {
- case 'normal': restFlavor = game.i18n.localize("SW5E.ShortRestNormal"); break;
- case 'gritty': restFlavor = game.i18n.localize(newDay ? "SW5E.ShortRestOvernight" : "SW5E.ShortRestGritty"); break;
- case 'epic': restFlavor = game.i18n.localize("SW5E.ShortRestEpic"); break;
- }
-
- // Summarize the health effects
- let srMessage = "SW5E.ShortRestResultShort";
- if ((dhd !== 0) && (dhp !== 0)){
- if (dtp !== 0){
- srMessage = "SW5E.ShortRestResultWithTech";
- }else{
- srMessage = "SW5E.ShortRestResult";
- }
- }else{
- if (dtp !== 0){
- srMessage = "SW5E.ShortRestResultOnlyTech";
- }
- }
-
- // Create a chat message
- ChatMessage.create({
- user: game.user._id,
- speaker: {actor: this, alias: this.name},
- flavor: restFlavor,
- content: game.i18n.format(srMessage, {name: this.name, dice: -dhd, health: dhp, tech: dtp})
- });
- }
-
- // Return data summarizing the rest effects
- return {
- dhd: dhd,
- dhp: dhp,
- dtp: dtp,
- updateData: updateData,
- updateItems: updateItems,
- newDay: newDay
- }
- }
-
- /* -------------------------------------------- */
-
- /**
- * Take a long rest, recovering HP, HD, resources, Force and Power points and power slots
- * @param {boolean} dialog Present a confirmation dialog window whether or not to take a long rest
- * @param {boolean} chat Summarize the results of the rest workflow as a chat message
- * @param {boolean} newDay Whether the long rest carries over to a new day
- * @return {Promise} A Promise which resolves once the long rest workflow has completed
- */
- async longRest({dialog=true, chat=true, newDay=true}={}) {
- const data = this.data.data;
-
- // Maybe present a confirmation dialog
- if ( dialog ) {
- try {
- newDay = await LongRestDialog.longRestDialog({actor: this});
- } catch(err) {
- return;
- }
- }
-
- // Recover hit, tech, and force points to full, and eliminate any existing temporary HP, TP, and FP
- const dhp = data.attributes.hp.max - data.attributes.hp.value;
- const dtp = data.attributes.tech.points.max - data.attributes.tech.points.value;
- const dfp = data.attributes.force.points.max - data.attributes.force.points.value;
- const updateData = {
- "data.attributes.hp.value": data.attributes.hp.max,
- "data.attributes.hp.temp": 0,
- "data.attributes.hp.tempmax": 0,
- "data.attributes.tech.points.value": data.attributes.tech.points.max,
- "data.attributes.tech.points.temp": 0,
- "data.attributes.tech.points.tempmax": 0,
- "data.attributes.force.points.value": data.attributes.force.points.max,
- "data.attributes.force.points.temp": 0,
- "data.attributes.force.points.tempmax": 0
- };
-
- // Recover character resources
- for ( let [k, r] of Object.entries(data.resources) ) {
- if ( r.max && (r.sr || r.lr) ) {
- updateData[`data.resources.${k}.value`] = r.max;
- }
- }
-
- // Recover power slots
- for ( let [k, v] of Object.entries(data.powers) ) {
- updateData[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0);
- }
- for ( let [k, v] of Object.entries(data.powers) ) {
- updateData[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0);
- }
- // Determine the number of hit dice which may be recovered
- let recoverHD = Math.max(Math.floor(data.details.level / 2), 1);
- let dhd = 0;
-
- // Sort classes which can recover HD, assuming players prefer recovering larger HD first.
- const updateItems = this.items.filter(item => item.data.type === "class").sort((a, b) => {
- let da = parseInt(a.data.data.hitDice.slice(1)) || 0;
- let db = parseInt(b.data.data.hitDice.slice(1)) || 0;
- return db - da;
- }).reduce((updates, item) => {
- const d = item.data.data;
- if ( (recoverHD > 0) && (d.hitDiceUsed > 0) ) {
- let delta = Math.min(d.hitDiceUsed || 0, recoverHD);
- recoverHD -= delta;
- dhd += delta;
- updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta});
- }
- return updates;
- }, []);
-
- // Iterate over owned items, restoring uses per day and recovering Hit Dice
- const recovery = newDay ? ["sr", "lr", "day"] : ["sr", "lr"];
- for ( let item of this.items ) {
- const d = item.data.data;
- if ( d.uses && recovery.includes(d.uses.per) ) {
- updateItems.push({_id: item.id, "data.uses.value": d.uses.max});
- }
- else if ( d.recharge && d.recharge.value ) {
- updateItems.push({_id: item.id, "data.recharge.charged": true});
- }
- }
-
- // Perform the updates
- await this.update(updateData);
- if ( updateItems.length ) await this.updateEmbeddedEntity("OwnedItem", updateItems);
-
- // Display a Chat Message summarizing the rest effects
- let restFlavor;
- switch (game.settings.get("sw5e", "restVariant")) {
- case 'normal': restFlavor = game.i18n.localize(newDay ? "SW5E.LongRestOvernight" : "SW5E.LongRestNormal"); break;
- case 'gritty': restFlavor = game.i18n.localize("SW5E.LongRestGritty"); break;
- case 'epic': restFlavor = game.i18n.localize("SW5E.LongRestEpic"); break;
- }
-
- // Determine the chat message to display
- if ( chat ) {
- let lrMessage = "SW5E.LongRestResult";
- if (dhp !== 0) lrMessage += "HP";
- if (dfp !== 0) lrMessage += "FP";
- if (dtp !== 0) lrMessage += "TP";
- if (dhd !== 0) lrMessage += "HD";
- ChatMessage.create({
- user: game.user._id,
- speaker: {actor: this, alias: this.name},
- flavor: restFlavor,
- content: game.i18n.format(lrMessage, {name: this.name, health: dhp, tech: dtp, force: dfp, dice: dhd})
- });
- }
-
- // Return data summarizing the rest effects
- return {
- dhd: dhd,
- dhp: dhp,
- dtp: dtp,
- dfp: dfp,
- updateData: updateData,
- updateItems: updateItems,
- newDay: newDay
- }
- }
-
- /* -------------------------------------------- */
-
-
- /**
- * Deploy an Actor into this one.
- *
- * @param {Actor} target The Actor to be deployed.
- * @param {boolean} [coord] Deploy as Coordinator
- * @param {boolean} [gunner] Deploy as Gunner
- * @param {boolean} [mech] Deploy as Mechanic
- * @param {boolean} [oper] Deploy as Operator
- * @param {boolean} [pilot] Deploy as Pilot
- * @param {boolean} [tech] Deploy as Technician
- * @param {boolean} [crew] Deploy as Crew
- * @param {boolean} [pass] Deploy as Passenger
- */
- async deployInto(target, { coord=false, gunner=false, mech=false, oper=false,
- pilot=false, tech=false, crew=false, pass=false}={}) {
-
- // Get the starship Actor data and the new char data
- const sship = duplicate(this.toJSON());
- const ssDeploy = sship.data.attributes.deployment;
- const char = target;
- const charUUID = char.uuid;
- const charName = char.data.name;
- const charRank = char.data.data.attributes.rank;
- let charProf = 0;
- if (charRank.total > 0) {
- charProf = char.data.data.attributes.prof;
- }
-
- if (coord){
- ssDeploy.coord.uuid = charUUID;
- ssDeploy.coord.name = charName;
- ssDeploy.coord.rank = charRank.coord;
- ssDeploy.coord.prof = charProf;
- }
-
- if (gunner){
- ssDeploy.gunner.uuid = charUUID;
- ssDeploy.gunner.name = charName;
- ssDeploy.gunner.rank = charRank.gunner;
- ssDeploy.gunner.prof = charProf;
- }
-
- if (mech){
- ssDeploy.mechanic.uuid = charUUID;
- ssDeploy.mechanic.name = charName;
- ssDeploy.mechanic.rank = charRank.mechanic;
- ssDeploy.mechanic.prof = charProf;
- }
-
- if (oper){
- ssDeploy.operator.uuid = charUUID;
- ssDeploy.operator.name = charName;
- ssDeploy.operator.rank = charRank.operator;
- ssDeploy.operator.prof = charProf;
- }
-
- if (pilot){
- ssDeploy.pilot.uuid = charUUID;
- ssDeploy.pilot.name = charName;
- ssDeploy.pilot.rank = charRank.pilot;
- ssDeploy.pilot.prof = charProf;
- }
-
- if (tech){
- ssDeploy.technician.uuid = charUUID;
- ssDeploy.technician.name = charName;
- ssDeploy.technician.rank = charRank.technician;
- ssDeploy.technician.prof = charProf;
- }
-
- if (crew){
- ssDeploy.crew.push({"uuid": charUUID, "name": charName, "rank": charRank, "prof": charProf});
- }
-
- if (pass){
- ssDeploy.passenger.push({"uuid": charUUID, "name": charName, "rank": charRank, "prof": charProf});
- }
- this.update({"data.attributes.deployment": ssDeploy});
- }
-
-
- /**
- * Transform this Actor into another one.
- *
- * @param {Actor} target The target Actor.
- * @param {boolean} [keepPhysical] Keep physical abilities (str, dex, con)
- * @param {boolean} [keepMental] Keep mental abilities (int, wis, cha)
- * @param {boolean} [keepSaves] Keep saving throw proficiencies
- * @param {boolean} [keepSkills] Keep skill proficiencies
- * @param {boolean} [mergeSaves] Take the maximum of the save proficiencies
- * @param {boolean} [mergeSkills] Take the maximum of the skill proficiencies
- * @param {boolean} [keepClass] Keep proficiency bonus
- * @param {boolean} [keepFeats] Keep features
- * @param {boolean} [keepPowers] Keep powers
- * @param {boolean} [keepItems] Keep items
- * @param {boolean} [keepBio] Keep biography
- * @param {boolean} [keepVision] Keep vision
- * @param {boolean} [transformTokens] Transform linked tokens too
- */
- async transformInto(target, { keepPhysical=false, keepMental=false, keepSaves=false, keepSkills=false,
- mergeSaves=false, mergeSkills=false, keepClass=false, keepFeats=false, keepPowers=false,
- keepItems=false, keepBio=false, keepVision=false, transformTokens=true}={}) {
-
- // Ensure the player is allowed to polymorph
- const allowed = game.settings.get("sw5e", "allowPolymorphing");
- if ( !allowed && !game.user.isGM ) {
- return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphWarn"));
- }
-
- // Get the original Actor data and the new source data
- const o = duplicate(this.toJSON());
- o.flags.sw5e = o.flags.sw5e || {};
- o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves};
- const source = duplicate(target.toJSON());
-
- // Prepare new data to merge from the source
- const d = {
- type: o.type, // Remain the same actor type
- name: `${o.name} (${source.name})`, // Append the new shape to your old name
- data: source.data, // Get the data model of your new form
- items: source.items, // Get the items of your new form
- effects: o.effects.concat(source.effects), // Combine active effects from both forms
- token: source.token, // New token configuration
- img: source.img, // New appearance
- permission: o.permission, // Use the original actor permissions
- folder: o.folder, // Be displayed in the same sidebar folder
- flags: o.flags // Use the original actor flags
- };
-
- // Additional adjustments
- delete d.data.resources; // Don't change your resource pools
- delete d.data.currency; // Don't lose currency
- delete d.data.bonuses; // Don't lose global bonuses
- delete d.token.actorId; // Don't reference the old actor ID
- d.token.actorLink = o.token.actorLink; // Keep your actor link
- d.token.name = d.name; // Token name same as actor name
- d.data.details.alignment = o.data.details.alignment; // Don't change alignment
- d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level
- d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration
- d.data.powers = o.data.powers; // Keep power slots
-
- // Handle wildcard
- if ( source.token.randomImg ) {
- const images = await target.getTokenImages();
- d.token.img = images[Math.floor(Math.random() * images.length)];
- }
-
- // Keep Token configurations
- const tokenConfig = ["displayName", "vision", "actorLink", "disposition", "displayBars", "bar1", "bar2"];
- if ( keepVision ) {
- tokenConfig.push(...['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle']);
- }
- for ( let c of tokenConfig ) {
- d.token[c] = o.token[c];
- }
-
- // Transfer ability scores
- const abilities = d.data.abilities;
- for ( let k of Object.keys(abilities) ) {
- const oa = o.data.abilities[k];
- const prof = abilities[k].proficient;
- if ( keepPhysical && ["str", "dex", "con"].includes(k) ) abilities[k] = oa;
- else if ( keepMental && ["int", "wis", "cha"].includes(k) ) abilities[k] = oa;
- if ( keepSaves ) abilities[k].proficient = oa.proficient;
- else if ( mergeSaves ) abilities[k].proficient = Math.max(prof, oa.proficient);
- }
-
- // Transfer skills
- if ( keepSkills ) d.data.skills = o.data.skills;
- else if ( mergeSkills ) {
- for ( let [k, s] of Object.entries(d.data.skills) ) {
- s.value = Math.max(s.value, o.data.skills[k].value);
- }
- }
-
- // Keep specific items from the original data
- d.items = d.items.concat(o.items.filter(i => {
- if ( i.type === "class" ) return keepClass;
- else if ( i.type === "feat" ) return keepFeats;
- else if ( i.type === "power" ) return keepPowers;
- else return keepItems;
- }));
-
- // Transfer classes for NPCs
- if (!keepClass && d.data.details.cr) {
- d.items.push({
- type: 'class',
- name: game.i18n.localize('SW5E.PolymorphTmpClass'),
- data: { levels: d.data.details.cr }
- });
- }
-
- // Keep biography
- if (keepBio) d.data.details.biography = o.data.details.biography;
-
- // Keep senses
- if (keepVision) d.data.traits.senses = o.data.traits.senses;
-
- // Set new data flags
- if ( !this.isPolymorphed || !d.flags.sw5e.originalActor ) d.flags.sw5e.originalActor = this.id;
- d.flags.sw5e.isPolymorphed = true;
-
- // Update unlinked Tokens in place since they can simply be re-dropped from the base actor
- if (this.isToken) {
- const tokenData = d.token;
- tokenData.actorData = d;
- delete tokenData.actorData.token;
- return this.token.update(tokenData);
- }
-
- // Update regular Actors by creating a new Actor with the Polymorphed data
- await this.sheet.close();
- Hooks.callAll('sw5e.transformActor', this, target, d, {
- keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills,
- keepClass, keepFeats, keepPowers, keepItems, keepBio, keepVision, transformTokens
- });
- const newActor = await this.constructor.create(d, {renderSheet: true});
-
- // Update placed Token instances
- if ( !transformTokens ) return;
- const tokens = this.getActiveTokens(true);
- const updates = tokens.map(t => {
- const newTokenData = duplicate(d.token);
- if ( !t.data.actorLink ) newTokenData.actorData = newActor.data;
- newTokenData._id = t.data._id;
- newTokenData.actorId = newActor.id;
- return newTokenData;
- });
- return canvas.scene?.updateEmbeddedEntity("Token", updates);
- }
-
- /* -------------------------------------------- */
-
- /**
- * If this actor was transformed with transformTokens enabled, then its
- * active tokens need to be returned to their original state. If not, then
- * we can safely just delete this actor.
- */
- async revertOriginalForm() {
- if ( !this.isPolymorphed ) return;
- if ( !this.owner ) {
- return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn"));
- }
-
- // If we are reverting an unlinked token, simply replace it with the base actor prototype
- if ( this.isToken ) {
- const baseActor = game.actors.get(this.token.data.actorId);
- const prototypeTokenData = duplicate(baseActor.token);
- prototypeTokenData.actorData = null;
- return this.token.update(prototypeTokenData);
- }
-
- // Obtain a reference to the original actor
- const original = game.actors.get(this.getFlag('sw5e', 'originalActor'));
- if ( !original ) return;
-
- // Get the Tokens which represent this actor
- if ( canvas.ready ) {
- const tokens = this.getActiveTokens(true);
- const tokenUpdates = tokens.map(t => {
- const tokenData = duplicate(original.data.token);
- tokenData._id = t.id;
- tokenData.actorId = original.id;
- return tokenData;
- });
- canvas.scene.updateEmbeddedEntity("Token", tokenUpdates);
- }
-
- // Delete the polymorphed Actor and maybe re-render the original sheet
- const isRendered = this.sheet.rendered;
- if ( game.user.isGM ) await this.delete();
- original.sheet.render(isRendered);
- return original;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Add additional system-specific sidebar directory context menu options for SW5e Actor entities
- * @param {jQuery} html The sidebar HTML
- * @param {Array} entryOptions The default array of context menu options
- */
- static addDirectoryContextOptions(html, entryOptions) {
- entryOptions.push({
- name: 'SW5E.PolymorphRestoreTransformation',
- icon: ' ',
- callback: li => {
- const actor = game.actors.get(li.data('entityId'));
- return actor.revertOriginalForm();
- },
- condition: li => {
- const allowed = game.settings.get("sw5e", "allowPolymorphing");
- if ( !allowed && !game.user.isGM ) return false;
- const actor = game.actors.get(li.data('entityId'));
- return actor && actor.isPolymorphed;
- }
- });
- }
-
- /* -------------------------------------------- */
- /* DEPRECATED METHODS */
- /* -------------------------------------------- */
-
- /**
- * @deprecated since sw5e 0.97
- */
- getPowerDC(ability) {
- console.warn(`The Actor5e#getPowerDC(ability) method has been deprecated in favor of Actor5e#data.data.abilities[ability].dc`);
- return this.data.data.abilities[ability]?.dc;
- }
-
- /* -------------------------------------------- */
-
- /**
- * Cast a Power, consuming a power slot of a certain level
- * @param {Item5e} item The power being cast by the actor
- * @param {Event} event The originating user interaction which triggered the cast
- * @deprecated since sw5e 1.2.0
- */
- async usePower(item, {configureDialog=true}={}) {
- console.warn(`The Actor5e#usePower method has been deprecated in favor of Item5e#roll`);
- if ( item.data.type !== "power" ) throw new Error("Wrong Item type");
- return item.roll();
- }
-}
\ No newline at end of file
diff --git a/module/actor/sheets/newSheet/base.js b/module/actor/sheets/newSheet/base.js
index d6636c0b..476c3b73 100644
--- a/module/actor/sheets/newSheet/base.js
+++ b/module/actor/sheets/newSheet/base.js
@@ -560,7 +560,7 @@ export default class ActorSheet5e extends ActorSheet {
/** @override */
async _onDropActor(event, data) {
- const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get('sw5e', 'allowPolymorphing'));
+ const canPolymorph = game.user.isGM || (this.actor.data.type === "starship") || (this.actor.owner && game.settings.get('sw5e', 'allowPolymorphing'));
if ( !canPolymorph ) return false;
// Get the target actor
@@ -585,49 +585,76 @@ export default class ActorSheet5e extends ActorSheet {
};
// Create and render the Dialog
- return new Dialog({
- title: game.i18n.localize('SW5E.PolymorphPromptTitle'),
- content: {
- options: game.settings.get('sw5e', 'polymorphSettings'),
- i18n: SW5E.polymorphSettings,
- isToken: this.actor.isToken
- },
- default: 'accept',
- buttons: {
- accept: {
- icon: ' ',
- label: game.i18n.localize('SW5E.PolymorphAcceptSettings'),
- callback: html => this.actor.transformInto(sourceActor, rememberOptions(html))
+
+ if (this.actor.data.type === "starship") {
+ return new Dialog({
+ title: game.i18n.localize('SW5E.DeploymentPromptTitle') + " " + sourceActor.data.name + " into " + this.actor.data.name,
+ content: {
+ i18n: SW5E.deploymentTypes,
+ isToken: this.actor.isToken
},
- wildshape: {
- icon: ' ',
- label: game.i18n.localize('SW5E.PolymorphWildShape'),
- callback: html => this.actor.transformInto(sourceActor, {
- keepBio: true,
- keepClass: true,
- keepMental: true,
- mergeSaves: true,
- mergeSkills: true,
- transformTokens: rememberOptions(html).transformTokens
- })
- },
- polymorph: {
- icon: ' ',
- label: game.i18n.localize('SW5E.Polymorph'),
- callback: html => this.actor.transformInto(sourceActor, {
- transformTokens: rememberOptions(html).transformTokens
- })
- },
- cancel: {
- icon: ' ',
- label: game.i18n.localize('Cancel')
+ default: 'accept',
+ buttons: {
+ deploy: {
+ icon: ' ',
+ label: game.i18n.localize('SW5E.DeploymentAcceptSettings'),
+ callback: html => this.actor.deployInto(sourceActor, rememberOptions(html))
+ },
+ cancel: {
+ icon: ' ',
+ label: game.i18n.localize('Cancel')
+ }
}
- }
- }, {
- classes: ['dialog', 'sw5e'],
- width: 600,
- template: 'systems/sw5e/templates/apps/polymorph-prompt.html'
- }).render(true);
+ }, {
+ classes: ['dialog', 'sw5e'],
+ width: 600,
+ template: 'systems/sw5e/templates/apps/deployment-prompt.html'
+ }).render(true);
+ } else {
+ return new Dialog({
+ title: game.i18n.localize('SW5E.PolymorphPromptTitle'),
+ content: {
+ options: game.settings.get('sw5e', 'polymorphSettings'),
+ i18n: SW5E.polymorphSettings,
+ isToken: this.actor.isToken
+ },
+ default: 'accept',
+ buttons: {
+ accept: {
+ icon: ' ',
+ label: game.i18n.localize('SW5E.PolymorphAcceptSettings'),
+ callback: html => this.actor.transformInto(sourceActor, rememberOptions(html))
+ },
+ wildshape: {
+ icon: ' ',
+ label: game.i18n.localize('SW5E.PolymorphWildShape'),
+ callback: html => this.actor.transformInto(sourceActor, {
+ keepBio: true,
+ keepClass: true,
+ keepMental: true,
+ mergeSaves: true,
+ mergeSkills: true,
+ transformTokens: rememberOptions(html).transformTokens
+ })
+ },
+ polymorph: {
+ icon: ' ',
+ label: game.i18n.localize('SW5E.Polymorph'),
+ callback: html => this.actor.transformInto(sourceActor, {
+ transformTokens: rememberOptions(html).transformTokens
+ })
+ },
+ cancel: {
+ icon: ' ',
+ label: game.i18n.localize('Cancel')
+ }
+ }
+ }, {
+ classes: ['dialog', 'sw5e'],
+ width: 600,
+ template: 'systems/sw5e/templates/apps/polymorph-prompt.html'
+ }).render(true);
+ }
}
/* -------------------------------------------- */
diff --git a/module/actor/sheets/newSheet/starship.js b/module/actor/sheets/newSheet/starship.js
index e27354d6..145a87e8 100644
--- a/module/actor/sheets/newSheet/starship.js
+++ b/module/actor/sheets/newSheet/starship.js
@@ -17,6 +17,7 @@ export default class ActorSheet5eStarship extends ActorSheet5e {
return mergeObject(super.defaultOptions, {
classes: ["sw5e", "sheet", "actor", "starship"],
width: 800,
+ height: 775,
tabs: [{
navSelector: ".root-tabs",
contentSelector: ".sheet-body",
@@ -135,6 +136,11 @@ export default class ActorSheet5eStarship extends ActorSheet5e {
activateListeners(html) {
super.activateListeners(html);
html.find(".health .rollable").click(this._onRollHPFormula.bind(this));
+ html.find('.refuel').click(this._onIncrementFuelLevel.bind(this));
+ html.find('.burnfuel').click(this._onDecrementFuelLevel.bind(this));
+ html.find('#engineslidervalue')[0].addEventListener('input', this._engineSliderUpdate.bind(this));
+ html.find('#shieldslidervalue')[0].addEventListener('input', this._shieldSliderUpdate.bind(this));
+ html.find('#weaponslidervalue')[0].addEventListener('input', this._weaponSliderUpdate.bind(this));
}
/* -------------------------------------------- */
@@ -152,4 +158,96 @@ export default class ActorSheet5eStarship extends ActorSheet5e {
AudioHelper.play({src: CONFIG.sounds.dice});
this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp});
}
+
+ /* -------------------------------------------- */
+
+ /**
+ * Handle refueling a starship
+ * @param {Event} event The original click event
+ * @private
+ */
+ _onIncrementFuelLevel(event) {
+ // event.preventDefault();
+ // const fuelcaparray = this.actor.data.effects.changes;
+ // var fuelcappos = fuelcaparray.indexOf('fuel.cap');
+ // const refuel = this.actor.data.effect.changes[fuelcappos].value;
+ this.actor.update({"data.attributes.fuel.value": this.actor.data.data.attributes.fuel.cap});
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Handle a starship burning fuel
+ * @param {Event} event The original click event
+ * @private
+ */
+ _onDecrementFuelLevel(event) {
+ // event.preventDefault();
+ // const fuelcaparray = this.actor.data.effects.changes;
+ // var fuelcappos = fuelcaparray.indexOf('fuel.cap');
+ // const refuel = this.actor.data.effect.changes[fuelcappos].value;
+ this.actor.update({"data.attributes.fuel.value": this.actor.data.data.attributes.fuel.value - 1});
+ }
+ _engineSliderUpdate(input) {
+ var symbol;
+ var coefficient;
+ switch(input.target.value) {
+ case "0":
+ symbol = "↓";
+ coefficient = 0.5;
+ break;
+ case "1":
+ symbol = "=";
+ coefficient = 1;
+ break;
+ case "2":
+ symbol = "↑";
+ coefficient = 2;
+ };
+ let slideroutput = symbol;
+ document.querySelector('#engineslideroutput').value = slideroutput;
+ this.actor.update({"data.attributes.power.routing.engines": coefficient});
+ }
+
+ _shieldSliderUpdate(input) {
+ var symbol;
+ var coefficient;
+ switch(input.target.value) {
+ case "0":
+ symbol = "↓";
+ coefficient = 0.5;
+ break;
+ case "1":
+ symbol = "=";
+ coefficient = 1;
+ break;
+ case "2":
+ symbol = "↑";
+ coefficient = 2;
+ };
+ let slideroutput = symbol;
+ document.querySelector('#shieldslideroutput').value = slideroutput;
+ this.actor.update({"data.attributes.power.routing.shields": coefficient});
+ }
+
+ _weaponSliderUpdate(input) {
+ var symbol;
+ var coefficient;
+ switch(input.target.value) {
+ case "0":
+ symbol = "↓";
+ coefficient = 0.5;
+ break;
+ case "1":
+ symbol = "=";
+ coefficient = 1;
+ break;
+ case "2":
+ symbol = "↑";
+ coefficient = 2;
+ };
+ let slideroutput = symbol;
+ document.querySelector('#weaponslideroutput').value = slideroutput;
+ this.actor.update({"data.attributes.power.routing.weapons": coefficient});
+ }
}
diff --git a/module/apps/recharge-rest.js b/module/apps/recharge-rest.js
new file mode 100644
index 00000000..1d61c7a4
--- /dev/null
+++ b/module/apps/recharge-rest.js
@@ -0,0 +1,117 @@
+/**
+ * A helper Dialog subclass for rolling Hit Dice on a recharge rest
+ * @extends {Dialog}
+ */
+export default class RechargeRestDialog extends Dialog {
+ constructor(actor, dialogData={}, options={}) {
+ super(dialogData, options);
+
+ /**
+ * Store a reference to the Actor entity which is resting
+ * @type {Actor}
+ */
+ this.actor = actor;
+
+ /**
+ * Track the most recently used HD denomination for re-rendering the form
+ * @type {string}
+ */
+ this._denom = null;
+ }
+
+ /* -------------------------------------------- */
+
+ /** @override */
+ static get defaultOptions() {
+ return mergeObject(super.defaultOptions, {
+ template: "systems/sw5e/templates/apps/recharge-rest.html",
+ classes: ["sw5e", "dialog"]
+ });
+ }
+
+ /* -------------------------------------------- */
+
+ /** @override */
+ getData() {
+ const data = super.getData();
+
+ // Determine Hull Dice
+ data.availableHD = this.actor.data.items.reduce((hd, item) => {
+ if ( item.type === "starship" ) {
+ const d = item.data;
+ const denom = d.hullDice || "d6";
+ const available = parseInt(d.hullDiceStart || 1) + parseInt(d.tier || 0) - parseInt(d.hullDiceUsed || 0);
+ hd[denom] = denom in hd ? hd[denom] + available : available;
+ }
+ return hd;
+ }, {});
+ data.canRoll = this.actor.data.data.attributes.hull.dice > 0;
+ data.denomination = this._denom;
+
+ // Determine rest type
+ const variant = game.settings.get("sw5e", "restVariant");
+ data.promptNewDay = variant !== "epic"; // It's never a new day when only resting 1 minute
+ data.newDay = false; // It may be a new day, but not by default
+ return data;
+ }
+
+ /* -------------------------------------------- */
+
+
+ /** @override */
+ activateListeners(html) {
+ super.activateListeners(html);
+ let btn = html.find("#roll-hulld");
+ btn.click(this._onRollHullDie.bind(this));
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * Handle rolling a Hull Die as part of a Recharge Rest action
+ * @param {Event} event The triggering click event
+ * @private
+ */
+ async _onRollHullDie(event) {
+ event.preventDefault();
+ const btn = event.currentTarget;
+ this._denom = btn.form.hulld.value;
+ await this.actor.rollHullDie(this._denom);
+ this.render();
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * A helper constructor function which displays the Short Rest dialog and returns a Promise once it's workflow has
+ * been resolved.
+ * @param {Actor5e} actor
+ * @return {Promise}
+ */
+ static async rechargeRestDialog({actor}={}) {
+ return new Promise((resolve, reject) => {
+ const dlg = new this(actor, {
+ title: "Recharge Rest",
+ buttons: {
+ rest: {
+ icon: ' ',
+ label: "Rest",
+ callback: html => {
+ let newDay = false;
+ if (game.settings.get("sw5e", "restVariant") === "gritty")
+ newDay = html.find('input[name="newDay"]')[0].checked;
+ resolve(newDay);
+ }
+ },
+ cancel: {
+ icon: ' ',
+ label: "Cancel",
+ callback: reject
+ }
+ },
+ close: reject
+ });
+ dlg.render(true);
+ });
+ }
+}
diff --git a/module/apps/refitting-rest.js b/module/apps/refitting-rest.js
new file mode 100644
index 00000000..090fa693
--- /dev/null
+++ b/module/apps/refitting-rest.js
@@ -0,0 +1,69 @@
+/**
+ * A helper Dialog subclass for completing a refitting rest
+ * @extends {Dialog}
+ */
+export default class RefittingRestDialog extends Dialog {
+ constructor(actor, dialogData = {}, options = {}) {
+ super(dialogData, options);
+ this.actor = actor;
+ }
+
+ /* -------------------------------------------- */
+
+ /** @override */
+ static get defaultOptions() {
+ return mergeObject(super.defaultOptions, {
+ template: "systems/sw5e/templates/apps/refitting-rest.html",
+ classes: ["sw5e", "dialog"]
+ });
+ }
+
+ /* -------------------------------------------- */
+
+ /** @override */
+ getData() {
+ const data = super.getData();
+ const variant = game.settings.get("sw5e", "restVariant");
+ data.promptNewDay = variant !== "gritty"; // It's always a new day when resting 1 week
+ data.newDay = variant === "normal"; // It's probably a new day when resting normally (8 hours)
+ return data;
+ }
+
+ /* -------------------------------------------- */
+
+ /**
+ * A helper constructor function which displays the Refitting Rest confirmation dialog and returns a Promise once it's
+ * workflow has been resolved.
+ * @param {Actor5e} actor
+ * @return {Promise}
+ */
+ static async refittingRestDialog({ actor } = {}) {
+ return new Promise((resolve, reject) => {
+ const dlg = new this(actor, {
+ title: "Refitting Rest",
+ buttons: {
+ rest: {
+ icon: ' ',
+ label: "Rest",
+ callback: html => {
+ let newDay = false;
+ if (game.settings.get("sw5e", "restVariant") === "normal")
+ newDay = html.find('input[name="newDay"]')[0].checked;
+ else if(game.settings.get("sw5e", "restVariant") === "gritty")
+ newDay = true;
+ resolve(newDay);
+ }
+ },
+ cancel: {
+ icon: ' ',
+ label: "Cancel",
+ callback: reject
+ }
+ },
+ default: 'rest',
+ close: reject
+ });
+ dlg.render(true);
+ });
+ }
+}
\ No newline at end of file
diff --git a/module/config.js b/module/config.js
index d0991cb5..394346bf 100644
--- a/module/config.js
+++ b/module/config.js
@@ -642,7 +642,6 @@ SW5E.healingTypes = {
"temphp": "SW5E.HealingTemp"
};
-
/* -------------------------------------------- */
@@ -664,113 +663,34 @@ SW5E.powerDieTypes = [1, "d4", "d6", "d8", "d10", "d12"];
/* -------------------------------------------- */
+
/**
- * Enumerate the base stat and feature settings for starships based on size.
- * @type {Array.}
+ * Enumerate the upgrade costs as they apply to starships in the SW5E system based on Tier.
+ * @type {Array.}
*/
-SW5E.baseStarshipSettings = {
- "tiny": {"changes":[{"key":"data.abilities.dex.value","value":4,"mode":2,"priority":20},{"key":"data.abilities.dex.proficient","value":1,"mode":4,"priority":20}, {"key":"data.abilities.con.value","value":-4,"mode":2,"priority":20}, {"key":"data.abilities.int.proficient","value":1,"mode":4,"priority":20}], "attributes":{"crewcap":null, "hd":"1d4", "hp":{"value":4, "max":4, "temp":4, "tempmax":4}, "hsm":1, "sd":"1d4", "mods":{"open":10, "max":10}, "suites":{"open":0, "max":0}, "movement":{"fly":300, "turn":300}}},
- "sm": {"changes":[{"key":"data.abilities.dex.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.dex.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":-2,"mode":2,"priority":20},{"key":"data.abilities.str.proficient","value":1,"mode":4,"priority":20}], "attributes":{"crewcap":1, "hd":"3d6", "hp":{"value":6, "max":6, "temp":6, "tempmax":6}, "hsm":2, "sd":"3d6", "mods":{"open":20, "max":20}, "suites":{"open":-1, "max":-1}, "movement":{"fly":300, "turn":250}}},
- "med": {"attributes":{"crewcap":1, "hd":"5d8", "hp":{"value":8, "max":8, "temp":8, "tempmax":8}, "hsm":3, "sd":"5d8", "mods":{"open":30, "max":30}, "suites":{"open":3, "max":3}, "movement":{"fly":300, "turn":200}}},
- "lg": {"changes":[{"key":"data.abilities.dex.value","value":-2,"mode":2,"priority":20},{"key":"data.abilities.wis.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":2,"mode":2,"priority":20}], "attributes":{"crewcap":200, "hd":"7d10", "hp":{"value":10, "max":10, "temp":10, "tempmax":10}, "hsm":4, "sd":"7d10", "mods":{"open":50, "max":50}, "suites":{"open":3, "max":3}, "movement":{"fly":300, "turn":150}}},
- "huge": {"changes":[{"key":"data.abilities.dex.value","value":-4,"mode":2,"priority":20},{"key":"data.abilities.wis.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":4,"mode":2,"priority":20}], "attributes":{"crewcap":4000, "hd":"9d12", "hp":{"value":12, "max":12, "temp":12, "tempmax":12}, "hsm":2, "sd":"9d12", "mods":{"open":60, "max":60}, "suites":{"open":6, "max":6}, "movement":{"fly":300, "turn":100}}},
- "grg": {"changes":[{"key":"data.abilities.dex.value","value":-6,"mode":2,"priority":20},{"key":"data.abilities.wis.proficient","value":1,"mode":4,"priority":20},{"key":"data.abilities.con.value","value":6,"mode":2,"priority":20}], "attributes":{"crewcap":80000, "hd":"11d20", "hp":{"value":20, "max":20, "temp":20, "tempmax":20}, "hsm":3, "sd":"11d20", "mods":{"open":70, "max":70}, "suites":{"open":10, "max":10}, "movement":{"fly":300, "turn":50}}}
-}
+SW5E.baseUpgradeCost = [0, 3900, 77500, 297000, 620000, 1150000];
/* -------------------------------------------- */
+
/**
- * The set of starship roles which can be selected in SW5e
- * @type {Object}
+ * Starship Deployment types
*/
- SW5E.starshipRolestiny = {
-};
-SW5E.starshipRolessm = {
- "bmbr": "SW5E.StarshipBomber",
- "intc": "SW5E.StarshipInterceptor",
- "scout": "SW5E.StarshipScout",
- "scrm": "SW5E.StarshipScrambler",
- "shtl": "SW5E.StarshipShuttle",
- "strf": "SW5E.StarshipStrikeFighter"
-};
-SW5E.starshipRolesmed = {
- "cour": "SW5E.StarshipCourier",
- "frtr": "SW5E.StarshipFreighter",
- "gnbt": "SW5E.StarshipGunboat",
- "msbt": "SW5E.StarshipMissileBoat",
- "nvgt": "SW5E.StarshipNavigator",
- "yacht": "SW5E.StarshipYacht"
-};
-SW5E.starshipRoleslg = {
- "ambd": "SW5E.StarshipAmbassador",
- "corv": "SW5E.StarshipCorvette",
- "crui": "SW5E.StarshipCruiser",
- "expl": "SW5E.StarshipExplorer",
- "pics": "SW5E.StarshipPicketShip",
- "shtd": "SW5E.StarshipShipsTender"
-};
-SW5E.starshipRoleshuge = {
- "btls": "SW5E.StarshipBattleship",
- "carr": "SW5E.StarshipCarrier",
- "colo": "SW5E.StarshipColonizer",
- "cmds": "SW5E.StarshipCommandShip",
- "intd": "SW5E.StarshipInterdictor",
- "jugg": "SW5E.StarshipJuggernaut"
-};
-SW5E.starshipRolesgrg = {
- "blks": "SW5E.StarshipBlockadeShip",
- "flgs": "SW5E.StarshipFlagship",
- "inct": "SW5E.StarshipIndustrialCenter",
- "mbmt": "SW5E.StarshipMobileMetropolis",
- "rsrc": "SW5E.StarshipResearcher",
- "wars": "SW5E.StarshipWarship"
+ SW5E.deploymentTypes = {
+ "coord": "SW5E.DeploymentTypeCoordinator",
+ "gunner": "SW5E.DeploymentTypeGunner",
+ "mech": "SW5E.DeploymentTypeMechanic",
+ "oper": "SW5E.DeploymentTypeOperator",
+ "pilot": "SW5E.DeploymentTypePilot",
+ "tech": "SW5E.DeploymentTypeTechnician",
+ "crew": "SW5E.DeploymentTypeCrew",
+ "pass": "SW5E.DeploymentTypePassenger"
};
/* -------------------------------------------- */
-/**
- * The set of starship role bonuses to starships which can be selected in SW5e
- * @type {Object}
- */
-
- SW5E.starshipRoleBonuses = {
- "bmbr": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]},
- "intc": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20}]},
- "scout": {"changes":[{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]},
- "scrm": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20}]},
- "shtl": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20}]},
- "strf": {"changes":[{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "cour": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20}]},
- "frtr": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20}]},
- "gnbt": {"changes":[{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "msbt": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]},
- "nvgt": {"changes":[{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]},
- "yacht": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20}]},
- "ambd": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20}]},
- "corv": {"changes":[{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20}]},
- "crui": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "expl": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]},
- "pics": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]},
- "shtd": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "btls": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "carr": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]},
- "colo": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20}]},
- "cmds": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]},
- "intd": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "jugg": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "blks": {"changes":[{"key":"data.abilities.dex.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "flgs": {"changes":[{"key":"data.abilities.cha.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]},
- "inct": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]},
- "mbmt": {"changes":[{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]},
- "rsrc": {"changes":[{"key":"data.abilities.int.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20}]},
- "wars": {"changes":[{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20}]}
-};
-
-/* -------------------------------------------- */
-
-
/**
* The set of possible sensory perception types which an Actor may have
@@ -822,7 +742,7 @@ SW5E.starshipSkills = {
"dat": "SW5E.StarshipSkillDat",
"hid": "SW5E.StarshipSkillHid",
"imp": "SW5E.StarshipSkillImp",
- "int": "SW5E.StarshipSkillInt",
+ "inf": "SW5E.StarshipSkillInf",
"man": "SW5E.StarshipSkillMan",
"men": "SW5E.StarshipSkillMen",
"pat": "SW5E.StarshipSkillPat",
diff --git a/module/item/entity.js b/module/item/entity.js
index 11ffc620..5965dfe8 100644
--- a/module/item/entity.js
+++ b/module/item/entity.js
@@ -598,7 +598,7 @@ export default class Item5e extends Item {
const level = this.actor?.data.data.powers[consumePowerLevel];
const fp = this.actor.data.data.attributes.force.points;
const tp = this.actor.data.data.attributes.tech.points;
- const powerCost = id.level + 1;
+ const powerCost = parseInt(id.level,10) + 1;
const innatePower = this.actor.data.data.attributes.powercasting === 'innate';
if (!innatePower){
switch (id.school){
diff --git a/packs/packs/adventuringgear.db b/packs/packs/adventuringgear.db
index 1e4f8991..e9b74e5a 100644
--- a/packs/packs/adventuringgear.db
+++ b/packs/packs/adventuringgear.db
@@ -52,7 +52,7 @@
{"name":"Traz","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"tool","data":{"description":{"value":"","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":6,"price":300,"attuned":false,"equipped":false,"rarity":"","identified":true,"ability":"int","chatFlavor":"","proficient":0,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Musical%20Instrument/Traz.webp","_id":"UQu4duMtxYEXKAbo"}
{"name":"Tent, two-person","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"loot","data":{"description":{"value":"","chat":"","unidentified":""},"source":"","quantity":1,"weight":5,"price":20,"attuned":false,"equipped":false,"rarity":"","identified":true,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{},"img":"systems/sw5e/packs/Icons/Utility/Tent.webp","_id":"UxL0trd3omeqzBk4"}
{"name":"Homing Beacon","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"loot","data":{"description":{"value":"A homing beacon is a device used to track starships or any other entity being transported. Homing beacons transmit using non-mass HoloNet transceivers able to be tracked through hyperspace. Homing beacons are small enough that they can easily be hidden inside a ship, or tucked into some crevice on its exterior.
","chat":"","unidentified":""},"source":"","quantity":1,"weight":1,"price":450,"attuned":false,"equipped":false,"rarity":"","identified":true,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{},"img":"systems/sw5e/packs/Icons/Utility/Homing%20Beacon.webp","_id":"V2hSxkLfq461mvNz"}
-{"name":"Power Cell","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"consumable","data":{"description":{"value":"Power cells fuel blaster weapons that deal energy or ion damage. Additionally, power cells are used to energize certain tools.
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":1,"price":10,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation": {"type": "none","cost": null,"condition": ""},"duration": {"value": null,"units": ""},"target": {"value": null,"width": null,"units": "","type": ""},"range":{"value": null,"long": null,"units": ""},"uses": {"value": 100,"max": "100","per": "charges","autoDestroy": false},"consume": {"type": "","target": "","amount": null},"ability": null,"actionType": "","attackBonus": 0,"chatFlavor": "","critical": null,"damage": {"parts": [],"versatile": ""},"formula": "","save": {"ability": "","dc": null,"scaling": "spell"},"consumableType": "ammo","attributes": {"spelldc": 10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Ammunition/Power%20Cell.webp","_id":"VUkO1T2aYMuUcBZM"}
+{"name":"Power Cell","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"consumable","data":{"description":{"value":"Power cells fuel blaster weapons that deal energy or ion damage. Additionally, power cells are used to energize certain tools.
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":1,"price":10,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation":{"type":"none","cost":null,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":100,"max":"100","per":"charges","autoDestroy":false},"consume":{"type":"","target":"","amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"spell"},"consumableType":"ammo","attributes":{"spelldc":10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Ammunition/Power%20Cell.webp","_id":"VUkO1T2aYMuUcBZM"}
{"name":"Propulsion pack","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"loot","data":{"description":{"value":"Propulsion packs enhance underwater movement. Activating or deactivating the propulsion pack requires a bonus action and, while active, you have a swimming speed of 30 feet. The propulsion pack lasts for 1 minute per power cell (to a maximum of 10 minutes) and can be recharged by a power source or replacing the power cells.
","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":20,"price":400,"attuned":false,"equipped":false,"rarity":"","identified":true,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{},"img":"systems/sw5e/packs/Icons/Weapon%20or%20Armor%20Accessory/Propulsion%20Pack.webp","_id":"XR1obpDj1PqDLfA8"}
{"name":"Emergency Battery","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"consumable","data":{"description":{"value":"All non-expendable droids need recharging as they are used. The battery has ten uses. As an action, you can expend one use of the kit to stabilize a droid that has 0 hit points, without needing to make an Intelligence (Technology) check.
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":5,"price":70,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"units":"","type":"creature"},"range":{"value":null,"long":null,"units":""},"uses":{"value":10,"max":10,"per":"charges","autoDestroy":true},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"other","attackBonus":0,"chatFlavor":"Stabilize Droid","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"spell"},"consumableType":"potion","attributes":{"spelldc":10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Medical/Emergency%20Battery.webp","_id":"Z0YM3aYCyCRhL6cx"}
{"name":"Smugglepack","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"backpack","data":{"description":{"value":"This backpack comes with a main compartment that can store up to 15 lb., not exceeding a volume of 1/2 cubic foot. Additionally, it has a hidden storage compartment that can hold up to 5 lb, not exceeding a volume of 1/4 cubic foot. Finding the hidden compartment requires a DC 15 Investigation check.
","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":6,"price":400,"attuned":false,"equipped":false,"rarity":"","identified":true,"capacity":{"type":"weight","value":20,"weightless":false},"currency":{"cp":0,"sp":0,"ep":0,"gp":0,"pp":0},"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Storage/Smugglerpack.webp","_id":"Zlj5z56A4oVQ5iEC"}
@@ -111,4 +111,3 @@
{"name":"Headcomm","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"loot","data":{"description":{"value":"A headcomm can be installed in a helmet or worn independently. It functions as a hands-free commlink.
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":1,"price":200,"attuned":false,"equipped":false,"rarity":"","identified":true,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{},"img":"systems/sw5e/packs/Icons/Communications/Headcomm.webp","_id":"zHERdLuCUPpxzaSJ"}
{"name":"Poisoner's Kit","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"tool","data":{"description":{"value":"A poisoner’s kit includes the vials, chemicals, and other equipment necessary for the creation of poisons. Proficiency with this kit lets you add your proficiency bonus to any ability checks you make to craft or use poisons.
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":2,"price":500,"attuned":false,"equipped":false,"rarity":"","identified":true,"ability":"int","chatFlavor":"","proficient":0,"attributes":{"spelldc":10},"damage":{"parts":[]}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Kit/Poisoner_s%20Kit.webp","_id":"zaLzNlsfzRf71Xpl"}
{"name":"Mine, Plasma","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"weapon","data":{"description":{"value":"When you use your action to set it, this mine sets an imperceptible laser line extending up to 15 feet. When the laser is tripped, the mine explodes, coating the area in a 15-foot radius around it in fire that burns for 1 minute. When a creature enters the fire or starts its turn there it must make a DC 13 Dexterity saving throw. On a failed save, the creature takes 2d6 fire damage, or half as much on a successful one. A construct makes this save with disadvantage.
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":2,"price":550,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":15,"units":"ft","type":"radius"},"range":{"value":null,"long":null,"units":""},"uses":{"value":1,"max":1,"per":"charges"},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"save","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"dex","dc":13,"scaling":"flat"},"weaponType":"improv","properties":{"amm":false,"fin":false,"fir":false,"foc":false,"hvy":false,"lgt":false,"lod":false,"rch":false,"rel":false,"ret":false,"spc":false,"thr":false,"two":false,"ver":false},"proficient":false,"attributes":{"spelldc":10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Explosive/Mine%2C%20Plasma.webp","_id":"zrSOUA8dUg9lHVdS"}
-{"name":"Power Cell","permission":{"default":0,"vXYkFWX6qzvOu2jc":3},"type":"consumable","data":{"description":{"value":"Power cells fuel blaster weapons that deal energy or ion damage. Additionally, power cells are used to energize certain tools.
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":1,"price":10,"attuned":false,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":null,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":240,"max":240,"per":"charges","autoDestroy":false},"consume":{"type":"","target":"","amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"spell"},"consumableType":"ammo","attributes":{"spelldc":10}},"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false,"effects":[]}},"img":"systems/sw5e/packs/Icons/Ammunition/Power%20Cell.webp","_id":"VUkO1T2aYMuUcBZM"}
diff --git a/packs/packs/species.db b/packs/packs/species.db
index b3ae3616..f5122423 100644
--- a/packs/packs/species.db
+++ b/packs/packs/species.db
@@ -119,5 +119,3 @@
{"_id":"ynuvnI54pMKLwF2a","name":"Echani","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"Biology and Appearance Echani are characterized by their white skin, hair, and eyes, and their remarkable tendency to look very much alike one another to outside observers, particularly amongst family members. It is thought that their origins stem from Arkanian experimentation on the human genome, a hypothesis that could explain their physical conformity.
Society and Culture A matriarchal, caste-based society originating from the Inner Rim world of Eshan, the echani spread to encompass a confederacy of six worlds including Bengali and Thyrsus, known as the Six Sisters, governed by the all-female Echani Command. \r\n\t\r\nEchani generals are sometimes seen by others as having the ability to predict their opponent's next move. This is no biologicial trait inherent to the species, but rather stems from the fact that combat is so ingrained into every level of echani culture; the echani hold to the idea that combat is the truest form of communication, and to know someone fully, you must fight them. While their combat rituals require complete freedom of movement and unarmed martial arts, in warfare, they tend towards light armor and melee weapons, and are considered excellent craftsmen of such.
Names Echani names tend to lack hard consonants, but are otherwise as variable as human ones. Echani surnames are tied directly to their place in the caste system.
Male Names. Caelian, Inarin, Losor, Uelis, Yusanis
Female Names. Astri, Brianna, Isena, Raskta, Senriel Surnames. Authal, Elysi, Fenni, Kinro, Lsu","chat":"","unidentified":""},"traits":{"value":"Ability Score Increase. Your Dexterity score increases by 2, and your Wisdom score increases by 1.
\nAge. Echani reach adulthood in their late teens and live less than a century.
\nAlignment. Echani culture's emphasis on honor and combat cause them to tend towards lawful alignments, though there are exceptions.
\nSize. Echani stand between 5 and a half and 6 feet tall and weigh around 150 lbs, with little variation between them. Your size is Medium.
\nSpeed. Your base walking speed is 30 feet.
\nAllies of the Force. Whenever you make a Wisdom (Insight) check against someone you know to wield the Force, you are considered to have expertise in the Insight skill.
\nCombative Culture. You have proficiency in Lore and Acrobatics.
\nEchani Art. If a humanoid you can see makes a melee weapon attack, you can use your reaction to make a Wisdom (Insight) check against the target's Charisma (Deception). On a success you learn one of the following traits about that creature: it's Strength, Dexterity or Constitution score; bonus to Strength, Dexterity or Constitution saving throws; armor class; or current hit points. On a failure, the target becomes immune to this feature for one day. You can use this ability a number of times equal to your Wisdom modifier (a minimum of once). You regain all expended uses on a long rest.
\nMartial Upbringing. You have proficiency in light armor, and gain proficiency with two martial vibroweapons of your choice.
\nUnarmed Combatant. Your unarmed strikes deal 1d6 kinetic damage. You can use your choice of your Strength or Dexterity modifier for the attack and damage rolls. You must use the same modifier for both rolls.
\nLanguages. You can speak, read, and write Galactic Basic and one extra language of your choice.
"},"skinColorOptions":{"value":"Pale tones"},"hairColorOptions":{"value":"White"},"eyeColorOptions":{"value":"Silver"},"distinctions":{"value":"Fair skin, white hair and eyes, remarkable familial similarity."},"heightAverage":{"value":"5'1\""},"heightRollMod":{"value":"+1d10\""},"weightAverage":{"value":"105 lb."},"weightRollMod":{"value":"x(2d4) lb."},"homeworld":{"value":"Eshan"},"slanguage":{"value":"Galactic Basic"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"alwaysActive":false,"effects":[{"modSpecKey":"data.details.species","value":"Echani","mode":"=","targetSpecific":false,"id":1,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.dex.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Abilities Dexterity"},{"modSpecKey":"data.abilities.wis.value","value":"1","mode":"+","targetSpecific":false,"id":3,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Abilities Wisdom"},{"modSpecKey":"data.traits.size","value":"med","mode":"=","targetSpecific":false,"id":4,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"30","mode":"=","targetSpecific":false,"id":5,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.skills.lor.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Skills Lore"},{"modSpecKey":"data.skills.acr.value","value":"1","mode":"+","targetSpecific":false,"id":7,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[],"label":"Skills Acrobatics"},{"modSpecKey":"data.traits.armorProf.value","value":"lgt","mode":"+","targetSpecific":false,"id":8,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":9,"itemId":"RZ06rVoMQ80tK8W1","active":false,"_targets":[]}]},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Echani.webp","effects":[{"_id":"HNBMxZCToQEy6PSr","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Echani","mode":5,"priority":5},{"key":"data.abilities.dex.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.wis.value","value":1,"mode":2,"priority":20},{"key":"data.traits.size","value":"med","mode":0,"priority":5},{"key":"data.attributes.movement.walk","value":"30","mode":5,"priority":5},{"key":"data.skills.lor.value","value":1,"mode":4,"priority":20},{"key":"data.skills.acr.value","value":1,"mode":4,"priority":20},{"key":"data.traits.armorProf.value","value":"lgt","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"flags.sw5e.unarmedCombatant","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Echani.webp","label":"Echani","tint":"","transfer":true}]}
{"_id":"yyCUAG4cUUKh4IUz","name":"Iktotchi","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"Biology and Appearance Iktotchi do not have hair, but rather they had a very resistant skin which protected them from the violent winds which crossed the satellite. Both males and females have down-curved cranial horns, which gave them an aggressive aspect. The males' horns are generally a little larger, a remnant from their mountain-dwelling, caprinaen ancestors. The horns are able to regenerate if damaged.
Society and Culture The Iktotchi are a fiercely guarded and isolationist species - vaunted for their ability to hide their feelings and bury any semblance of emotion. Originating on the harsh, windy moon of Iktotch, which orbits the planet Iktotchon in the Expansion Region, the Iktotch are gifted with precognition, and are courted as often by Jedi as by pirates for their skills.\r\n\r\nIktotchi society is a stratified society. Upward mobility is both possible and encouraged. Iktotchi are an outwardly dispassionate people, which is evidenced by their culture. They have a robust legal system, and suffer little crime. Iktotchi are respectful of cultures other than their own and can easily integrate with others.\r\n\r\nIktotchi who distinguish themselves often earn a titular nickname, by which they are referred to in place of their name. Generally, this is done by accomplishing a remarkable feat that benefits the Iktotchi as whole.
Names Iktotchi names are generally two syllables. Surnames are familial. Respected Iktotchi often adopt a nickname, which they use in place of their birth name.
Male Names. Dilnam, Imruth, Kashkil, Yellam Female Names. Kemkal, Onyeth, Reshu, Zorlu Surnames. Hevil, Kaawi, Mimir, Nudaal, Zelend","chat":"","unidentified":""},"traits":{"value":"Ability Score Increase. Your Intelligence score increases by 2, and your Strength score increases by 1.
\nAge. Iktotchi reach adulthood in their late teens and live less than a century.
\nAlignment. Iktotchi are lawful and tend toward the light side, though there are exceptions.
\nSize. Iktotchi typically stand between 5 and 6 feet tall and weigh about 170 lbs. Regardless of your position in that range, your size is Medium.
\nSpeed. Your base walking speed is 30 feet.
\nPrecognition. You can see brief fragments of the future that allow you to turn failures into successes. When you roll a 1 on an attack roll, ability check, or saving throw, you can reroll the die and must use the new roll.
\nTelepathy. You can communicate telepathically with creatures within 30 feet of you. You must share a language with the target in order to communicate in this way.
\nHorns. Your horns are a natural weapon, which you can use to make unarmed strikes. If you hit with it, you deal kinetic damage equal to 1d6 + your Strength modifier.
\nPilot. You have proficiency in the Piloting skill.
\nLanguages. You can speak, read, and write Galactic Basic and Iktotchese.
"},"skinColorOptions":{"value":"Pink"},"hairColorOptions":{"value":"None"},"eyeColorOptions":{"value":"Black"},"distinctions":{"value":"Horns, precognition, telepathy, thick pink skin"},"heightAverage":{"value":"4'11\""},"heightRollMod":{"value":"+2d10\""},"weightAverage":{"value":"120 lb."},"weightRollMod":{"value":"x(2d4) lb."},"homeworld":{"value":"Iktotch, moon of Iktotchon"},"slanguage":{"value":"Iktotchese"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"colorScheme":{"value":""},"manufacturer":{"value":""},"planguage":{"value":""},"droidDistinctions":{"value":""},"droidLanguage":{"value":""},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"alwaysActive":false,"effects":[{"modSpecKey":"data.details.species","value":"Iktotchi","mode":"=","targetSpecific":false,"id":1,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.abilities.str.value","value":"1","mode":"+","targetSpecific":false,"id":3,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Abilities Strength"},{"modSpecKey":"data.traits.size","value":"med","mode":"=","targetSpecific":false,"id":4,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"30","mode":"=","targetSpecific":false,"id":5,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.skills.pil.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":7,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"iktotchese","mode":"+","targetSpecific":false,"id":8,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":"+","targetSpecific":false,"id":9,"itemId":"UZKrP5BCcEle7QSh","active":false,"_targets":[]}]},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Iktotchi.webp","effects":[{"_id":"MAjUMql3tivJTfVO","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Iktotchi","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.str.value","value":1,"mode":2,"priority":20},{"key":"data.traits.size","value":"med","mode":0,"priority":5},{"key":"data.attributes.movement.walk","value":"30","mode":5,"priority":5},{"key":"data.skills.pil.value","value":1,"mode":4,"priority":20},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"iktotchese","mode":0,"priority":0},{"key":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":0,"priority":0},{"key":"flags.sw5e.precognition","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Iktotchi.webp","label":"Iktotchi","tint":"","transfer":true}]}
{"_id":"zKpCsa8WCfz9abwv","name":"Killik","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"Biology and Appearance \nKilliks possess a strong chitinous exoskeleton that is glossy and greenish with their carcasses capable of surviving thousands of years of erosion as seen by the colonists of Alderaan. The exoskeleton also contains a number of spiracles which served as their way of breathing. Typically, these Human-sized hive creatures have four arms with each ending in a powerful three-fingered claw. They stand on two stout legs that are capable of leaping great distances. Killiks can communicate with other Killiks through use of pheromones.
\nSociety and Culture \nThe Killiks have a communal society, with each and every Killik being in mental contact with another. Due to their hive mind, every Killik nest is virtually one individual. Killiks are also peaceful in nature. Their telepathic connection is capable of extending to other species which includes non-insectoids. A willing creature can submit to this telepathy to become a Joiner. They effectively become another vessel of the hive mind. Killiks lose connection to their hive mind at great distances. Those who voluntarily leave the hive mind are referred to as Leavers. It is rare that they are allowed to rejoin their hive without reason.
\nNames \nKilliks are a hive-mind insectoid that typically don't use names. On the off chance they do, it's usually an incomprehensible series of clicking noises. They are receptive to nicknames given by others.
","chat":"","unidentified":""},"traits":{"value":"Ability Score Increase. Your Intelligence score increases by 2, and your Constitution score increases by 1.
Age. Killiks reach adulthood in their 40s and live an average of 200 years.
Alignment. Killiks' willingness to brainwash or kill their enemies cause them to tend towards the dark side, though there are exceptions.
Size. Killiks stand between 5 and 6 feet tall and weigh about 160 lbs. Regardless of your position in that range, your size is Medium.
Speed. Your base walking speed is 30 feet.
Four-Armed. Killiks have four arms which they can use independently of one another. You can only gain the benefit of items held by two of your arms at any given time, and once per round you can switch which arms you are benefiting from (no action required).
Hardened Carapace. While you are unarmored or wearing light armor, your AC is 13 + your Dexterity modifier.
Strong-Legged. When you make a long jump, you can cover a number of feet up to twice your Strength score. When you make a high jump, you can leap a number of feet up into the air equal to 3 + twice your Strength modifier.
Telepathy. You can communicate telepathically with creatures within 30 feet of you. You must share a language with the target in order to communicate in this way.
Languages. You can speak, read, and write Killik. You can understand spoken and written Galactic Basic, but your vocal cords do not allow you to speak it.
"},"skinColorOptions":{"value":"Brown, chestnut, green, red, scarlet, or yellow"},"hairColorOptions":{"value":"None"},"eyeColorOptions":{"value":"Black or orange"},"distinctions":{"value":"Chitinous armor, mandibles projected from face, four arms ending in long three toed claws protrude from their torsos"},"heightAverage":{"value":"4'9\""},"heightRollMod":{"value":"+2d10\""},"weightAverage":{"value":"110 lb."},"weightRollMod":{"value":"x(2d4) lb."},"homeworld":{"value":"Alderaan"},"slanguage":{"value":"Killik"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"alwaysActive":false,"effects":[{"modSpecKey":"data.details.species","value":"Killik","mode":"=","targetSpecific":false,"id":1,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.abilities.con.value","value":"1","mode":"+","targetSpecific":false,"id":3,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Abilities Constitution"},{"modSpecKey":"data.traits.size","value":"med","mode":"=","targetSpecific":false,"id":4,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"30","mode":"=","targetSpecific":false,"id":5,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.attributes.ac.min","value":"13","mode":"=","targetSpecific":false,"id":6,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[],"label":"Attributes Armor Class Min"},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":7,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.value","value":"killik","mode":"+","targetSpecific":false,"id":8,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[]},{"modSpecKey":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":"+","targetSpecific":false,"id":9,"itemId":"pzCpIjsuNVs8encY","active":false,"_targets":[]}]},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Killik.webp","effects":[{"_id":"EWQOMXrZbfi4zSPF","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Killik","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.abilities.con.value","value":1,"mode":2,"priority":20},{"key":"data.traits.size","value":"med","mode":0,"priority":5},{"key":"data.attributes.movement.walk","value":"30","mode":5,"priority":5},{"key":"data.attributes.ac.value","value":"13+@abilities.dex.mod","mode":5,"priority":1},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"killik","mode":0,"priority":0},{"key":"data.traits.languages.custom","value":"telepathy (30 ft.)","mode":0,"priority":0},{"key":"flags.sw5e.strongLegged","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.extraArms","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Killik.webp","label":"Killik","tint":"","transfer":true}]}
-{"_id":"J7HJQkcvghtwMAdF","name":"Anzellan","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"Biology and Appearance \nThe anzellans are a diminutive species hailing from the secluded planet Anzella. Their eyes have floating corneal micro-lenses that allow them to see microscopic details. Anzellans are a bubbly and receptive people. They are a jovial and trusting people that tend to welcome strangers with open arms. Due to their volcanic homeworld, anzellans are also adapted towards heat. This, coupled with their small size, make them well-suited to working in compact places.
\nSociety and Culture \nAnzella is a tropical planet covered in thousands of small volcanic islands. Many of these islands are developed as small villages, with the largest islands designed to accommodate larger species. Anzellan culture is generally based around tourism and crafting; in fact, anzellans are renowned craftsmen due to their discerning eyesight and ability to fit into small spaces. Anzellan government is generally casual. Each village has its own governing council of rotating members; these villages act independently from one another unless their decisions would affect more than a single island. In that case, all of the councils work together to come to a planet-wide decision.
\nNames \nAnzellan names are rarely longer than two syllables, with a bouncy intonation to them. Their surnames are familial.
\n Male Names. Babu, Gridel, Moru, Rano, Yodel
\n Female Names. Dibi, Fing, Nooni, Teena, Zazi
\n Surnames. E'ayoo, Frik, Meer, Tanni, Vrut
","chat":"","unidentified":""},"traits":{"value":"Ability Score Increase. Your Intelligence score increases by 2, and two other ability scores of your choice increase by 1.
Age. Anzellans are considered adults at ten years old. They are a short-lived species, however, that rarely lives longer than 60 years.
Alignment. Anzellans are a friendly and respectful people, which causes them to tend toward lawful light side, though there are exceptions.
Size. Anzellans stand between 1 and 2 feet tall and weigh around 10 lbs. Regardless of your position in that range, your size is Tiny.
Speed. Your base walking speed is 20 feet.
Crafters. You have proficiency in one tool of your choice.
Detail Oriented. You are practiced at scouring for details. You have advantage on Intelligence (Investigation) checks within 5 feet.
Pintsized. Your tiny stature makes it hard for you to wield bigger weapons. You can't use medium or heavy shields. Additionally, you can't wield weapons with the two-handed or versatile property, and you can only wield one-handed weapons in two hands unless they have the light property.
Puny. Anzellans are too small to pack much of a punch. You have disadvantage on Strength saving throws, and when determining your bonus to attack and damage rolls for weapon attacks using Strength, you can't add more than +3.
Small and Nimble. You are too small and fast to effectively target. You have a +1 bonus to AC, and you have advantage on Dexterity saving throws.
Tanned. You are naturally adapted to hot climates, as described in chapter 5 of the Dungeon Master's Guide.
Technician. You are proficient in the Technology skill.
Tinker. You have proficiency with tinker's tools. You can use these and spend 1 hour and 100 cr worth of materials to construct a Tiny Device (AC 5, 1 hp). You can take the Use an Object action to have your device cause one of the following effects: create a small explosion, create a repeating loud noise for 1 minute, create smoke for 1 minute, create a soothing melody for 1 minute. You can maintain a number of these devices up to your proficiency bonus at once, and a device stops functioning after 24 hours away from you. You can dismantle the device to reclaim the materials used to create it.
Languages. You can speak, read, and write Galactic Basic and Anzellan. Anzellan is characterized by its bouncy sound and emphasis on alternating syllables.
","source":"Expanded Content"},"skinColorOptions":{"value":"Brown, green, or tan"},"hairColorOptions":{"value":"Black, gray, or white"},"eyeColorOptions":{"value":"Black"},"distinctions":{"value":"Diminutive size, wispy eyebrows"},"heightAverage":{"value":"1'0\""},"heightRollMod":{"value":"+2d6\""},"weightAverage":{"value":"3 lb."},"weightRollMod":{"value":"x1 lb."},"homeworld":{"value":"Anzella"},"slanguage":{"value":"Anzellan"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"effects":[{"modSpecKey":"data.details.species","value":"Anzellan","mode":"=","targetSpecific":false,"id":1,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.traits.size","value":"tin","mode":"=","targetSpecific":false,"id":3,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"20","mode":"=","targetSpecific":false,"id":4,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.attributes.ac.min","value":"1","mode":"+","targetSpecific":false,"id":5,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Armor Class Min"},{"modSpecKey":"data.skills.tec.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Skills Technology"},{"modSpecKey":"data.traits.toolProf.custom","value":"Tinker's tools","mode":"+","targetSpecific":false,"id":7,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Tool Prof Custom"},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":8,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Language"},{"modSpecKey":"data.traits.languages.value","value":"anzellan","mode":"+","targetSpecific":false,"id":9,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[]}],"alwaysActive":false},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Anzellan.webp","effects":[{"_id":"ZGdzAq1Gl4xaxDRD","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Anzellan","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.traits.size","value":"tiny","mode":5,"priority":5},{"key":"data.attributes.movement.walk","value":20,"mode":5,"priority":5},{"key":"data.attributes.ac.value","value":1,"mode":2,"priority":20},{"key":"data.skills.tec.value","value":1,"mode":4,"priority":20},{"key":"data.traits.toolProf.custom","value":"Tinker's tools","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"anzellan","mode":0,"priority":0},{"key":"flags.sw5e.detailOriented","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.pintsized","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.puny","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.tinker","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Anzellan.webp","label":"Anzellan","tint":"","transfer":true}]}
-{"_id":"J7HJQkcvghtwMAdF","name":"Anzellan","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"species","data":{"data":"$characteristics-table","description":{"value":"Biology and Appearance \nThe anzellans are a diminutive species hailing from the secluded planet Anzella. Their eyes have floating corneal micro-lenses that allow them to see microscopic details. Anzellans are a bubbly and receptive people. They are a jovial and trusting people that tend to welcome strangers with open arms. Due to their volcanic homeworld, anzellans are also adapted towards heat. This, coupled with their small size, make them well-suited to working in compact places.
\nSociety and Culture \nAnzella is a tropical planet covered in thousands of small volcanic islands. Many of these islands are developed as small villages, with the largest islands designed to accommodate larger species. Anzellan culture is generally based around tourism and crafting; in fact, anzellans are renowned craftsmen due to their discerning eyesight and ability to fit into small spaces. Anzellan government is generally casual. Each village has its own governing council of rotating members; these villages act independently from one another unless their decisions would affect more than a single island. In that case, all of the councils work together to come to a planet-wide decision.
\nNames \nAnzellan names are rarely longer than two syllables, with a bouncy intonation to them. Their surnames are familial.
\n Male Names. Babu, Gridel, Moru, Rano, Yodel
\n Female Names. Dibi, Fing, Nooni, Teena, Zazi
\n Surnames. E'ayoo, Frik, Meer, Tanni, Vrut
","chat":"","unidentified":""},"traits":{"value":"Ability Score Increase. Your Intelligence score increases by 2, and two other ability scores of your choice increase by 1.
Age. Anzellans are considered adults at ten years old. They are a short-lived species, however, that rarely lives longer than 60 years.
Alignment. Anzellans are a friendly and respectful people, which causes them to tend toward lawful light side, though there are exceptions.
Size. Anzellans stand between 1 and 2 feet tall and weigh around 10 lbs. Regardless of your position in that range, your size is Tiny.
Speed. Your base walking speed is 20 feet.
Crafters. You have proficiency in one tool of your choice.
Detail Oriented. You are practiced at scouring for details. You have advantage on Intelligence (Investigation) checks within 5 feet.
Pintsized. Your tiny stature makes it hard for you to wield bigger weapons. You can't use medium or heavy shields. Additionally, you can't wield weapons with the two-handed or versatile property, and you can only wield one-handed weapons in two hands unless they have the light property.
Puny. Anzellans are too small to pack much of a punch. You have disadvantage on Strength saving throws, and when determining your bonus to attack and damage rolls for weapon attacks using Strength, you can't add more than +3.
Small and Nimble. You are too small and fast to effectively target. You have a +1 bonus to AC, and you have advantage on Dexterity saving throws.
Tanned. You are naturally adapted to hot climates, as described in chapter 5 of the Dungeon Master's Guide.
Technician. You are proficient in the Technology skill.
Tinker. You have proficiency with tinker's tools. You can use these and spend 1 hour and 100 cr worth of materials to construct a Tiny Device (AC 5, 1 hp). You can take the Use an Object action to have your device cause one of the following effects: create a small explosion, create a repeating loud noise for 1 minute, create smoke for 1 minute, create a soothing melody for 1 minute. You can maintain a number of these devices up to your proficiency bonus at once, and a device stops functioning after 24 hours away from you. You can dismantle the device to reclaim the materials used to create it.
Languages. You can speak, read, and write Galactic Basic and Anzellan. Anzellan is characterized by its bouncy sound and emphasis on alternating syllables.
","source":"Expanded Content"},"skinColorOptions":{"value":"Brown, green, or tan"},"hairColorOptions":{"value":"Black, gray, or white"},"eyeColorOptions":{"value":"Black"},"distinctions":{"value":"Diminutive size, wispy eyebrows"},"heightAverage":{"value":"1'0\""},"heightRollMod":{"value":"+2d6\""},"weightAverage":{"value":"3 lb."},"weightRollMod":{"value":"x1 lb."},"homeworld":{"value":"Anzella"},"slanguage":{"value":"Anzellan"},"damage":{"parts":[]},"armorproperties":{"parts":[]},"weaponproperties":{"parts":[]},"source":"EC"},"flags":{"dynamiceffects":{"equipActive":true,"effects":[{"modSpecKey":"data.details.species","value":"Anzellan","mode":"=","targetSpecific":false,"id":1,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Details Species"},{"modSpecKey":"data.abilities.int.value","value":"2","mode":"+","targetSpecific":false,"id":2,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Abilities Intelligence"},{"modSpecKey":"data.traits.size","value":"tin","mode":"=","targetSpecific":false,"id":3,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Size"},{"modSpecKey":"data.attributes.movement.walk","value":"20","mode":"=","targetSpecific":false,"id":4,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Speed"},{"modSpecKey":"data.attributes.ac.min","value":"1","mode":"+","targetSpecific":false,"id":5,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Attributes Armor Class Min"},{"modSpecKey":"data.skills.tec.value","value":"1","mode":"+","targetSpecific":false,"id":6,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Skills Technology"},{"modSpecKey":"data.traits.toolProf.custom","value":"Tinker's tools","mode":"+","targetSpecific":false,"id":7,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Tool Prof Custom"},{"modSpecKey":"data.traits.languages.value","value":"basic","mode":"+","targetSpecific":false,"id":8,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[],"label":"Traits Language"},{"modSpecKey":"data.traits.languages.value","value":"anzellan","mode":"+","targetSpecific":false,"id":9,"itemId":"bIEwmrB96DiRuntI","active":false,"_targets":[]}],"alwaysActive":false},"dae":{"activeEquipped":false,"alwaysActive":true}},"img":"systems/sw5e/packs/Icons/Species/Anzellan.webp","effects":[{"_id":"ZGdzAq1Gl4xaxDRD","flags":{"dae":{"transfer":true,"stackable":false}},"changes":[{"key":"data.details.species","value":"Anzellan","mode":5,"priority":5},{"key":"data.abilities.int.value","value":2,"mode":2,"priority":20},{"key":"data.traits.size","value":"tiny","mode":5,"priority":5},{"key":"data.attributes.movement.walk","value":20,"mode":5,"priority":5},{"key":"data.attributes.ac.value","value":1,"mode":2,"priority":20},{"key":"data.skills.tec.value","value":1,"mode":4,"priority":20},{"key":"data.traits.toolProf.custom","value":"Tinker's tools","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"basic","mode":0,"priority":0},{"key":"data.traits.languages.value","value":"anzellan","mode":0,"priority":0},{"key":"flags.sw5e.detailOriented","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.pintsized","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.puny","value":"1","mode":5,"priority":20},{"key":"flags.sw5e.tinker","value":"1","mode":5,"priority":20}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Species/Anzellan.webp","label":"Anzellan","tint":"","transfer":true}]}
diff --git a/packs/packs/starshiparmor.db b/packs/packs/starshiparmor.db
index 20efef0d..79733cd0 100644
--- a/packs/packs/starshiparmor.db
+++ b/packs/packs/starshiparmor.db
@@ -1,6 +1,6 @@
-{"_id":"AAA9PWi1rTiSUIIe","name":"Lightweight Armor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Lightweight armor offers a trade-off of a more maneuverable but less resilient ship. A ship with Lightweight Armor installed has a +2 bonus to armor class, but has one fewer maximum hull point per Hull Die.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":3100,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10,"type":"ssarmor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":"(-1)"},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""},"attributes":{"dr":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Lightweight%20Armor.webp","effects":[]}
-{"_id":"JhX8qXjrDL3pCRmF","name":"Reinforced Armor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Opposite of lightweight armor is reinforced armor. This armor improves a ship's resilience, but makes it less likely to avoid damage. A ship with Reinforced Armor installed has a -1 penalty to armor class, but has one additional maximum hull point per Hull Die.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":3700,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10,"type":"ssarmor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":"1"},"regrateco":{"value":""},"attributes":{"dr":"6"},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reinforced%20Armor.webp","effects":[]}
-{"_id":"M7igMGsBIosGA4dS","name":"Quick-Charge Shield","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Quick-Charge Shields, opposite of Fortress Shields, offer a reduced capacity but rapidly replenish.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":4900,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"ssshield","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":"(2/3)"},"hpperhd":{"value":""},"regrateco":{"value":"(3/2)"},"attributes":{"dr":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Quick-Charge%20Shield.webp","effects":[]}
-{"_id":"RvtLP3FgKLBYBHSf","name":"Directional Shield","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Directional Shields are the most commonly used and balanced shields on the market.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":4300,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"ssshield","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":"1"},"hpperhd":{"value":""},"regrateco":{"value":"1"},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""},"attributes":{"dr":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Directional%20Shield.webp","effects":[]}
-{"_id":"Wj62TEtwKeG1P2DD","name":"Fortress Shield","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Fortress shields offer a higher maximum shield points, but regenerate slower than normal shields.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":4650,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"ssshield","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":"(3/2)"},"hpperhd":{"value":""},"regrateco":{"value":"(2/3)"},"attributes":{"dr":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Fortress%20Shield.webp","effects":[]}
-{"_id":"aG6mKPerYCFmkI00","name":"Deflection Armor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Deflection armor is the most common type of armor aboard ships, and offers no benefit or penalty to armor class or hull points.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":3450,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10,"type":"ssarmor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"attributes":{"dr":"3"},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Deflection%20Armor.webp","effects":[]}
+{"_id":"AAA9PWi1rTiSUIIe","name":"Lightweight Armor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Lightweight armor offers a trade-off of a more maneuverable but less resilient ship. A ship with Lightweight Armor installed has a +2 bonus to armor class, but has one fewer maximum hull point per Hull Die.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":3100,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10,"type":"ssarmor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"dmgred":{"value":"0"},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""},"attributes":{"dr":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Lightweight%20Armor.webp","effects":[]}
+{"_id":"JhX8qXjrDL3pCRmF","name":"Reinforced Armor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Opposite of lightweight armor is reinforced armor. This armor improves a ship's resilience, but makes it less likely to avoid damage. A ship with Reinforced Armor installed has a -1 penalty to armor class, but has one additional maximum hull point per Hull Die.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":3700,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10,"type":"ssarmor","dex":0},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"dmgred":{"value":"6"},"regrateco":{"value":""},"attributes":{"dr":"6"},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reinforced%20Armor.webp","effects":[]}
+{"_id":"M7igMGsBIosGA4dS","name":"Quick-Charge Shield","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Quick-Charge Shields, opposite of Fortress Shields, offer a reduced capacity but rapidly replenish.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":4900,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"ssshield","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":"0.667"},"dmgred":{"value":""},"regrateco":{"value":"1.5"},"attributes":{"dr":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Quick-Charge%20Shield.webp","effects":[]}
+{"_id":"RvtLP3FgKLBYBHSf","name":"Directional Shield","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Directional Shields are the most commonly used and balanced shields on the market.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":4300,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"ssshield","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":"1"},"dmgred":{"value":""},"regrateco":{"value":"1"},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""},"attributes":{"dr":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Directional%20Shield.webp","effects":[]}
+{"_id":"Wj62TEtwKeG1P2DD","name":"Fortress Shield","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Fortress shields offer a higher maximum shield points, but regenerate slower than normal shields.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":4650,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"ssshield","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":"1.5"},"dmgred":{"value":""},"regrateco":{"value":"0.667"},"attributes":{"dr":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Fortress%20Shield.webp","effects":[]}
+{"_id":"aG6mKPerYCFmkI00","name":"Deflection Armor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Deflection armor is the most common type of armor aboard ships, and offers no benefit or penalty to armor class or hull points.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":0,"price":3450,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10,"type":"ssarmor","dex":2},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"dmgred":{"value":"3"},"regrateco":{"value":""},"attributes":{"dr":"3"},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Deflection%20Armor.webp","effects":[]}
diff --git a/packs/packs/starshipequipment.db b/packs/packs/starshipequipment.db
index e6aa1ef7..03f7dcbf 100644
--- a/packs/packs/starshipequipment.db
+++ b/packs/packs/starshipequipment.db
@@ -5,10 +5,10 @@
{"_id":"MVXftcjJ1yzsCU3N","name":"Hyperdrive, Class 0.5","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"The hyperdrive is a propulsion system that allows a starship to reach hyperspeed and traverse the void between stars in the alternate dimension of hyperspace. For a starship to have a hyperdrive, it must have a vacant hyperdrive slot modification.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":50000,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"hyper","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":"0.5"}},"flags":{"core":{"sourceId":"Item.3CA76AXkU73nE53o"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Hyperdrive.webp","effects":[]}
{"name":"Hyperdrive, Class 8","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"The hyperdrive is a propulsion system that allows a starship to reach hyperspeed and traverse the void between stars in the alternate dimension of hyperspace. For a starship to have a hyperdrive, it must have a vacant hyperdrive slot modification.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":1000,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"hyper","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":"8"}},"flags":{"core":{"sourceId":"Item.pJASNAp63U6Y2Yx8"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Hyperdrive.webp","effects":[],"_id":"P84rgL4vBaWw0GJe"}
{"name":"Hyperdrive, Class 5","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"The hyperdrive is a propulsion system that allows a starship to reach hyperspeed and traverse the void between stars in the alternate dimension of hyperspace. For a starship to have a hyperdrive, it must have a vacant hyperdrive slot modification.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":2500,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"hyper","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":"5"}},"flags":{"core":{"sourceId":"Item.UgLbCq7OWL9G9BD7"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Hyperdrive.webp","effects":[],"_id":"U4BhpyMxJdbnNErJ"}
-{"_id":"UAiau5ZNXVJAJFUn","name":"Power Core Reactor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Power core reactors have highly variable power output capabilities, but sacrifice fuel economy and as a result.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":5750,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"reactor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":"(3/2)"},"powdicerec":{"value":"1d2"},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.2MTQUv6r5ePNANyn"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reactor.webp","effects":[]}
+{"_id":"UAiau5ZNXVJAJFUn","name":"Power Core Reactor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Power core reactors have highly variable power output capabilities, but sacrifice fuel economy and as a result.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":5750,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"reactor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":"1.5"},"powdicerec":{"value":"1d2"},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.2MTQUv6r5ePNANyn"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reactor.webp","effects":[]}
{"_id":"VzkRXuQx2sqN9nd0","name":"Distributed Power Coupling","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Distributed power coupling sacrifices flexibility by allocating power separately to each system.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":5100,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"powerc","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":"2"},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.4zjcBtJhXgpFW2pb"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Power%20Coupling.webp","effects":[]}
-{"_id":"ZyEdKtLwSXuUQs0P","name":"Ionization Reactor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Ionization reactors are highly fuel-efficient reactors that trade power output for fuel economy.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":5100,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"reactor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":"(2/3)"},"powdicerec":{"value":"(1d2)-1"},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.kXo8mNp6GFLYWokY"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reactor.webp","effects":[]}
-{"name":"Fuel Cell Reactor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Fuel cell reactors are the most common and balanced reactors on the market.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":4500,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"reactor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":"1"},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.Qwu6WlJiIgFWq9VF"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reactor.webp","effects":[],"_id":"jk7zL3cqhufDKsuh"}
+{"_id":"ZyEdKtLwSXuUQs0P","name":"Ionization Reactor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Ionization reactors are highly fuel-efficient reactors that trade power output for fuel economy.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":5100,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"reactor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":"0.667"},"powdicerec":{"value":"(1d2)-1"},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.kXo8mNp6GFLYWokY"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reactor.webp","effects":[]}
+{"name":"Fuel Cell Reactor","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Fuel cell reactors are the most common and balanced reactors on the market.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":4500,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"reactor","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":"1"},"powdicerec":{"value":"1"},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.Qwu6WlJiIgFWq9VF"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Reactor.webp","effects":[],"_id":"jk7zL3cqhufDKsuh"}
{"name":"Hyperdrive, Class 1.0","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"The hyperdrive is a propulsion system that allows a starship to reach hyperspeed and traverse the void between stars in the alternate dimension of hyperspace. For a starship to have a hyperdrive, it must have a vacant hyperdrive slot modification.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":15000,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"hyper","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":"1"}},"flags":{"core":{"sourceId":"Item.MrCZaUig1puXcEVy"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Hyperdrive.webp","effects":[],"_id":"o9DmeVhCJNezkjdI"}
{"_id":"oqB8RltTDjHnaS1Y","name":"Direct Power Coupling","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"Direct power coupling has a central power capacitor that feeds power directly to each system.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":4100,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":0,"type":"powerc","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":"4"},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":""}},"flags":{"core":{"sourceId":"Item.MEiHVZHModsp5w0b"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Power%20Coupling.webp","effects":[]}
{"name":"Hyperdrive, Class 3","permission":{"default":0,"IpSq6HI4edO6e0Yw":3},"type":"equipment","data":{"description":{"value":"The hyperdrive is a propulsion system that allows a starship to reach hyperspeed and traverse the void between stars in the alternate dimension of hyperspace. For a starship to have a hyperdrive, it must have a vacant hyperdrive slot modification.
","chat":"","unidentified":""},"source":"SotG","quantity":1,"weight":null,"price":7500,"attuned":false,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"","cost":0,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":""},"range":{"value":null,"long":null,"units":""},"uses":{"value":0,"max":0,"per":null},"consume":{"type":"","target":null,"amount":null},"ability":null,"actionType":"","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":null,"type":"hyper","dex":null},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"speed":{"value":null,"conditions":""},"strength":0,"stealth":false,"proficient":true,"properties":{"Absorptive":false,"Agile":false,"Anchor":false,"Avoidant":false,"Barbed":false,"Bulky":false,"Charging":false,"Concealing":false,"Cumbersome":false,"Gauntleted":false,"Imbalanced":false,"Impermeable":false,"Insulated":false,"Interlocking":false,"Lambent":false,"Lightweight":false,"Magnetic":false,"Obscured":false,"Obtrusive":false,"Powered":false,"Reactive":false,"Regulated":false,"Reinforced":false,"Responsive":false,"Rigid":false,"Silent":false,"Spiked":false,"Steadfast":false,"Strength":false,"Versatile":false},"capx":{"value":""},"hpperhd":{"value":""},"regrateco":{"value":""},"cscap":{"value":""},"sscap":{"value":""},"fuelcostsmod":{"value":""},"powdicerec":{"value":""},"hdclass":{"value":"3"}},"flags":{"core":{"sourceId":"Item.bzY549C3zaI3H6mt"}},"img":"systems/sw5e/packs/Icons/Starship%20Equipment/Hyperdrive.webp","effects":[],"_id":"rFm20P0THnzZRUxB"}
diff --git a/packs/packs/starships.db b/packs/packs/starships.db
new file mode 100644
index 00000000..32695c68
--- /dev/null
+++ b/packs/packs/starships.db
@@ -0,0 +1,6 @@
+{"_id":"6BN8l5E8QtYt103T","name":"Small Starship","permission":{"default":0,"yXqD5rPwgjXHtqeZ":3},"type":"starship","data":{"description":{"value":"As the fighters cover his approach, Zik Beskin activates his targeting computer and ignores the explosions surrounding him, instead Focusing on the Destroyer's shield generator. It had to come down soon, or this fight was lost. Arsix, behind him, beeps and whirs, preparing the ion pulse missiles for the attack run, prewarming their engines and arming the warheads.
\nTogether a long time now, Beskin and Arsix had spilled their share of blood and oil, respectively, for the Rebellion--usually just while improving their ancient Ywing. But today, the blood and oil spilt wouldn't be their own. As Zik let fly a pair of missiles, he knew they would find their target. Today wasn't over just yet. This was her fourth sortie of the day, and Sheena was tired. The terrorists just kept coming. Every time she was about to shut her eyes a new wave of the Rebels came. And every time they did, she rushed to her TIE Interceptor and joined the alert fighters to take the fighters down before they could blow a hole in the planet's defenses. Every time they retreated before suffering heavy losses. But every time they came back. This last time she had decided to just nap in the cramped cockpit, so when the claxon rang out, she and her ship were basically ready to fly. This time she was going to end them quickly. As she repeatedly squeezed her trigger, she executed Koiogran turns and snap rolls galore, her laser blasts striking true, and the debris of A-wings, X-wings, and B-Wings--along with some frozen traitor remains--floated in space the next few days. At night, she leaned back against her beau, sipping some wine and watch-ing the beautiful streaks of light cross the sky as, piece by piece, the wreckage burned up in the atmosphere during reentry.
\nR5-S1 locked down the loose stabilizer with his gripper arm as he angled the X-Wing's deflector shields. This ship took a firm gripper to get under control, but R5-S1 was up to the task. As his pilot, Veets, fired his last blast from the overheating cannons on the deployed s-foils, R5-S1 did the work of cooling off the guns, spooling up the hyperdrive, and running the calculations for lightspeed. It was time to go. Small ships have a tiny crew, often only a pilot and perhaps an astromech, but often strike above their weight class, a threat to small and large ships alike.
"},"size":"sm","tier":0,"hullDice":"d6","hullDiceStart":3,"hullDiceRolled":[6,4,4],"hullDiceUsed":0,"shldDice":"d6","shldDiceStart":3,"shldDiceRolled":[6,4,4],"shldDiceUsed":0,"pwrDice":"1","buildBaseCost":50000,"buildMinWorkforce":5,"upgrdCostMult":1,"upgrdMinWorkforce":1,"baseSpaceSpeed":300,"baseTurnSpeed":250,"crewMinWorkforce":1,"modBaseCap":20,"modMaxSuitesBase":-1,"modMaxSuitesMult":1,"modMaxSuiteCap":1,"modCostMult":1,"modMinWorkforce":2,"hardpointMult":1,"equipCostMult":1,"equipMinWorkforce":1,"cargoCap":2,"fuelCost":50,"fuelCap":10,"foodCap":10,"source":"SotG"},"folder":null,"sort":200001,"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Features/Small.webp","effects":[{"_id":"aVvrOFBux3t6WMEc","flags":{"dae":{"stackable":false,"transfer":true}},"changes":[{"key":"data.abilities.dex.value","value":2,"mode":2,"priority":1},{"key":"data.abilities.con.value","value":-2,"mode":2,"priority":1}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Starship%20Features/Small.webp","label":"Small Starship","origin":"Item.Kgo48vXNxSZkWp7H","tint":"","transfer":true}]}
+{"_id":"6liD1m4hqKSeS5sp","name":"Medium Starship","permission":{"default":0,"yXqD5rPwgjXHtqeZ":3},"type":"starship","data":{"description":{"value":"The Freighter shudders with the blasts of cannon fire. Despite its maneuvers, the pilot can't quite shake the pursuit. The technician's efforts to reinforce the shields are failing. The mechanic is pumping the reactor for every scrap of energy it can generate. The operator is frantically making the final few calculations for the jump to hyperspace. Finally, just as the ship's shields dissipate, the pilot makes the gut call, jettisoning the illicit cargo. As it distracts and hampers the followers, the freighter shifts power to the thrusters and quickly flies away.
\nAs the pirates activate their tractor beam to attempt to capture the weaponless frigate and its exotic wares, the gunboat escort intercedes. It flies in the line of the tractor, breaking the lock on the frigate, and unleashes a volley of cannon fire. The pirates, incapable of withstanding the salvo, drop the tractor beam and retreat.
\nAs the gunboat pursues to be sure the pirates don't come back for a second bite at the apple, the crew finally locks on target and unleashes a long-range night-stinger missile, putting a permanent end to the pirates' illicit and unwelcome activities.
\nThe captain invites his guests into his well-stocked cantina. He eyes the opposition as they take in his ship, assessing their reactions and noting their expressions. Fully aware of the effect the opulent room has on the unprepared, he easily stifles his grin and gestures for the starry-eyed vis-itors to sit across from him. He indicates for the server to bring drinks as he casually leans back and puts his feet on the table, confident this deal will go his way. He presses a button on the tiny remote in his hand, causing hidden panels to slide away and reveal his mostly-legal wares. \"So,\" he says, \"just how many of these do you need, and where will I deliver them?\"
\nMedium ships are the bread and butter of the closeknit group. They are large enough to accommodate all of the immediate needs of a crew, while at the same time being small enough to feel cozy.
"},"size":"med","tier":0,"hullDice":"d8","hullDiceStart":5,"hullDiceRolled":[8,5,5,5,5],"hullDiceUsed":0,"shldDice":"d8","shldDiceStart":5,"shldDiceRolled":[8,5,5,5,5],"shldDiceUsed":0,"pwrDice":"1","buildBaseCost":100000,"buildMinWorkforce":10,"upgrdCostMult":2,"upgrdMinWorkforce":5,"baseSpaceSpeed":300,"baseTurnSpeed":200,"crewMinWorkforce":1,"modBaseCap":30,"modMaxSuitesBase":3,"modMaxSuitesMult":1,"modMaxSuiteCap":4,"modCostMult":2,"modMinWorkforce":4,"hardpointMult":1.5,"equipCostMult":2,"equipMinWorkforce":2,"cargoCap":25,"fuelCost":100,"fuelCap":30,"foodCap":120,"source":"SotG"},"folder":null,"sort":300001,"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Features/Medium.webp","effects":[]}
+{"_id":"FH8iBT4uujRUR0j7","name":"Gargantuan Starship","permission":{"default":0,"yXqD5rPwgjXHtqeZ":3},"type":"starship","data":{"description":{"value":"The smaller ships retreat into the shadow of the blockade ship, fleeing an overwhelming foe. As the dreadnought's shields envelope them, they quickly turn and spring on their pursuers, utilizing the bulwark's shields as they unleash all of the firepower they have to bear.
\nMeanwhile, the blockade ship unleashes a storm of electromagnetic energy from its antenna array, cutting of communications between enemy ships, effectively isolating the incoming forces from their distant fleet and from each other.
\nIn the center of the fleet, the command ship surveys the battlefield. Wherever the line wavers, the command ship quickly directs ships to reinforce. Finally, the formations of the enemy flag, and the command ship directs the fleet to capitalize on their failure as it determines and uploads targeting coordinates to its torpedo ships.
\nThe warship looms ominously over the battlefield as the two opposing armies crash. Despite the efforts of the enemy line, the warship closes into firing range of the capital ships. Having already determined an ordered targeting precedence, the operating crew confirms final firing solutions for the gunners as they charge up the main super-weapon on the prow of the ship. It unleashes its first devastating blast as the rest of its arsenal begins to lance out at secondary targets nearby.
\nGargantuan ships are the dreadnoughts that strike fear into the hearts of the faithless. They are the embodiment of indomitable might: a symbol of total and complete control.
"},"size":"grg","tier":0,"hullDice":"d20","hullDiceStart":11,"hullDiceRolled":[20,11,11,11,11,11,11,11,11,11,11],"hullDiceUsed":0,"shldDice":"d20","shldDiceStart":11,"shldDiceRolled":[20,11,11,11,11,11,11,11,11,11,11],"shldDiceUsed":0,"pwrDice":"1","buildBaseCost":1000000000,"buildMinWorkforce":10000,"upgrdCostMult":1000,"upgrdMinWorkforce":5000,"baseSpaceSpeed":300,"baseTurnSpeed":50,"crewMinWorkforce":80000,"modBaseCap":70,"modMaxSuitesBase":10,"modMaxSuitesMult":4,"modMaxSuiteCap":40000,"modCostMult":500,"modMinWorkforce":1000,"hardpointMult":3,"equipCostMult":500,"equipMinWorkforce":500,"cargoCap":200000,"fuelCost":100000,"fuelCap":1800,"foodCap":576000000,"source":"SotG"},"folder":null,"sort":600001,"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Features/Gargantuan.webp","effects":[{"_id":"3erWl25IS1iaKUiv","flags":{"dae":{"stackable":false,"transfer":true}},"changes":[{"key":"data.abilities.dex.value","value":-6,"mode":2,"priority":1},{"key":"data.abilities.con.value","value":6,"mode":2,"priority":1}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Starship%20Features/Gargantuan.webp","label":"Gargantuan Starship","origin":"Item.KjK5001DYmUlFfPF","tint":"","transfer":true}]}
+{"_id":"RFKvLuqE13INBxqd","name":"Large Starship","permission":{"default":0,"yXqD5rPwgjXHtqeZ":3},"type":"starship","data":{"description":{"value":"Trapped in the capital ship's tractor beam, the ambassador frigate moves slowly towards the cruiser. The crew struggles to squeeze more power out of their reactor while the few marines on board take positions next to the hatches, wiping sweat from their brows as they check and re-check their weapons. Finally, bringing all the power the ship has to bare, the frigate is able to break the hold of the tractor beam and regain its trajectory, slowly but surely increasing the distance, before finally escaping the planet's moon and being able to jump to hyperspace. Shouts of joy echo down the ship's corridors and extra rations are ordered in celebration.
\nEngines burning brightly, the corvette sprints through the blockade, trying to minimize the amount of fire its meager shields will have to absorb. Top and bottom turrets swivel to port, unleashing return fire against inbound interceptors. The ground drops from under everyone's feet as the artificial gravity systems flicker for a second as a pulse weapon detonates nearby. The high-pitched, distant whine of the reactor is barely audible over commands issuing from the bridge. The visual readouts indicated that they were now past the picket line, and the interceptors appeared to be breaking off, unsure of their ability to take on the much larger ship without the support of their battle stations. They'd made it. Looking back at the scopes, the coordinator's head hung down. They'd been the only ones to do so.
\nAs the Pelta-Class Picket ship danced between the larger destroyers and dreadnaughts, it continued its near constant barrage of heavy laser cannon fire, interspersed with individual launches of concussion missiles and proton torpedoes directed at vulnerable parts of the opposing fleet. If too many of those enemy guns came to bear on the the Pelta, it would be in trouble, but it's speed and it's relatively limited firepower made it a less-than juicy target. For now. But that is exactly what it's captain needed. Just a few more clicks and they would be in a perfect flanking position, able to pound the engines of the flag ship as soon as it's shields were brought down by the bombing squad beginning their run now.
\nLarge ships occupy the pinnacle of size for most private owner/operators in the galaxy. Large ships require an extensive crew and are costly to maintain, but can pack quite a punch and may house various suites and operation centers that allow the ship to operate as an impressive and mobile base of operations for wealthy individuals and successful adventurers.
"},"size":"lg","tier":0,"hullDice":"d10","hullDiceStart":7,"hullDiceRolled":[10,6,6,6,6,6,6],"hullDiceUsed":0,"shldDice":"d10","shldDiceStart":7,"shldDiceRolled":[10,6,6,6,6,6,6],"shldDiceUsed":0,"pwrDice":"1","buildBaseCost":1000000,"buildMinWorkforce":100,"upgrdCostMult":10,"upgrdMinWorkforce":50,"baseSpaceSpeed":300,"baseTurnSpeed":150,"crewMinWorkforce":200,"modBaseCap":50,"modMaxSuitesBase":3,"modMaxSuitesMult":2,"modMaxSuiteCap":400,"modCostMult":5,"modMinWorkforce":10,"hardpointMult":2,"equipCostMult":5,"equipMinWorkforce":5,"cargoCap":500,"fuelCost":1000,"fuelCap":300,"foodCap":240000,"source":"SotG"},"folder":null,"sort":400001,"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Features/Large.webp","effects":[{"_id":"NwpCHKQsAOq16TMa","flags":{"dae":{"stackable":false,"transfer":true}},"changes":[{"key":"data.abilities.dex.value","value":-2,"mode":2,"priority":1},{"key":"data.abilities.con.value","value":2,"mode":2,"priority":1}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Starship%20Features/Large.webp","label":"Large Ship","origin":"Item.ywTvt1JEvDEoqG3A","tint":"","transfer":true}]}
+{"_id":"pgmf0rMYLt4LQtfN","name":"Huge Starship","permission":{"default":0,"yXqD5rPwgjXHtqeZ":3},"type":"starship","data":{"description":{"value":"The battleship's shields flicker as it absorbs the blows of the attacking fighters. It continues inexorably past them as it's point-defense system peppers it's vicinity with blaster fire to ward them off. As it a approaches the fragile medical frigate the fighters scramble to protect, its gunners lock on to the target before unleashing a fierce volley of turbolaser fire and snapping it in half.
\nAs the carrier leaves hyperspace, snubfighters deploy from its hangars in formations and move to intercept the space station's patrol fighters. Before the enemy craft have the opportunity to respond, the fighters fall upon them, quickly decimating their ranks. But before the snubfighters had even left the carrier, a second wave of small bombers had been prepping for takeoff. As they spew forth from the hangars, they quickly lock on to the space station and launch proton bombs, pulverizing the station in minutes.
\nWith the command given, the operator activates the interdictor's gravity well projectors. the lights inside dim almost imperceptibly as huge amounts of power is drawn from the reactor core and supplemental capacitors to the projectors. Accompanied by a lowpitched hum, the gravity well projectors power up.
\nMinutes pass for the ship uneventfully, until finally a frigate lurches unceremoniously out of hyperspace into realspace in front of them. The ship then activates its tractor beam, trapping its quarry.
\nHuge starships, regardless of their specific purpose, are the backbone of any military. They provide a mobile base of operations and function as a staging ground for the faction that controls them.
"},"size":"huge","tier":0,"hullDice":"d12","hullDiceStart":9,"hullDiceRolled":[12,7,7,7,7,7,7,7,7],"hullDiceUsed":0,"shldDice":"d12","shldDiceStart":9,"shldDiceRolled":[12,7,7,7,7,7,7,7,7],"shldDiceUsed":0,"pwrDice":"1","buildBaseCost":100000000,"buildMinWorkforce":1000,"upgrdCostMult":100,"upgrdMinWorkforce":500,"baseSpaceSpeed":300,"baseTurnSpeed":100,"crewMinWorkforce":4000,"modBaseCap":60,"modMaxSuitesBase":6,"modMaxSuitesMult":3,"modMaxSuiteCap":4000,"modCostMult":50,"modMinWorkforce":100,"hardpointMult":2,"equipCostMult":50,"equipMinWorkforce":50,"cargoCap":10000,"fuelCost":10000,"fuelCap":600,"foodCap":9600000,"source":"SotG"},"folder":null,"sort":500001,"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Features/Huge.webp","effects":[{"_id":"5dnyAxPsWqhRxR2v","flags":{"dae":{"stackable":false,"transfer":true}},"changes":[{"key":"data.abilities.dex.value","value":-4,"mode":2,"priority":1},{"key":"data.abilities.con.value","value":4,"mode":2,"priority":1}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Starship%20Features/Huge.webp","label":"Huge Starship","origin":"Item.h8l5MTU8C1pDIQGL","tint":"","transfer":true}]}
+{"_id":"zC4qM8JMmMzCjMJK","name":"Tiny Starship","permission":{"default":0,"yXqD5rPwgjXHtqeZ":3},"type":"starship","data":{"description":{"value":"As the droid fighter ducked and weaved through flying blaster bolts and dodged the occasional flak, its optical sensors continued to become more and more occluded as they accumulated a dark haze of dust and smoke. Switching to active radar systems, the droid continued to relentlessly pursue its target: the fleeing Jedi and its small padawn that was barely larger than a youngling. Not that it mattered. Whether either target continued to draw breath a few seconds from now didn't really matter to the droid. Of course, that cessation was its goal, but it didn't really care about that goal...it's just what it was doing. What it had to do. But not exactly what it wanted to do. The droid passed power from its fully charged weapon's capacitors into its guns, which blazed to life and spewed plasma towards the small child's back. It was a perfect shot. How could it not be. That's what it did. Then the Jedi's own plasma weapon flared as it darted across the youngling's back, deflecting the droid's blasts directly back at it. Of course, that is what Jedi's did. And as the bolts tore through the droid ship's hull, into its main computer banks, across its power banks and out its engines, the droid's final computations let it know that it was plummeting to the earth at terminal velocity. It's just what it was doing. What it now had to do. But not exactly what it wanted to do.
\nThe small drone slips silently past the blockade, scanning the defensive formations as it goes. The only chance to save the people on embattled Neth-Feeno was to coordinate a supply drop with them. This little, remote-controlled stealth ship was their only hope. It had to reach the surface to get the plan and maps through. Then it had to return with a full readout of the defenses. Only then could a distracting assault be planned to cover the air drop. As the tiny craft floated past the final sensor pod mounted right next to the final turret canon, the monitoring crew let out a sigh, and wiped sweat from their brows. As their screens, already more static than signal, winked out as the craft's transmissions were cut-off as it passed completely into the black-out zone, the team leader turned to his squad and said, \"May the force be with us today.\"
\nOne thing all Tiny starships have in common is that they are unmanned. Sometimes they are controlled remotely, but more often they are controlled by droids.
"},"size":"tiny","tier":0,"hullDice":"d4","hullDiceStart":1,"hullDiceRolled":[4],"hullDiceUsed":0,"shldDice":"d4","shldDiceStart":1,"shldDiceRolled":[4],"shldDiceUsed":0,"pwrDice":"1","buildBaseCost":10000,"buildMinWorkforce":3,"upgrdCostMult":0.5,"upgrdMinWorkforce":1,"baseSpaceSpeed":300,"baseTurnSpeed":300,"crewMinWorkforce":0,"modBaseCap":10,"modMaxSuitesBase":0,"modMaxSuitesMult":0,"modMaxSuiteCap":0,"modCostMult":0.5,"modMinWorkforce":1,"hardpointMult":1,"equipCostMult":0.5,"equipMinWorkforce":1,"cargoCap":0,"fuelCost":25,"fuelCap":5,"foodCap":0,"source":"SotG"},"folder":null,"sort":100001,"flags":{},"img":"systems/sw5e/packs/Icons/Starship%20Features/Tiny.webp","effects":[{"_id":"5BCYRjiFKUZY8ke9","flags":{"dae":{"stackable":false,"transfer":true}},"changes":[{"key":"data.abilities.dex.value","value":4,"mode":2,"priority":1},{"key":"data.abilities.con.value","value":-4,"mode":2,"priority":1}],"disabled":false,"duration":{"startTime":null,"seconds":null,"rounds":null,"turns":null,"startRound":null,"startTurn":null},"icon":"systems/sw5e/packs/Icons/Starship%20Features/Tiny.webp","label":"Tiny Starship","origin":"Item.k1Cxor0HkSEBpkuN","tint":"","transfer":true}]}
diff --git a/packs/packs/weapons.db b/packs/packs/weapons.db
index 39e87327..708c8be2 100644
--- a/packs/packs/weapons.db
+++ b/packs/packs/weapons.db
@@ -137,7 +137,6 @@
{"_id":"uQ2AXesizBRcTjRl","name":"Ion Carbine","permission":{"default":0,"vXYkFWX6qzvOu2jc":3,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"Reload 16
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":8,"price":300,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":60,"long":240,"units":"ft"},"uses":{"value":0,"max":0,"per":""},"consume":{"type":"ammo","target":"","amount":1},"ability":"","actionType":"rwak","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[["1d3 + @mod","ion"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"simpleB","properties":{"amm":true,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":false,"spc":false,"str":false,"thr":false,"two":true,"ver":false,"vic":false,"mgc":false,"nodam":false,"faulldam":false},"proficient":false},"folder":"7rtfHBtXhhiRSS8u","sort":2600001,"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false}},"img":"systems/sw5e/packs/Icons/Simple%20Blasters/Ion%20Carbine.webp","effects":[]}
{"_id":"v55dQl0raOAucwgP","name":"Vibromace","permission":{"default":0,"vXYkFWX6qzvOu2jc":3,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":12,"price":80,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":5,"long":null,"units":"ft"},"uses":{"value":0,"max":0,"per":""},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"mwak","attackBonus":0,"chatFlavor":"","critical":null,"damage":{"parts":[["1d8 + @mod","kinetic"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"simpleVW","properties":{"amm":false,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":true,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":false,"ret":false,"shk":false,"sil":false,"spc":false,"str":false,"thr":false,"two":true,"ver":false,"vic":false,"mgc":false,"nodam":false,"faulldam":false},"proficient":false},"folder":"7rtfHBtXhhiRSS8u","sort":6400001,"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false}},"img":"systems/sw5e/packs/Icons/Simple%20Vibroweapons/Vibromace.webp","effects":[]}
{"_id":"w62Yd7ahdYyTH61q","name":"Shatter cannon","permission":{"default":0,"MmfWtlBdw3ij5wl9":3},"type":"weapon","data":{"description":{"value":"Ammunition (range 80/320), Burst 4, Reload 8, Silent, Strength 15, Two-handed
","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":24,"price":1300,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"creature"},"range":{"value":80,"long":320,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"charges","target":"","amount":30},"ability":"","actionType":"rwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d10","kinetic"]],"versatile":""},"formula":"","save":{"ability":"dex","dc":null,"scaling":"dex"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialB","properties":{"amm":true,"aut":false,"bur":true,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":true,"spc":false,"str":true,"thr":false,"two":true,"ver":false,"vic":false,"nodam":false,"fulldam":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":6999220,"flags":{},"img":"systems/sw5e/packs/Icons/Martial%20Blasters/Shatter%20Cannon.webp","effects":[]}
-{"_id":"woDLArHK5OZHsTeU","name":"Disguised Blade","permission":{"default":0,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"","chat":"","unidentified":""},"source":"EC","quantity":1,"weight":1,"price":200,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":5,"long":null,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"mwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d4 + @mod","kinetic"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialVW","properties":{"amm":false,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":true,"dgd":true,"dis":false,"dpt":false,"dou":false,"fin":true,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":true,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":false,"ret":false,"shk":false,"sil":false,"spc":false,"str":false,"thr":false,"two":false,"ver":false,"vic":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":7150001,"flags":{},"img":"systems/sw5e/packs/Icons/Disguised%20Blade,"effects":[]}
{"_id":"xfIWfVXfe5ZfD8S2","name":"IWS (Blaster)","permission":{"default":0,"MmfWtlBdw3ij5wl9":3},"type":"weapon","data":{"description":{"value":"\n
The IWS is a heavy weapon that can fire in three different modes. On your turn, you can use your object interaction to switch between modes, detailed below.
\n
Antiarmor. While in this mode, rather than traditional power cells, the IWS fires grenades. When firing a grenade at long range, creatures within the radius of the grenade’s explosion have advantage on the saving throw.
\n
Blaster. While in this mode, the weapon uses traditional power cells.
\n
Sniper. While in this mode, the weapon uses traditional power cells.
\n
\nAntiarmor: Special, Ammunition (range 60/240), reload 1, special
\nBlaster: 1d8 Energy, Ammunition (range 80/320), reload 12
\nSniper: 1d12 Energy, Ammunition (range 120/480), reload 4
\n
\nSpecial, Strength 13, Two-handed
","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":12,"price":7200,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":null,"width":null,"units":"","type":"space"},"range":{"value":80,"long":320,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"charges","target":"","amount":20},"ability":"","actionType":"rwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d8 + @mod","energy"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialB","properties":{"amm":true,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":false,"spc":false,"str":true,"thr":false,"two":true,"ver":false,"vic":false,"nodam":false,"fulldam":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":6650001,"flags":{},"img":"systems/sw5e/packs/Icons/Martial%20Blasters/IWS.webp","effects":[]}
{"_id":"y6faozksI3Bhwnpq","name":"Bowcaster","permission":{"default":0,"vXYkFWX6qzvOu2jc":3,"5TZqObbCr9nKC79s":3},"type":"weapon","data":{"description":{"value":"Burst 4, Reload 4, Strength 11
","chat":"","unidentified":""},"source":"PHB","quantity":1,"weight":16,"price":400,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"enemy"},"range":{"value":50,"long":200,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"charges","target":"","amount":1},"ability":"","actionType":"rwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d10 + @mod","energy"]],"versatile":""},"formula":"","save":{"ability":"","dc":null,"scaling":"power"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"simpleB","properties":{"amm":true,"aut":false,"bur":true,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":false,"dou":false,"fin":false,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":true,"ret":false,"shk":false,"sil":false,"spc":false,"str":true,"thr":false,"two":true,"ver":false,"vic":false,"mgc":false,"nodam":false,"faulldam":false,"fulldam":false},"proficient":false},"folder":"7rtfHBtXhhiRSS8u","sort":800001,"flags":{"dynamiceffects":{"equipActive":false,"alwaysActive":false}},"img":"systems/sw5e/packs/Icons/Simple%20Blasters/Bowcaster.webp","effects":[]}
{"_id":"yVxRMON2OWIGeU4n","name":"Disruptorshiv","permission":{"default":0,"MmfWtlBdw3ij5wl9":3},"type":"weapon","data":{"description":{"value":"Disruptive, Finesse, Shocking 13
","chat":"","unidentified":""},"source":"WH","quantity":1,"weight":1,"price":900,"attunement":0,"equipped":false,"rarity":"","identified":true,"activation":{"type":"action","cost":1,"condition":""},"duration":{"value":null,"units":""},"target":{"value":1,"width":null,"units":"","type":"creature"},"range":{"value":5,"long":null,"units":"ft"},"uses":{"value":0,"max":"0","per":""},"consume":{"type":"","target":"","amount":null},"ability":"","actionType":"mwak","attackBonus":"0","chatFlavor":"","critical":null,"damage":{"parts":[["1d4 + @mod","kinetic"]],"versatile":""},"formula":"1d4","save":{"ability":"dex","dc":13,"scaling":"flat"},"armor":{"value":10},"hp":{"value":0,"max":0,"dt":null,"conditions":""},"weaponType":"martialVW","properties":{"amm":false,"aut":false,"bur":false,"def":false,"dex":false,"dir":false,"drm":false,"dgd":false,"dis":false,"dpt":true,"dou":false,"fin":true,"fix":false,"foc":false,"hvy":false,"hid":false,"ken":false,"lgt":false,"lum":false,"mig":false,"pic":false,"rap":false,"rch":false,"rel":false,"ret":false,"shk":true,"sil":false,"spc":false,"str":false,"thr":false,"two":false,"ver":false,"vic":false,"nodam":false,"fulldam":false},"proficient":true},"folder":"7rtfHBtXhhiRSS8u","sort":6750001,"flags":{},"img":"systems/sw5e/packs/Icons/Martial%20Vibroweapons/Disruptorshiv.webp","effects":[]}
diff --git a/sw5e-dark.css b/sw5e-dark.css
index 424da398..5506c78c 100644
--- a/sw5e-dark.css
+++ b/sw5e-dark.css
@@ -797,3 +797,14 @@ body.dark-theme .sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hove
body.dark-theme .sw5e.sheet.actor.npc .swalt-sheet header .experience {
color: #4f4f4f;
}
+body.dark-theme .sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel-label {
+ background: #D6D6D6;
+ color: #1C1C1C;
+ border: 1px solid #1C1C1C;
+}
+body.dark-theme .sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel {
+ background: #c40f0f;
+}
+body.dark-theme .sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel .fuel-bar {
+ background: #0dce0d;
+}
\ No newline at end of file
diff --git a/sw5e-global.css b/sw5e-global.css
index 9f890ada..9c5943a1 100644
--- a/sw5e-global.css
+++ b/sw5e-global.css
@@ -1757,3 +1757,78 @@ input[type="reset"]:disabled {
transform: rotate(360deg);
}
}
+.sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper {
+ display: grid;
+ grid-template-columns: 300px 100px;
+ width: 400px;
+ justify-self: end;
+}
+.sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel-label {
+ font-size: 12px;
+ line-height: 14px;
+ width: 100%;
+ text-shadow: none;
+ padding: 0;
+ margin: 0;
+ height: auto;
+ text-align: center;
+ margin-left: -2px;
+ border-radius: 0 4px 4px 0;
+}
+.sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel {
+ position: relative;
+ border-radius: 4px;
+ height: 16px;
+ margin: 0;
+ width: 100%;
+}
+.sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel .fuel-bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ border-radius: 4px;
+ border: none;
+}
+input[type=range][orient=vertical] {
+ -webkit-appearance: slider-vertical;
+ width: 10px;
+ height: 60px !important;
+ padding: 0 0 !important;
+ background-color: #c40f0f !important;
+ box-sizing: border-box;
+}
+input[type=range][orient=vertical]::-webkit-slider-runnable-track {
+ -webkit-appearance: slider-vertical !important;
+ height: 60px !important;
+ width: 10px !important;
+ line-height: 60px !important;
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+ margin-top: 0 0 !important;
+ border-radius: 3px !important;
+ background: linear-gradient(
+ to top,
+ #c40f0f 50%,
+ #0dce0d 50%
+ );
+}
+input[type=range][orient=vertical]::-webkit-slider-thumb {
+ -webkit-appearance: none !important;
+ background-color: #c40f0f !important;
+ margin-right: -4px !important;
+ margin-top: 0px !important;
+ cursor: grab !important;
+ border-radius: 0 0 0 0 !important;
+ width: 10px !important;
+ height: 5px !important;
+ font-size: 10px;
+}
+output {
+ display: block;
+ margin: 5px auto;
+ font-size:1.75em;
+}
+input .vertslider {
+ height: 60px;
+}
\ No newline at end of file
diff --git a/sw5e-light.css b/sw5e-light.css
index 20f09b81..d1635367 100644
--- a/sw5e-light.css
+++ b/sw5e-light.css
@@ -784,3 +784,14 @@ body.light-theme .sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hov
body.light-theme .sw5e.sheet.actor.npc .swalt-sheet header .experience {
color: #4f4f4f;
}
+body.light-theme .sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel-label {
+ background: #D6D6D6;
+ color: #1C1C1C;
+ border: 1px solid #1C1C1C;
+}
+body.light-theme .sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel {
+ background: #c40f0f;
+}
+body.light-theme .sw5e.sheet.actor .swalt-sheet .panel.resources .traits .fuel-wrapper .fuel .fuel-bar {
+ background: #0dce0d;
+}
\ No newline at end of file
diff --git a/sw5e.css b/sw5e.css
index 51b2e6f4..e41c90da 100644
--- a/sw5e.css
+++ b/sw5e.css
@@ -429,6 +429,7 @@
list-style: none;
margin: 0;
padding: 0;
+ display: block;
}
.sw5e.sheet .items-list .item-name {
flex: 2;
diff --git a/sw5e.js b/sw5e.js
index 5bef2a6f..91c8e45a 100644
--- a/sw5e.js
+++ b/sw5e.js
@@ -140,11 +140,11 @@ Hooks.once("init", function() {
makeDefault: false,
label: "SW5E.SheetClassNPCOld"
});
- // Actors.registerSheet("sw5e", ActorSheet5eStarship, {
- // types: ["starship"],
- // makeDefault: true,
- // label: "SW5E.SheetClassStarship"
- // });
+ Actors.registerSheet("sw5e", ActorSheet5eStarship, {
+ types: ["starship"],
+ makeDefault: true,
+ label: "SW5E.SheetClassStarship"
+ });
Actors.registerSheet('sw5e', ActorSheet5eVehicle, {
types: ['vehicle'],
makeDefault: true,
@@ -175,16 +175,15 @@ Hooks.once("setup", function() {
const toLocalize = [
"abilities", "abilityAbbreviations", "abilityActivationTypes", "abilityConsumptionTypes", "actorSizes", "alignments",
"armorProficiencies", "armorPropertiesTypes", "conditionTypes", "consumableTypes", "cover", "currencies", "damageResistanceTypes",
- "damageTypes", "distanceUnits", "equipmentTypes", "healingTypes", "itemActionTypes", "languages",
+ "damageTypes", "deploymentTypes", "distanceUnits", "equipmentTypes", "healingTypes", "itemActionTypes", "languages",
"limitedUsePeriods", "movementTypes", "movementUnits", "polymorphSettings", "proficiencyLevels", "senses", "skills",
- "starshipRolessm", "starshipRolesmed", "starshipRoleslg", "starshipRoleshuge", "starshipRolesgrg", "starshipSkills",
- "powerComponents", "powerLevels", "powerPreparationModes", "powerScalingModes", "powerSchools", "targetTypes",
+ "starshipSkills", "powerComponents", "powerLevels", "powerPreparationModes", "powerScalingModes", "powerSchools", "targetTypes",
"timePeriods", "toolProficiencies", "weaponProficiencies", "weaponProperties", "weaponSizes", "weaponTypes"
];
// Exclude some from sorting where the default order matters
const noSort = [
- "abilities", "alignments", "currencies", "distanceUnits", "movementUnits", "itemActionTypes", "proficiencyLevels",
+ "abilities", "alignments", "currencies", "deploymentTypes", "distanceUnits", "movementUnits", "itemActionTypes", "proficiencyLevels",
"limitedUsePeriods", "powerComponents", "powerLevels", "powerPreparationModes", "weaponTypes"
];
@@ -288,6 +287,14 @@ Handlebars.registerHelper('getProperty', function (data, property) {
return getProperty(data, property);
});
+Handlebars.registerHelper('round', function(value) {
+ return Math.floor(value);
+});
+
+Handlebars.registerHelper('debug', function(value) {
+ console.log(value)
+ return value;
+})
function setFolderBackground(html) {
html.find("header.folder-header").each(function() {
diff --git a/system.json b/system.json
index 3a0a82ca..ca98cd25 100644
--- a/system.json
+++ b/system.json
@@ -109,6 +109,42 @@
"label": "Species Traits",
"path": "./packs/packs/speciestraits.db",
"entity": "Item"
+ },
+ {
+ "name": "starshiparmor",
+ "label": "Starship Armor",
+ "path": "./packs/packs/starshiparmor.db",
+ "entity": "Item"
+ },
+ {
+ "name": "starshipequipment",
+ "label": "Starship Equipment",
+ "path": "./packs/packs/starshipequipment.db",
+ "entity": "Item"
+ },
+ {
+ "name": "starshipfeatures",
+ "label": "Starship Features",
+ "path": "./packs/packs/starshipfeatures.db",
+ "entity": "Item"
+ },
+ {
+ "name": "starshipmodifications",
+ "label": "Starship Modifications",
+ "path": "./packs/packs/starshipmodifications.db",
+ "entity": "Item"
+ },
+ {
+ "name": "starships",
+ "label": "Starship Types",
+ "path": "./packs/packs/starships.db",
+ "entity": "Item"
+ },
+ {
+ "name": "starshipweapons",
+ "label": "Starship Weapons",
+ "path": "./packs/packs/starshipweapons.db",
+ "entity": "Item"
},
{
"name": "tables",
diff --git a/template.json b/template.json
index 1681d52e..2eb68c9a 100644
--- a/template.json
+++ b/template.json
@@ -1,6 +1,6 @@
{
"Actor": {
- "types": ["character", "npc", "vehicle"],
+ "types": ["character", "npc", "starship", "vehicle"],
"templates": {
"common": {
"abilities": {
@@ -37,8 +37,8 @@
"value": 10,
"min": 0,
"max": 10,
- "temp": 0,
- "tempmax": 0
+ "temp": null,
+ "tempmax": null
},
"init": {
"value": 0,
@@ -89,6 +89,15 @@
},
"creature": {
"attributes": {
+ "rank": {
+ "total": 0,
+ "coord": 0,
+ "gunner": 0,
+ "mechanic": 0,
+ "operator": 0,
+ "pilot": 0,
+ "technician": 0
+ },
"senses": {
"darkvision": 0,
"blindsight": 0,
@@ -416,31 +425,119 @@
"starship": {
"templates": ["common"],
"attributes": {
- "cargcap": 0,
- "crewcap": 0,
- "cscap": 0,
+ "cost": {
+ "baseBuild": 0,
+ "baseUpgrade": 0,
+ "multEquip": 0,
+ "multModification": 0,
+ "multUpgrade": 0
+ },
"death": {
"failure": 0,
"success": 0
},
- "dr": 0,
- "engpow": 1,
- "exhaustion": 0,
- "hsm": 1,
+ "deployment": {
+ "coord": {
+ "uuid": null,
+ "name": null,
+ "rank": null,
+ "prof": null
+ },
+ "gunner": {
+ "uuid": null,
+ "name": null,
+ "rank": null,
+ "prof": null
+ },
+ "mechanic": {
+ "uuid": null,
+ "name": null,
+ "rank": null,
+ "prof": null
+ },
+ "operator": {
+ "uuid": null,
+ "name": null,
+ "rank": null,
+ "prof": null
+ },
+ "pilot": {
+ "uuid": null,
+ "name": null,
+ "rank": null,
+ "prof": null
+ },
+ "technician": {
+ "uuid": null,
+ "name": null,
+ "rank": null,
+ "prof": null
+ },
+ "crew": [],
+ "passenger": []
+ },
+ "equip": {
+ "armor": {
+ "dr": 0,
+ "maxDex": 99,
+ "stealthDisadv": false
+ },
+ "hyperdrive": {
+ "class": null
+ },
+ "powerCoupling": {
+ "centralCap": 0,
+ "systemCap": 0
+ },
+ "reactor": {
+ "fuelMult": 1,
+ "powerRecDie": "1"
+ },
+ "size": {
+ "cargoCap": 0,
+ "crewMinWorkforce": 0,
+ "foodCap": 0
+ },
+ "shields": {
+ "capMult": 1,
+ "regenRateMult": 1
+ }
+ },
+ "systemDamage": 0,
+ "fuel": {
+ "cap": 0,
+ "cost": 0,
+ "value": 0
+ },
"hull": {
"die": "",
"dice": 0,
+ "dicemax": 0,
"formula":"",
"value": null,
"max": null
},
"mods": {
- "open": 10,
- "max": 10
+ "capUsed": 0,
+ "capLimit": 10,
+ "hardpoints":{
+ "open": 0,
+ "max": 0
+ },
+ "installed": 0,
+ "suites": {
+ "open": 0,
+ "max": 0,
+ "cap": 0
+ }
},
- "pwrdice": {
- "pwrdie": "",
- "recovery": 1,
+ "power": {
+ "die": "",
+ "routing":{
+ "engines": 1,
+ "shields": 1,
+ "weapons": 1
+ },
"central": {
"value": 0,
"max": 0
@@ -469,21 +566,24 @@
"shld": {
"die": "",
"dice": 0,
+ "dicemax": 0,
+ "depleted": false,
"formula":"",
"value": null,
"max": null
},
- "shieldpow": 1,
- "sscap": 0,
- "suites": {
- "open": 0,
- "max": 0
- },
- "weaponpow": 1
+ "used": false,
+ "workforce": {
+ "max": 0,
+ "minBuild": 0,
+ "minEquip": 0,
+ "minModification": 0,
+ "minUpgrade": 0
+ }
},
"details": {
"tier": 0,
- "role": "",
+ "role": [],
"source": ""
},
"skills": {
@@ -507,7 +607,7 @@
"value": 0,
"ability": "cha"
},
- "int": {
+ "inf": {
"value": 0,
"ability": "cha"
},
@@ -545,7 +645,7 @@
}
},
"traits": {
- "size": "med"
+ "size": null
}
},
"vehicle": {
@@ -832,7 +932,7 @@
"capx": {
"value": null
},
- "hpperhd": {
+ "dmgred": {
"value": null
},
"regrateco": {
@@ -1015,12 +1115,34 @@
"size": "",
"tier": 0,
"hullDice": "d6",
- "hullDiceStart": 1,
+ "hullDiceStart": 3,
+ "hullDiceRolled":[6,4,4],
"hullDiceUsed": 0,
"shldDice": "d6",
- "shldDiceStart": 1,
+ "shldDiceStart": 3,
+ "shldDiceRolled":[6,4,4],
"shldDiceUsed": 0,
"pwrDice": "1",
+ "buildBaseCost": 50000,
+ "buildMinWorkforce": 5,
+ "upgrdCostMult": 1,
+ "upgrdMinWorkforce": 1,
+ "baseSpaceSpeed": 300,
+ "baseTurnSpeed": 250,
+ "crewMinWorkforce": 1,
+ "modBaseCap": 20,
+ "modMaxSuitesBase": 0,
+ "modMaxSuitesMult": 1,
+ "modMaxSuiteCap": 1,
+ "modCostMult": 1,
+ "modMinWorkforce": 2,
+ "hardpointMult": 2,
+ "equipCostMult": 1,
+ "equipMinWorkforce": 1,
+ "cargoCap": 2,
+ "fuelCost": 50,
+ "fuelCap": 10,
+ "foodCap": 10,
"source": "SotG"
},
"starshipfeature": {
diff --git a/templates/actors/newActor/parts/swalt-crew.html b/templates/actors/newActor/parts/swalt-crew.html
index 16c50787..423f7cc4 100644
--- a/templates/actors/newActor/parts/swalt-crew.html
+++ b/templates/actors/newActor/parts/swalt-crew.html
@@ -8,6 +8,26 @@
{{localize "SW5E.Reaction"}}
+
+ Coordinator: {{data.attributes.deployment.coord.name}}
+ Rank: {{data.attributes.deployment.coord.rank}}
+ Prof: {{data.attributes.deployment.coord.prof}}
+ Gunner: {{data.attributes.deployment.gunner.name}}
+ Rank: {{data.attributes.deployment.gunner.rank}}
+ Prof: {{data.attributes.deployment.gunner.prof}}
+ Mechanic: {{data.attributes.deployment.mechanic.name}}
+ Rank: {{data.attributes.deployment.mechanic.rank}}
+ Prof: {{data.attributes.deployment.mechanic.prof}}
+ Operator: {{data.attributes.deployment.operator.name}}
+ Rank: {{data.attributes.deployment.operator.rank}}
+ Prof: {{data.attributes.deployment.operator.prof}}
+ Pilot: {{data.attributes.deployment.pilot.name}}
+ Rank: {{data.attributes.deployment.pilot.rank}}
+ Prof: {{data.attributes.deployment.pilot.prof}}
+ Technician: {{data.attributes.deployment.technician.name}}
+ Rank: {{data.attributes.deployment.technician.rank}}
+ Prof: {{data.attributes.deployment.technician.prof}}
+
{{#each sections as |section sid|}}
diff --git a/templates/actors/newActor/starship.html b/templates/actors/newActor/starship.html
index 5738742c..3a5b9d32 100644
--- a/templates/actors/newActor/starship.html
+++ b/templates/actors/newActor/starship.html
@@ -14,14 +14,8 @@
-
{{lookup config.actorSizes data.traits.size}}
-
- {{lookup config.starshipRolessm data.details.role}}
-
+
{{!-- ARMOR CLASS --}}
@@ -31,10 +25,11 @@
-
+
{{!-- HULL POINTS --}}
@@ -42,14 +37,15 @@
{{ localize "SW5E.HullPoints" }}
+ data-dtype="Number" class="value-number" />
/
+ data-dtype="Number" class="value-number" />
-