Updating to Push Class skills on level up

Update from DND5E beta for Class Skills on level up and other various fixups
This commit is contained in:
supervj 2020-10-06 00:45:33 -04:00
parent 67ba5b2d2d
commit 53d7284596
36 changed files with 1537 additions and 440 deletions

View file

@ -64,13 +64,6 @@ export default class Actor5e extends Actor {
/** @override */
prepareBaseData() {
// Compute initial ability score modifiers in base data since these may be referenced
for (let abl of Object.values(this.data.data.abilities)) {
abl.mod = Math.floor((abl.value - 10) / 2);
}
// Type-specific base data preparation
switch ( this.data.type ) {
case "character":
return this._prepareCharacterData(this.data);
@ -107,6 +100,7 @@ export default class Actor5e extends Actor {
}
// 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)) {
@ -115,6 +109,7 @@ export default class Actor5e extends Actor {
abl.saveBonus = saveBonus;
abl.checkBonus = checkBonus;
abl.save = abl.mod + abl.prof + abl.saveBonus;
abl.dc = 8 + abl.mod + abl.prof + dcBonus;
// If we merged saves when transforming, take the highest bonus here.
if (originalSaves && abl.proficient) {
@ -135,7 +130,7 @@ export default class Actor5e extends Actor {
init.total = init.mod + init.prof + init.bonus;
// Prepare power-casting data
data.attributes.powerdc = this.getPowerDC(data.attributes.powercasting);
this._computePowercastingDC(this.data);
this._computePowercastingProgression(this.data);
}
@ -165,22 +160,6 @@ export default class Actor5e extends Actor {
/* -------------------------------------------- */
/**
* Return the power DC for this actor using a certain ability score
* @param {string} ability The ability score, i.e. "str"
* @return {number} The power DC
*/
getPowerDC(ability) {
const actorData = this.data.data;
let bonus = getProperty(actorData, "bonuses.power.dc");
bonus = Number.isNumeric(bonus) ? parseInt(bonus) : 0;
ability = actorData.abilities[ability];
const prof = actorData.attributes.prof;
return 8 + (ability ? ability.mod : 0) + prof + bonus;
}
/* -------------------------------------------- */
/** @override */
getRollData() {
const data = super.getRollData();
@ -194,6 +173,101 @@ export default class Actor5e extends Actor {
return data;
}
/* -------------------------------------------- */
/**
* Return the features which a character is awarded for each class level
* @param cls {Object} Data object for class, equivalent to Item5e.data or raw compendium entry
* @return {Promise<Item5e[]>} Array of Item5e entities
*/
static async getClassFeatures(cls) {
const level = cls.data.levels;
const className = cls.name.toLowerCase();
// Get the configuration of features which may be added
const clsConfig = CONFIG.SW5E.classFeatures[className];
let featureIDs = clsConfig["features"][level] || [];
const subclassName = cls.data.subclass.toLowerCase().slugify();
// Identify subclass features
if ( subclassName !== "" ) {
const subclassConfig = clsConfig["subclasses"][subclassName];
if ( subclassConfig !== undefined ) {
const subclassFeatureIDs = subclassConfig["features"][level];
if ( subclassFeatureIDs ) {
featureIDs = featureIDs.concat(subclassFeatureIDs);
}
}
else console.warn("Invalid subclass: " + subclassName);
}
// Load item data for all identified features
const features = await Promise.all(featureIDs.map(id => 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 classData = duplicate(item.data);
let changed = false;
// Get and create features for an increased class level
const newLevels = getProperty(u, "data.levels");
if (newLevels && (newLevels > item.data.data.levels)) {
classData.data.levels = newLevels;
changed = true;
}
// Get features for a newly changed subclass
const newSubclass = getProperty(u, "data.subclass");
if (newSubclass && (newSubclass !== item.data.data.subclass)) {
classData.data.subclass = newSubclass;
changed = true;
}
// Get the new features
if ( changed ) {
const features = await Actor5e.getClassFeatures(classData);
if ( features.length ) toCreate.push(...features);
}
}
// De-dupe created items with ones that already exist (by name)
if ( toCreate.length ) {
const existing = new Set(this.items.map(i => i.name));
toCreate = toCreate.filter(c => !existing.has(c.name));
}
return toCreate
}
/* -------------------------------------------- */
/* Data Preparation Helpers */
/* -------------------------------------------- */
@ -313,6 +387,31 @@ export default class Actor5e extends Actor {
/* -------------------------------------------- */
/**
* Compute the powercasting DC for all item abilities which use power DC scaling
* @param {object} actorData The actor data being prepared
* @private
*/
_computePowercastingDC(actorData) {
// Compute the powercasting DC
const data = actorData.data;
data.attributes.powerdc = data.attributes.powercasting ? data.abilities[data.attributes.powercasting].dc : 10;
// Apply powercasting DC to any power items which use it
for ( let i of this.items ) {
const save = i.data.data.save;
if ( save?.ability ) {
if ( save.scaling === "power" ) save.dc = data.attributes.powerdc;
else if ( save.scaling !== "flat" ) save.dc = data.abilities[save.scaling]?.dc ?? 10;
const ability = CONFIG.SW5E.abilities[save.ability];
i.labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability});
}
}
}
/* -------------------------------------------- */
/**
* Prepare data related to the power-casting capabilities of the Actor
* @private
@ -419,7 +518,7 @@ export default class Actor5e extends Actor {
// [Optional] add Currency Weight
if ( game.settings.get("sw5e", "currencyWeight") ) {
const currency = actorData.data.currency;
const numCoins = Object.values(currency).reduce((val, denom) => val += denom, 0);
const numCoins = Object.values(currency).reduce((val, denom) => val += Math.max(denom, 0), 0);
weight += Math.round((numCoins * 10) / CONFIG.SW5E.encumbrance.currencyPerWeight) / 10;
}
@ -591,12 +690,12 @@ export default class Actor5e extends Actor {
// Update Actor data
if ( usesSlots && consumeSlot && (lvl > 0) ) {
const slots = parseInt(this.data.data.powers[consumeSlot].value);
const slots = parseInt(this.data.data.powers[consumeSlot]?.value);
if ( slots === 0 || Number.isNaN(slots) ) {
return ui.notifications.error(game.i18n.localize("SW5E.PowerCastNoSlots"));
}
await this.update({
[`data.powers.${consumeSlot}.value`]: Math.max(parseInt(this.data.data.powers[consumeSlot].value) - 1, 0)
[`data.powers.${consumeSlot}.value`]: Math.max(slots - 1, 0)
});
}
@ -610,7 +709,7 @@ export default class Actor5e extends Actor {
// Initiate ability template placement workflow if selected
if ( placeTemplate && item.hasAreaTarget ) {
const template = AbilityTemplate.fromItem(item);
if ( template ) template.drawPreview(event);
if ( template ) template.drawPreview();
if ( this.sheet.rendered ) this.sheet.minimize();
}
@ -1004,19 +1103,26 @@ export default class Actor5e extends Actor {
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("SW5E.ShortRestNormal"); break;
case 'gritty': restFlavor = game.i18n.localize(newDay ? "SW5E.ShortRestOvernight" : "SW5E.ShortRestGritty"); break;
case 'epic': restFlavor = game.i18n.localize("SW5E.ShortRestEpic"); break;
}
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)) srMessage = "SW5E.ShortRestResult";
// Create a chat message
ChatMessage.create({
user: game.user._id,
speaker: {actor: this, alias: this.name},
flavor: restFlavor,
content: game.i18n.format("SW5E.ShortRestResult", {name: this.name, dice: -dhd, health: dhp})
content: game.i18n.format(srMessage, {name: this.name, dice: -dhd, health: dhp})
});
}
@ -1036,13 +1142,13 @@ export default class Actor5e extends Actor {
* Take a long rest, recovering HP, HD, resources, 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}={}) {
async longRest({dialog=true, chat=true, newDay=true}={}) {
const data = this.data.data;
// Maybe present a confirmation dialog
let newDay = false;
if ( dialog ) {
try {
newDay = await LongRestDialog.longRestDialog({actor: this});
@ -1120,12 +1226,17 @@ export default class Actor5e extends Actor {
case 'epic': restFlavor = game.i18n.localize("SW5E.LongRestEpic"); break;
}
// Determine the chat message to display
if ( chat ) {
let lrMessage = "SW5E.LongRestResultShort";
if((dhp !== 0) && (dhd !== 0)) lrMessage = "SW5E.LongRestResult";
else if ((dhp !== 0) && (dhd === 0)) lrMessage = "SW5E.LongRestResultHitPoints";
else if ((dhp === 0) && (dhd !== 0)) lrMessage = "SW5E.LongRestResultHitDice";
ChatMessage.create({
user: game.user._id,
speaker: {actor: this, alias: this.name},
flavor: restFlavor,
content: game.i18n.format("SW5E.LongRestResult", {name: this.name, health: dhp, dice: dhd})
content: game.i18n.format(lrMessage, {name: this.name, health: dhp, dice: dhd})
});
}
@ -1356,4 +1467,16 @@ export default class Actor5e extends Actor {
}
});
}
/* -------------------------------------------- */
/* 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;
}
}

View file

@ -163,18 +163,18 @@ export default class ActorSheet5e extends ActorSheet {
};
// Format a powerbook entry for a certain indexed level
const registerSection = (sl, i, label, level={}) => {
const registerSection = (sl, i, label, {prepMode="prepared", value, max, override}={}) => {
powerbook[i] = {
order: i,
label: label,
usesSlots: i > 0,
canCreate: owner && (i >= 1),
canCreate: owner,
canPrepare: (data.actor.type === "character") && (i >= 1),
powers: [],
uses: useLabels[i] || level.value || 0,
slots: useLabels[i] || level.max || 0,
override: level.override || 0,
dataset: {"type": "power", "level": i},
uses: useLabels[i] || value || 0,
slots: useLabels[i] || max || 0,
override: override || 0,
dataset: {"type": "power", "level": prepMode in sections ? 1 : i, "preparation.mode": prepMode},
prop: sl
};
};
@ -187,7 +187,7 @@ export default class ActorSheet5e extends ActorSheet {
return max;
}, 0);
// Structure the powerbook for every level up to the maximum which has a slot
// Level-based powercasters have cantrips and leveled slots
if ( maxLevel > 0 ) {
registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]);
for (let lvl = 1; lvl <= maxLevel; lvl++) {
@ -195,9 +195,18 @@ export default class ActorSheet5e extends ActorSheet {
registerSection(sl, lvl, CONFIG.SW5E.powerLevels[lvl], levels[sl]);
}
}
// Pact magic users have cantrips and a pact magic section
if ( levels.pact && levels.pact.max ) {
registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]);
registerSection("pact", sections.pact, CONFIG.SW5E.powerPreparationModes.pact, levels.pact);
if ( !powerbook["0"] ) registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]);
const l = levels.pact;
const config = CONFIG.SW5E.powerPreparationModes.pact;
registerSection("pact", sections.pact, config, {
prepMode: "pact",
value: l.value,
max: l.max,
override: l.override
});
}
// Iterate over every power item, adding powers to the powerbook by section
@ -206,17 +215,24 @@ export default class ActorSheet5e extends ActorSheet {
let s = power.data.level || 0;
const sl = `power${s}`;
// Powercasting mode specific headings
// Specialized powercasting modes (if they exist)
if ( mode in sections ) {
s = sections[mode];
if ( !powerbook[s] ){
registerSection(mode, s, CONFIG.SW5E.powerPreparationModes[mode], levels[mode]);
const l = levels[mode] || {};
const config = CONFIG.SW5E.powerPreparationModes[mode];
registerSection(mode, s, config, {
prepMode: mode,
value: l.value,
max: l.max,
override: l.override
});
}
}
// Higher-level power headings
// Sections for higher-level powers which the caster "should not" have, but power items exist for
else if ( !powerbook[s] ) {
registerSection(sl, s, CONFIG.SW5E.powerLevels[s], levels[sl]);
registerSection(sl, s, CONFIG.SW5E.powerLevels[s], {levels: levels[sl]});
}
// Add the power to the relevant heading
@ -508,13 +524,6 @@ export default class ActorSheet5e extends ActorSheet {
itemData = scroll.data;
}
// Upgrade the number of class levels a character has
if ( (itemData.type === "class") && ( this.actor.itemTypes.class.find(c => c.name === itemData.name)) ) {
const cls = this.actor.itemTypes.class.find(c => c.name === itemData.name);
const lvl = cls.data.data.levels;
return cls.update({"data.levels": Math.min(lvl + 1, 20 + lvl - this.actor.data.data.details.level)})
}
// Create the owned item as normal
// TODO remove conditional logic in 0.7.x
if (isNewerVersion(game.data.version, "0.6.9")) return super._onDropItemCreate(itemData);
@ -843,4 +852,4 @@ export default class ActorSheet5e extends ActorSheet {
// Return a copy of the extracted data
return duplicate(itemData);
}
}
}

View file

@ -1,4 +1,5 @@
import ActorSheet5e from "./base.js";
import Actor5e from "../entity.js";
/**
* An Actor sheet for player character type actors in the SW5E system.
@ -253,4 +254,32 @@ export default class ActorSheet5eCharacter extends ActorSheet5e {
yes: () => this.actor.convertCurrency()
});
}
/* -------------------------------------------- */
/** @override */
async _onDropItemCreate(itemData) {
// Upgrade the number of class levels a character has
// and add features
if ( itemData.type === "class" ) {
const cls = this.actor.itemTypes.class.find(c => c.name === itemData.name);
const classWasAlreadyPresent = !!cls;
// Add new features for class level
if ( !classWasAlreadyPresent ) {
Actor5e.getClassFeatures(itemData).then(features => {
this.actor.createEmbeddedEntity("OwnedItem", features);
});
}
// If the actor already has the class, increment the level instead of creating a new item
if ( classWasAlreadyPresent ) {
const lvl = cls.data.data.levels;
return cls.update({"data.levels": Math.min(lvl + 1, 20 + lvl - this.actor.data.data.details.level)})
}
}
super._onDropItemCreate(itemData);
}
}

View file

@ -51,8 +51,8 @@ export default class AbilityUseDialog extends Dialog {
const html = await renderTemplate("systems/sw5e/templates/apps/ability-use.html", data);
// Create the Dialog and return as a Promise
const icon = data.hasPowerSlots ? "fa-magic" : "fa-fist-raised";
const label = game.i18n.localize("SW5E.AbilityUse" + (data.hasPowerSlots ? "Cast" : "Use"));
const icon = data.isPower ? "fa-magic" : "fa-fist-raised";
const label = game.i18n.localize("SW5E.AbilityUse" + (data.isPower ? "Cast" : "Use"));
return new Promise((resolve) => {
const dlg = new this(item, {
title: `${item.name}: Usage Configuration`,
@ -85,6 +85,12 @@ export default class AbilityUseDialog extends Dialog {
const lvl = itemData.level;
const canUpcast = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(itemData.preparation.mode);
// If can't upcast, return early and don't bother calculating available power slots
if (!canUpcast) {
data = mergeObject(data, { isPower: true, canUpcast });
return;
}
// Determine the levels which are feasible
let lmax = 0;
const powerLevels = Array.fromRange(10).reduce((arr, i) => {
@ -97,7 +103,7 @@ export default class AbilityUseDialog extends Dialog {
arr.push({
level: i,
label: i > 0 ? game.i18n.format('SW5E.PowerLevelSlot', {level: label, n: slots}) : label,
canCast: canUpcast && (max > 0),
canCast: max > 0,
hasSlots: slots > 0
});
return arr;
@ -109,14 +115,14 @@ export default class AbilityUseDialog extends Dialog {
powerLevels.push({
level: 'pact',
label: `${game.i18n.format('SW5E.PowerLevelPact', {level: pact.level, n: pact.value})}`,
canCast: canUpcast,
canCast: true,
hasSlots: pact.value > 0
});
}
const canCast = powerLevels.some(l => l.hasSlots);
// Return merged data
data = mergeObject(data, { hasPowerSlots: true, canUpcast, powerLevels });
data = mergeObject(data, { isPower: true, canUpcast, powerLevels });
if ( !canCast ) data.errors.push("SW5E.PowerCastNoSlots");
}

View file

@ -1,102 +0,0 @@
/**
* A specialized Dialog subclass for casting a power item at a certain level
* @type {Dialog}
*/
export class PowerCastDialog extends Dialog {
constructor(actor, item, dialogData={}, options={}) {
super(dialogData, options);
this.options.classes = ["sw5e", "dialog"];
/**
* Store a reference to the Actor entity which is casting the power
* @type {Actor5e}
*/
this.actor = actor;
/**
* Store a reference to the Item entity which is the power being cast
* @type {Item5e}
*/
this.item = item;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/**
* A constructor function which displays the Power Cast Dialog app for a given Actor and Item.
* Returns a Promise which resolves to the dialog FormData once the workflow has been completed.
* @param {Actor5e} actor
* @param {Item5e} item
* @return {Promise}
*/
static async create(actor, item) {
const ad = actor.data.data;
const id = item.data.data;
// Determine whether the power may be upcast
const lvl = id.level;
const canUpcast = (lvl > 0) && CONFIG.SW5E.powerUpcastModes.includes(id.preparation.mode);
// Determine the levels which are feasible
let lmax = 0;
const powerLevels = Array.fromRange(10).reduce((arr, i) => {
if ( i < lvl ) return arr;
const l = ad.powers["power"+i] || {max: 0, override: null};
let max = parseInt(l.override || l.max || 0);
let slots = Math.clamped(parseInt(l.value || 0), 0, max);
if ( max > 0 ) lmax = i;
arr.push({
level: i,
label: i > 0 ? `${CONFIG.SW5E.powerLevels[i]} (${slots} Slots)` : CONFIG.SW5E.powerLevels[i],
canCast: canUpcast && (max > 0),
hasSlots: slots > 0
});
return arr;
}, []).filter(sl => sl.level <= lmax);
const pact = ad.powers.pact;
if (pact.level >= lvl) {
// If this character has pact slots, present them as an option for
// casting the power.
powerLevels.push({
level: 'pact',
label: game.i18n.localize('SW5E.PowerLevelPact')
+ ` (${game.i18n.localize('SW5E.Level')} ${pact.level}) `
+ `(${pact.value} ${game.i18n.localize('SW5E.Slots')})`,
canCast: canUpcast,
hasSlots: pact.value > 0
});
}
const canCast = powerLevels.some(l => l.hasSlots);
// Render the Power casting template
const html = await renderTemplate("systems/sw5e/templates/apps/power-cast.html", {
item: item.data,
canCast: canCast,
canUpcast: canUpcast,
powerLevels,
hasPlaceableTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget
});
// Create the Dialog and return as a Promise
return new Promise((resolve, reject) => {
const dlg = new this(actor, item, {
title: `${item.name}: Power Configuration`,
content: html,
buttons: {
cast: {
icon: '<i class="fas fa-magic"></i>',
label: "Cast",
callback: html => resolve(new FormData(html[0].querySelector("#power-config-form")))
}
},
default: "cast",
close: reject
});
dlg.render(true);
});
}
}

View file

@ -33,6 +33,7 @@ export const highlightCriticalSuccessFailure = function(message, html, data) {
export const displayChatActionButtons = function(message, html, data) {
const chatCard = html.find(".sw5e.chat-card");
if ( chatCard.length > 0 ) {
html.find(".flavor-text").remove();
// If the user is the message author or the actor owner, proceed
let actor = game.actors.get(data.message.speaker.actor);

694
module/classFeatures.js Normal file
View file

@ -0,0 +1,694 @@
export const ClassFeatures = {
/* These are the class features for DND5E left in for reference to help build SW5E Class features
// Remove once SW5E Class features are added.
"barbarian": {
"subclasses": {
"path-of-the-ancestral-guardian": {
"label": "Path of the Ancestral Guardian",
"source": "XGE pg. 9"
},
"path-of-the-battlerager": {
"label": "Path of the Battlerager",
"source": "SCAG pg. 121"
},
"path-of-the-berserker": {
"label": "Path of the Berserker",
"source": "PHB pg. 49",
"features": {
"3": ["Compendium.sw5e.classfeatures.CkbbAckeCtyHXEnL"],
"6": ["Compendium.sw5e.classfeatures.0Jgf8fYY2ExwgQpN"],
"10": ["Compendium.sw5e.classfeatures.M6VSMzVtKPhh8B0i"],
"14": ["Compendium.sw5e.classfeatures.xzD9zlRP6dUxCtCl"]
}
},
"path-of-the-juggernaut": {
"label": "Path of the Juggernaut",
"source": "TCS pg. 102"
},
"path-of-the-storm-herald": {
"label": "Path of the Storm Herald",
"source": "XGE pg. 10"
},
"path-of-the-totem-warrior": {
"label": "Path of the Totem Warrior",
"source": "PHB pg. 50; SCAG pg. 121"
},
"path-of-the-zealot": {
"label": "Path of the Zealot",
"source": "XGE pg. 11"
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.VoR0SUrNX5EJVPIO", "Compendium.sw5e.classfeatures.SZbsNbaxFFGwBpNK"],
"2": ["Compendium.sw5e.classfeatures.SCVjqRdlZ9cvHVSR", "Compendium.sw5e.classfeatures.vt31lWAULygEl7yk"],
"3": ["Compendium.sw5e.classfeatures.TH1QAf6YNGSeBVjT"],
"5": ["Compendium.sw5e.classfeatures.XogoBnFWmCAHXppo", "Compendium.sw5e.classfeatures.Kl6zifJ5OmdHlOi2"],
"7": ["Compendium.sw5e.classfeatures.NlXslw4yAqmKZWtN"],
"9": ["Compendium.sw5e.classfeatures.L94gyvNpUhUe0rwh"],
"11": ["Compendium.sw5e.classfeatures.FqfmbPgxiyrWzhYk"],
"15": ["Compendium.sw5e.classfeatures.l8tUhZ5Pecm9wz7I"],
"18": ["Compendium.sw5e.classfeatures.Q1exex5ALteprrPo"],
"20": ["Compendium.sw5e.classfeatures.jVU4AgqfrFaqgXns"]
}
},
"bard": {
"subclasses": {
"college-of-glamour": {
"label": "College of Glamour",
"source": "XGE pg. 14",
"features": {}
},
"college-of-lore": {
"label": "College of Lore",
"source": "PHB pg. 54",
"features": {
"3": ["Compendium.sw5e.classfeatures.5zPmHPQUne7RDfaU"],
"6": ["Compendium.sw5e.classfeatures.myBu3zi5eYvQIcuy"],
"14": ["Compendium.sw5e.classfeatures.pquwueEMweRhiWaq"]
}
},
"college-of-swords": {
"label": "College of Swords",
"source": "XGE pg. 15",
"features": {}
},
"college-of-valor": {
"label": "College of Valor",
"source": "PHB pg. 55",
"features": {}
},
"college-of-whispers": {
"label": "College of Whispers",
"source": "XGE pg. 16",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.hpLNiGq7y67d2EHA", "Compendium.sw5e.classfeatures.u4NLajXETJhJU31v"],
"2": ["Compendium.sw5e.classfeatures.ezWijmCnlnQ9ZRX2", "Compendium.sw5e.classfeatures.he8RpPXwSl2lVSIk"],
"3": ["Compendium.sw5e.classfeatures.ILhzFHiRrqgQ9dFJ", "Compendium.sw5e.classfeatures.aQLg7BWdRnm4Hr9S"],
"5": ["Compendium.sw5e.classfeatures.3VDZGs5Ug3hIE322"],
"6": ["Compendium.sw5e.classfeatures.SEJmsjkEhdAZ90ki"],
"10": ["Compendium.sw5e.classfeatures.aonJ2YjkqkYB9WYB"],
"20": ["Compendium.sw5e.classfeatures.GBYN5rH4nh1ocRlY"]
}
},
"cleric": {
"subclasses": {
"ambition-domain": {
"label": "Ambition Domain",
"source": "PS:A pg. 27",
"features": {}
},
"arcana-domain": {
"label": "Arcana Domain",
"source": "SCAG pg. 125",
"features": {}
},
"blood-domain": {
"label": "Blood Domain",
"source": "TCS pg. 101",
"features": {}
},
"death-domain": {
"label": "Death Domain",
"source": "DMG pg. 96",
"features": {}
},
"forge-domain": {
"label": "Forge Domain",
"source": "XGE pg. 18",
"features": {}
},
"grave-domain": {
"label": "Grave Domain",
"source": "XGE pg. 19",
"features": {}
},
"knowledge-domain": {
"label": "Knowledge Domain",
"source": "PHB pg. 59",
"features": {}
},
"life-domain": {
"label": "Life Domain",
"source": "PHB pg. 60",
"features": {
"1": ["Compendium.sw5e.classfeatures.68bYIOvx6rIqnlOW", "Compendium.sw5e.classfeatures.jF8AFfEMICIJnAkR", "Compendium.sw5e.powers.8dzaICjGy6mTUaUr", "Compendium.sw5e.powers.uUWb1wZgtMou0TVP"],
"2": ["Compendium.sw5e.classfeatures.hEymt45rICi4f9eL"],
"3": ["Compendium.sw5e.powers.F0GsG0SJzsIOacwV", "Compendium.sw5e.powers.JbxsYXxSOTZbf9I0"],
"5": ["Compendium.sw5e.powers.ZU9d6woBdUP8pIPt", "Compendium.sw5e.powers.LmRHHMtplpxr9fX6"],
"6": ["Compendium.sw5e.classfeatures.yv49QN6Bzqs0ecCs"],
"7": ["Compendium.sw5e.powers.VtCXMdyM6mAdIJZb", "Compendium.sw5e.powers.TgHsuhNasPbhu8MO"],
"8": ["Compendium.sw5e.classfeatures.T6u5z8ZTX6UftXqE"],
"9": ["Compendium.sw5e.powers.Pyzmm8R7rVsNAPsd", "Compendium.sw5e.powers.AGFMPAmuzwWO6Dfz"],
"17": ["Compendium.sw5e.classfeatures.4UOgxzr83vFuUash"]
}
},
"light-domain": {
"label": "Light Domain",
"source": "PHB pg. 60",
"features": {}
},
"nature-domain": {
"label": "Nature Domain",
"source": "PHB pg. 61",
"features": {}
},
"order-domain": {
"label": "Order Domain",
"source": "GGR pg. 25",
"features": {}
},
"solidarity-domain": {
"label": "Solidarity Domain",
"source": "PS:A pg. 24",
"features": {}
},
"strength-domain": {
"label": "Strength Domain",
"source": "PS:A pg. 25",
"features": {}
},
"tempest-domain": {
"label": "Tempest Domain",
"source": "PHB pg. 62",
"features": {}
},
"trickery-domain": {
"label": "Trickery Domain",
"source": "PHB pg. 62",
"features": {}
},
"war-domain": {
"label": "War Domain",
"source": "PHB pg. 63",
"features": {}
},
"zeal-domain": {
"label": "Zeal Domain",
"source": "PS:A pg. 28",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.x637K2Icp2ZFM1TB", "Compendium.sw5e.classfeatures.v4gKwLhAq9vuqza7"],
"2": ["Compendium.sw5e.classfeatures.YpiLQEKGalROn7iJ"],
"5": ["Compendium.sw5e.classfeatures.NMy4piwXIpLjYbRE"],
"10": ["Compendium.sw5e.classfeatures.eVXqHn0ojWrEuYGU"]
},
},
"druid": {
"subclasses": {
"circle-of-dreams": {
"label": "Circle of Dreams",
"source": "XGE pg. 22",
"features": {}
},
"circle-of-the-land": {
"label": "Circle of the Land",
"source": "PHB pg. 68",
"features": {
"2": ["Compendium.sw5e.classfeatures.lT8GsPOPgRzDC3QJ", "Compendium.sw5e.classfeatures.wKdRtFsvGfMKQHLY"],
"3": ["Compendium.sw5e.classfeatures.YiK59gWSlcQ6Mbdz"],
"6": ["Compendium.sw5e.classfeatures.3FB25qKxmkmxcxuC"],
"10": ["Compendium.sw5e.classfeatures.OTvrJSJSUgAwXrWX"],
"14": ["Compendium.sw5e.classfeatures.EuX1kJNIw1F68yus"]
}
},
"circle-of-the-moon": {
"label": "Circle of the Moon",
"source": "PHB pg. 69",
"features": {}
},
"circle-of-the-shepherd": {
"label": "Circle of the Shepherd",
"source": "XGE pg. 23",
"features": {}
},
"circle-of-spores": {
"label": "Circle of Spores",
"source": "GGR pg. 26",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.LzJ5ayHt0OlSVGxi", "Compendium.sw5e.classfeatures.i6tPm3FNK13Ftc9v"],
"2": ["Compendium.sw5e.classfeatures.swK0r5TOIxredxWS", "Compendium.sw5e.classfeatures.u6Du2P9s81SWuGbi"],
"18": ["Compendium.sw5e.classfeatures.cVDEQo0ow1WJT7Wl", "Compendium.sw5e.classfeatures.xvgPu1O57DgXCM86"],
"20": ["Compendium.sw5e.classfeatures.ip4bvmGoz3qkoqes"]
},
},
"fighter": {
"subclasses": {
"arcane-archer": {
"label": "Arcane Archer",
"source": "XGE pg. 28",
"features": {}
},
"banneret": {
"label": "Banneret",
"source": "SCAG pg. 128",
"features": {}
},
"battle-master": {
"label": "Battle Master",
"source": "PHB pg. 73",
"features": {}
},
"cavalier": {
"label": "Cavalier",
"source": "XGE pg. 30",
"features": {}
},
"champion": {
"label": "Champion",
"source": "PHB pg. 72",
"features": {
"3": ["Compendium.sw5e.classfeatures.YgLQV1O849wE5TgM"],
"7": ["Compendium.sw5e.classfeatures.dHu1yzIjD38BvGGd"],
"11": ["Compendium.sw5e.classfeatures.kYJsED0rqqqUcgKz"],
"15": ["Compendium.sw5e.classfeatures.aVKH6TLn1AG9hPSA"],
"18": ["Compendium.sw5e.classfeatures.ipG5yx1tRNmeJfSH"]
}
},
"echo-knight": {
"label": "Echo Knight",
"source": "EGW pg. 183",
"features": {}
},
"eldritch-knight": {
"label": "Eldritch Knight",
"source": "PHB pg. 74",
"features": {}
},
"samurai": {
"label": "Samurai",
"source": "XGE pg. 31",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.fbExzwNwEAl2kW9c", "Compendium.sw5e.classfeatures.nTjmWbyHweXuIqwc"],
"2": ["Compendium.sw5e.classfeatures.xF1VTcJ3AdkbTsdQ"],
"3": ["Compendium.sw5e.classfeatures.ax8M0X0q1GGWM26j"],
"5": ["Compendium.sw5e.classfeatures.q9g1MLXuLZyxjQMg"],
"9": ["Compendium.sw5e.classfeatures.653ZHbNcmm7ZGXbw"]
},
},
"monk": {
"subclasses": {
"way-of-the-cobalt-soul": {
"label": "Way of the Cobalt Soul",
"source": "TCS pg. 104",
"features": {}
},
"way-of-the-drunken-master": {
"label": "Way of the Drunken Master",
"source": "XGE pg. 33",
"features": {}
},
"way-of-the-elements": {
"label": "Way of the Four Elements",
"source": "PHB pg. 80",
"features": {}
},
"way-of-the-kensei": {
"label": "Way of the Kensei",
"source": "XGE pg. 34",
"features": {}
},
"way-of-the-long-death": {
"label": "Way of the Long Death",
"source": "SCAG pg. 130",
"features": {}
},
"way-of-the-open-hand": {
"label": "Way of the Open Hand",
"source": "PHB pg. 79",
"features": {
"3": ["Compendium.sw5e.classfeatures.iQxLNydNLlCHNKbp"],
"6": ["Compendium.sw5e.classfeatures.Q7mOdk4b1lgjcptF"],
"11": ["Compendium.sw5e.classfeatures.rBDZLatuoolT2FUW"],
"17": ["Compendium.sw5e.classfeatures.h1gM8SH3BNRtFevE"]
}
},
"way-of-the-shadow": {
"label": "Way of Shadow",
"source": "PHB pg. 80",
"features": {}
},
"way-of-the-sun-soul": {
"label": "Way of the Sun Soul",
"source": "XGE pg. 35; SCAG pg. 131",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.UAvV7N7T4zJhxdfI", "Compendium.sw5e.classfeatures.l50hjTxO2r0iecKw"],
"2": ["Compendium.sw5e.classfeatures.10b6z2W1txNkrGP7", "Compendium.sw5e.classfeatures.7vSrGc0MP5Vzm9Ac"],
"3": ["Compendium.sw5e.classfeatures.rtpQdX77dYWbDIOH", "Compendium.sw5e.classfeatures.mzweVbnsJPQiVkAe"],
"4": ["Compendium.sw5e.classfeatures.KQz9bqxVkXjDl8gK"],
"5": ["Compendium.sw5e.classfeatures.XogoBnFWmCAHXppo", "Compendium.sw5e.classfeatures.pvRc6GAu1ok6zihC"],
"6": ["Compendium.sw5e.classfeatures.7flZKruSSu6dHg6D"],
"7": ["Compendium.sw5e.classfeatures.a4P4DNMmH8CqSNkC", "Compendium.sw5e.classfeatures.ZmC31XKS4YNENnoc"],
"10": ["Compendium.sw5e.classfeatures.bqWA7t9pDELbNRkp"],
"13": ["Compendium.sw5e.classfeatures.XjuGBeB8Y0C3A5D4"],
"14": ["Compendium.sw5e.classfeatures.7D2EkLdISwShEDlN"],
"15": ["Compendium.sw5e.classfeatures.gDH8PMrKvLHaNmEI"],
"18": ["Compendium.sw5e.classfeatures.3jwFt3hSqDswBlOH"],
"20": ["Compendium.sw5e.classfeatures.mQNPg89YIs7g5tG4"]
},
},
"paladin": {
"subclasses": {
"oath-of-the-ancients": {
"label": "Oath of the Ancients",
"source": "PHB pg. 86",
"features": {}
},
"oath-of-conquest": {
"label": "Oath of Conquest",
"source": "SCAG pg. 128",
"features": {}
},
"oath-of-the-crown": {
"label": "Oath of the Crown",
"source": "SCAG pg. 132",
"features": {}
},
"oath-of-devotion": {
"label": "Oath of Devotion",
"source": "PHB pg. 85",
"features": {
"3": ["Compendium.sw5e.powers.xmDBqZhRVrtLP8h2", "Compendium.sw5e.powers.gvdA9nPuWLck4tBl"],
"5": ["Compendium.sw5e.powers.F0GsG0SJzsIOacwV", "Compendium.sw5e.powers.CylBa7jR8DSbo8Z3"],
"9": ["Compendium.sw5e.powers.ZU9d6woBdUP8pIPt", "Compendium.sw5e.powers.15Fa6q1nH27XfbR8"],
"13": ["Compendium.sw5e.powers.da0a1t2FqaTjRZGT", "Compendium.sw5e.powers.TgHsuhNasPbhu8MO"],
"17": ["Compendium.sw5e.powers.d54VDyFulD9xxY7J", "Compendium.sw5e.powers.5e1xTohkzqFqbYH4"]
}
},
"oathbreaker": {
"label": "Oathbreaker",
"source": "DMG pg. 97",
"features": {}
},
"oath-of-redemption": {
"label": "Oath of Redemption",
"source": "XGE pg. 38",
"features": {}
},
"oath-of-vengeance": {
"label": "Oath of Vengeance",
"source": "PHB pg. 87",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.E8ozg8avUVOX9N7u", "Compendium.sw5e.classfeatures.OdrvL3afwLOPeuYZ"],
"2": ["Compendium.sw5e.classfeatures.ySMPQ6zNSlvkrl2f", "Compendium.sw5e.classfeatures.fbExzwNwEAl2kW9c", "Compendium.sw5e.classfeatures.ihoQHsmVZlyDbPhX"],
"3": ["Compendium.sw5e.classfeatures.dY9yrqkyEDuF0CG2", "Compendium.sw5e.classfeatures.olAqNsUTIef9x8xC"],
"5": ["Compendium.sw5e.classfeatures.XogoBnFWmCAHXppo"],
"6": ["Compendium.sw5e.classfeatures.carGDhkIdoduTC0I"],
"10": ["Compendium.sw5e.classfeatures.nahSkBO6LH4HkpaT"],
"11": ["Compendium.sw5e.classfeatures.FAk41RPCTcvCk6KI"],
"14": ["Compendium.sw5e.classfeatures.U7BIPVPsptBmwsnV"]
},
},
"ranger": {
"subclasses": {
"beast-master": {
"label": "Beast Master",
"source": "PHB pg. 93",
"features": {}
},
"gloom-stalker": {
"label": "Gloom Stalker",
"source": "XGE pg. 41",
"features": {}
},
"horizon-walker": {
"label": "Horizon Walker",
"source": "XGE pg. 42",
"features": {}
},
"hunter": {
"label": "Hunter",
"source": "PHB pg. 93",
"features": {
"3": ["Compendium.sw5e.classfeatures.wrxIW5sDfmGr3u5s"],
"7": ["Compendium.sw5e.classfeatures.WgQrqjmeyMqDzVt3"],
"11": ["Compendium.sw5e.classfeatures.7zlTRRXT1vWSBGjX"],
"15": ["Compendium.sw5e.classfeatures.a0Sq88dgnREcIMfl"]
}
},
"monster-slayer": {
"label": "Monster Slayer",
"source": "XGE pg. 43",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.4Vpj9vCOB37GtXk6", "Compendium.sw5e.classfeatures.8fbZt2Qh7ZttwIan"],
"2": ["Compendium.sw5e.classfeatures.fbExzwNwEAl2kW9c", "Compendium.sw5e.classfeatures.u6xV3Ki3TXRrD7zg"],
"3": ["Compendium.sw5e.classfeatures.1dJHU48yNqn3lcfx", "Compendium.sw5e.classfeatures.kaHcUGiwi8AtfZIm"],
"5": ["Compendium.sw5e.classfeatures.XogoBnFWmCAHXppo"],
"8": ["Compendium.sw5e.classfeatures.C5fzaOBc6HxyOWRn"],
"10": ["Compendium.sw5e.classfeatures.r0unvWK0lPsDthDx"],
"14": ["Compendium.sw5e.classfeatures.DhU2dWCNnX78TstR"],
"18": ["Compendium.sw5e.classfeatures.QBVmY56RMQuh6C8h"],
"20": ["Compendium.sw5e.classfeatures.3CaP1vFHVR8LgHjx"]
},
},
"rogue": {
"subclasses": {
"arcane-trickster": {
"label": "Arcane Trickster",
"source": "PHB pg. 97",
"features": {}
},
"assassin": {
"label": "Assassin",
"source": "PHB pg. 97",
"features": {}
},
"inquisitive": {
"label": "Inquisitive",
"source": "XGE pg. 45",
"features": {}
},
"mastermind": {
"label": "Mastermind",
"source": "XGE pg. 46; SCAG pg. 135",
"features": {}
},
"scout": {
"label": "Scout",
"source": "XGE pg. 47",
"features": {}
},
"swashbuckler": {
"label": "Swashbuckler",
"source": "XGE pg. 47; SCAG pg. 135",
"features": {}
},
"thief": {
"label": "Thief",
"source": "PHB pg. 97",
"features": {
"3": ["Compendium.sw5e.classfeatures.ga3dt2zrCn2MHK8R", "Compendium.sw5e.classfeatures.FGrbXs6Ku5qxFK5G"],
"9": ["Compendium.sw5e.classfeatures.Ei1Oh4UAA2E30jcD"],
"13": ["Compendium.sw5e.classfeatures.NqWyHE7Rpw9lyKWu"],
"17": ["Compendium.sw5e.classfeatures.LhRm1EeUMvp2EWhV"]
}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.3sYPftQKnbbVnHrh", "Compendium.sw5e.classfeatures.DPN2Gfk8yi1Z5wp7", "Compendium.sw5e.classfeatures.ohwfuwnvuoBWlSQr"],
"2": ["Compendium.sw5e.classfeatures.01pcLg6PRu5zGrsb"],
"3": ["Compendium.sw5e.classfeatures.80USV8ZFPIahpLd0"],
"5": ["Compendium.sw5e.classfeatures.Mm64SKAHJWYecgXS"],
"7": ["Compendium.sw5e.classfeatures.a4P4DNMmH8CqSNkC"],
"11": ["Compendium.sw5e.classfeatures.YN9xm6MCvse4Y60u"],
"14": ["Compendium.sw5e.classfeatures.fjsBk7zxoAbLf8ZI"],
"15": ["Compendium.sw5e.classfeatures.V4pwFxlwHtNeB4w9"],
"18": ["Compendium.sw5e.classfeatures.L7nJSRosos8sHJH9"],
"20": ["Compendium.sw5e.classfeatures.rQhWDaMHMn7iU4f2"]
},
},
"sorcerer": {
"subclasses": {
"draconic-bloodline": {
"label": "Draconic Bloodline",
"source": "PHB pg. 102",
"features": {
"1": ["Compendium.sw5e.classfeatures.EZsonMThTNLZq35j", "Compendium.sw5e.classfeatures.MW1ExvBLm8Hg82aA"],
"6": ["Compendium.sw5e.classfeatures.x6eEZ9GUsuOcEa3G"],
"14": ["Compendium.sw5e.classfeatures.3647zjKSE9zFwOXc"],
"18": ["Compendium.sw5e.classfeatures.Gsha4bl0apxqspFy"]
}
},
"divine-soul": {
"label": "Divine Soul",
"source": "XGE pg. 50",
"features": {}
},
"pyromancer": {
"label": "Pyromancer",
"source": "PS:K pg. 9",
"features": {}
},
"runechild": {
"label": "Runechild",
"source": "TCS pg. 103",
"features": {}
},
"shadow-magic": {
"label": "Shadow Magic",
"source": "XGE pg. 50",
"features": {}
},
"storm-sorcery": {
"label": "Storm Sorcery",
"source": "XGE pg. 51; SCAG pg. 137",
"features": {}
},
"wild-magic": {
"label": "Wild Magic",
"source": "PHB pg. 103",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.cmRCL9T9UgRYOj1c", "Compendium.sw5e.classfeatures.oygRF3ZjTv2T7z0Y"],
"2": ["Compendium.sw5e.classfeatures.LBKChJY5n02Afhnq"],
"3": ["Compendium.sw5e.classfeatures.9Uh7uTDNZ04oTJsL"],
"20": ["Compendium.sw5e.classfeatures.F2lEKSmOY0NUruzY"]
},
},
"warlock": {
"subclasses": {
"the-archfey": {
"label": "The Archfey",
"source": "PHB pg. 108",
"features": {}
},
"the-celestial": {
"label": "The Celestial",
"source": "XGE pg. 54",
"features": {}
},
"the-fiend": {
"label": "The Fiend",
"source": "PHB pg. 109",
"features": {
"1": ["Compendium.sw5e.classfeatures.Jv0zu4BtUi8bFCqJ"],
"6": ["Compendium.sw5e.classfeatures.OQSb0bO1yDI4aiMx"],
"10": ["Compendium.sw5e.classfeatures.9UZ2WjUF2k58CQug"],
"14": ["Compendium.sw5e.classfeatures.aCUmlnHlUPHS0rdu"]
}
},
"the-hexblade": {
"label": "The Hexblade",
"source": "XGE pg. 55",
"features": {}
},
"the-oldone": {
"label": "The Great Old One",
"source": "PHB pg. 109",
"features": {}
},
"the-undying": {
"label": "The Undying",
"source": "SCAG pg. 139",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.jTXHaK0vvT5DV3uO", "Compendium.sw5e.classfeatures.x6IJZwr6f0SGral7"],
"2": ["Compendium.sw5e.classfeatures.8MlxM2nEfE3Q0EVk"],
"3": ["Compendium.sw5e.classfeatures.QwgfIpCN8VWfoUtX"],
"11": ["Compendium.sw5e.classfeatures.zB77V8BcCJvWVxck"],
"13": ["Compendium.sw5e.classfeatures.HBn6FXLz7Eiudz0V"],
"15": ["Compendium.sw5e.classfeatures.knDZR4l4QfLTKinm"],
"17": ["Compendium.sw5e.classfeatures.vMxJQEKeK6WwZFaF"],
"20": ["Compendium.sw5e.classfeatures.0C04rwyvoknvFYiy"]
},
},
"wizard": {
"subclasses": {
"school-of-abjuration": {
"label": "School of Abjuration",
"source": "PHB pg. 115",
"features": {}
},
"school-of-bladesinging": {
"label": "School of Bladesinging",
"source": "SCAG pg. 141",
"features": {}
},
"school-of-chronurgy-magic": {
"label": "School of Chronurgy Magic",
"source": "EGW pg. 185",
"features": {}
},
"school-of-conjuration": {
"label": "School of Conjuration",
"source": "PHB pg. 116",
"features": {}
},
"school-of-divination": {
"label": "School of Divination",
"source": "PHB pg. 116",
"features": {}
},
"school-of-enchantment": {
"label": "School of Enchantment",
"source": "PHB pg. 117",
"features": {}
},
"school-of-evocation": {
"label": "School of Evocation",
"source": "PHB pg. 117",
"features": {
"2": ["Compendium.sw5e.classfeatures.7uzJ2JkmsdRGLra3", "Compendium.sw5e.classfeatures.6VBXkjjBgjSpNElh"],
"6": ["Compendium.sw5e.classfeatures.evEWCpE5MYgr5RRW"],
"10": ["Compendium.sw5e.classfeatures.7O85kj6uDEG5NzUE"],
"14": ["Compendium.sw5e.classfeatures.VUtSLeCzFubGXmGx"]
}
},
"school-of-graviturgy-magic": {
"label": "School of Graviturgy Magic",
"source": "EGW pg. 185",
"features": {}
},
"school-of-illusion": {
"label": "School of Illusion",
"source": "PHB pg. 118",
"features": {}
},
"school-of-necromancy": {
"label": "School of Necromancy",
"source": "PHB pg. 118",
"features": {}
},
"school-of-transmutation": {
"label": "School of Transmutation",
"source": "PHB pg. 119",
"features": {}
},
"school-of-war-magic": {
"label": "School of War Magic",
"source": "XGE pg. 59",
"features": {}
}
},
"features": {
"1": ["Compendium.sw5e.classfeatures.gbNo5eVPaqr8IVKL", "Compendium.sw5e.classfeatures.e0uTcFPpgxjIyUW9"],
"2": ["Compendium.sw5e.classfeatures.AEWr9EMxy5gj4ZFT"],
"18": ["Compendium.sw5e.classfeatures.JfFfHTeIszx1hNRx"],
"20": ["Compendium.sw5e.classfeatures.nUrZDi6QN1YjwAr6", "Compendium.sw5e.classfeatures.31bKbWe9ZGVLEns6"]
},
}
*/
};

View file

@ -9,8 +9,17 @@ export const _getInitiativeFormula = function(combatant) {
const actor = combatant.actor;
if ( !actor ) return "1d20";
const init = actor.data.data.attributes.init;
const parts = ["1d20", init.mod, (init.prof !== 0) ? init.prof : null, (init.bonus !== 0) ? init.bonus : null];
if ( actor.getFlag("sw5e", "initiativeAdv") ) parts[0] = "2d20kh";
let nd = 1;
let mods = "";
if (actor.getFlag("sw5e", "halflingLucky")) mods += "r=1";
if (actor.getFlag("sw5e", "initiativeAdv")) {
nd = 2;
mods += "kh";
}
const parts = [`${nd}d20${mods}`, init.mod, (init.prof !== 0) ? init.prof : null, (init.bonus !== 0) ? init.bonus : null];
// Optionally apply Dexterity tiebreaker
const tiebreaker = game.settings.get("sw5e", "initiativeDexTiebreaker");

View file

@ -1,3 +1,5 @@
import {ClassFeatures} from "./classFeatures.js"
// Namespace SW5e Configuration Values
export const SW5E = {};
@ -282,6 +284,9 @@ SW5E.damageTypes = {
"sonic": "SW5E.DamageSonic"
};
// Damage Resistance Types
SW5E.damageResistanceTypes = duplicate(SW5E.damageTypes);
/* -------------------------------------------- */
// armor Types
@ -291,6 +296,7 @@ SW5E.armorPropertiesTypes = {
"Anchor": "SW5E.ArmorProperAnchor",
"Avoidant": "SW5E.ArmorProperAvoidant",
"Barbed": "SW5E.ArmorProperBarbed",
"Bulky": "SW5E.ArmorProperBulky",
"Charging": "SW5E.ArmorProperCharging",
"Concealing": "SW5E.ArmorProperConcealing",
"Cumbersome": "SW5E.ArmorProperCumbersome",
@ -303,6 +309,7 @@ SW5E.armorPropertiesTypes = {
"Lightweight": "SW5E.ArmorProperLightweight",
"Magnetic": "SW5E.ArmorProperMagnetic",
"Obscured": "SW5E.ArmorProperObscured",
"Obtrusive": "SW5E.ArmorProperObtrusive",
"Powered": "SW5E.ArmorProperPowered",
"Reactive": "SW5E.ArmorProperReactive",
"Regulated": "SW5E.ArmorProperRegulated",
@ -311,6 +318,7 @@ SW5E.armorPropertiesTypes = {
"Rigid": "SW5E.ArmorProperRigid",
"Silent": "SW5E.ArmorProperSilent",
"Spiked": "SW5E.ArmorProperSpiked",
"Strength": "SW5E.ArmorProperStrength",
"Steadfast": "SW5E.ArmorProperSteadfast",
"Versatile": "SW5E.ArmorProperVersatile"
};
@ -490,7 +498,9 @@ SW5E.weaponTypes = {
"martialLW": "SW5E.WeaponMartialLW",
"natural": "SW5E.WeaponNatural",
"improv": "SW5E.WeaponImprov",
"ammo": "SW5E.WeaponAmmo"
"ammo": "SW5E.WeaponAmmo",
"siege": "SW5E.WeaponSiege"
};
@ -512,14 +522,15 @@ SW5E.weaponProperties = {
"dis": "SW5E.WeaponPropertiesDis",
"dpt": "SW5E.WeaponPropertiesDpt",
"dou": "SW5E.WeaponPropertiesDou",
"hvy": "SW5E.WeaponPropertiesHvy",
"hid": "SW5E.WeaponPropertiesHid",
"fin": "SW5E.WeaponPropertiesFin",
"fix": "SW5E.WeaponPropertiesFix",
"foc": "SW5E.WeaponPropertiesFoc",
"hvy": "SW5E.WeaponPropertiesHvy",
"hid": "SW5E.WeaponPropertiesHid",
"ken": "SW5E.WeaponPropertiesKen",
"lgt": "SW5E.WeaponPropertiesLgt",
"lum": "SW5E.WeaponPropertiesLum",
"mig": "SW5E.WeaponPropertiesMig",
"pic": "SW5E.WeaponPropertiesPic",
"rap": "SW5E.WeaponPropertiesRap",
"rch": "SW5E.WeaponPropertiesRch",
@ -552,7 +563,6 @@ SW5E.powerSchools = {
"enh": "SW5E.SchoolEnh"
};
// Power Levels
SW5E.powerLevels = {
0: "SW5E.PowerLevel0",
@ -639,7 +649,6 @@ SW5E.cover = {
.5: 'SW5E.CoverHalf',
.75: 'SW5E.CoverThreeQuarters',
1: 'SW5E.CoverTotal'
};
/* -------------------------------------------- */
@ -662,6 +671,7 @@ SW5E.conditionTypes = {
"prone": "SW5E.ConProne",
"restrained": "SW5E.ConRestrained",
"shocked": "SW5E.ConShocked",
"slowed": "SW5E.ConSlowed",
"stunned": "SW5E.ConStunned",
"unconscious": "SW5E.ConUnconscious"
};
@ -782,6 +792,9 @@ SW5E.CR_EXP_LEVELS = [
20000, 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000, 105000, 120000, 135000, 155000
];
// Character Features Per Class And Level
SW5E.classFeatures = ClassFeatures;
// Configure Optional Character Flags
SW5E.characterFlags = {
"detailOriented": {
@ -863,7 +876,13 @@ SW5E.characterFlags = {
section: "Feats",
type: Boolean
},
"remarkableAthlete": {
"reliableTalent": {
name: "SW5E.FlagsReliableTalent",
hint: "SW5E.FlagsReliableTalentHint",
section: "Feats",
type: Boolean
},
"remarkableAthlete": {
name: "SW5E.FlagsRemarkableAthlete",
hint: "SW5E.FlagsRemarkableAthleteHint",
abilities: ['str','dex','con'],
@ -878,3 +897,8 @@ SW5E.characterFlags = {
placeholder: 20
}
};
// Configure allowed status flags
SW5E.allowedActorFlags = [
"isPolymorphed", "originalActor"
].concat(Object.keys(SW5E.characterFlags));

View file

@ -150,7 +150,6 @@ export default class Item5e extends Item {
// Get the Item's data
const itemData = this.data;
const actorData = this.actor ? this.actor.data : {};
const data = itemData.data;
const C = CONFIG.SW5E;
const labels = {};
@ -228,16 +227,12 @@ export default class Item5e extends Item {
// Item Actions
if ( data.hasOwnProperty("actionType") ) {
// Save DC
let save = data.save || {};
if ( !save.ability ) save.dc = null;
else if ( this.isOwned ) { // Actor owned items
if ( save.scaling === "power" ) save.dc = actorData.data.attributes.powerdc;
else if ( save.scaling !== "flat" ) save.dc = this.actor.getPowerDC(save.scaling);
} else { // Un-owned items
// Saving throws for unowned items
const save = data.save;
if ( save?.ability && !this.isOwned ) {
if ( save.scaling !== "flat" ) save.dc = null;
labels.save = game.i18n.format("SW5E.SaveDC", {dc: save.dc || "", ability: C.abilities[save.ability]});
}
labels.save = save.ability ? `${game.i18n.localize("SW5E.AbbreviationDC")} ${save.dc || ""} ${C.abilities[save.ability]}` : "";
// Damage
let dam = data.damage || {};
@ -303,13 +298,20 @@ export default class Item5e extends Item {
user: game.user._id,
type: CONST.CHAT_MESSAGE_TYPES.OTHER,
content: html,
flavor: this.name,
speaker: {
actor: this.actor._id,
token: this.actor.token,
alias: this.actor.name
}
},
flags: {"core.canPopout": true}
};
// If the consumable was destroyed in the process - embed the item data in the surviving message
if ( (this.data.type === "consumable") && !this.actor.items.has(this.id) ) {
chatData.flags["sw5e.itemData"] = this.data;
}
// Toggle default roll mode
rollMode = rollMode || game.settings.get("core", "rollMode");
if ( ["gmroll", "blindroll"].includes(rollMode) ) chatData["whisper"] = ChatMessage.getWhisperRecipients("GM");
@ -443,7 +445,7 @@ export default class Item5e extends Item {
// Maybe initiate template placement workflow
if ( this.hasAreaTarget && placeTemplate ) {
const template = AbilityTemplate.fromItem(this);
if ( template ) template.drawPreview(event);
if ( template ) template.drawPreview();
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
}
return true;
@ -481,7 +483,7 @@ export default class Item5e extends Item {
// Ability activation properties
if ( data.hasOwnProperty("activation") ) {
props.push(
labels.activation + (data.activation.condition ? `(${data.activation.condition})` : ""),
labels.activation + (data.activation?.condition ? ` (${data.activation.condition})` : ""),
labels.target,
labels.range,
labels.duration
@ -617,17 +619,15 @@ export default class Item5e extends Item {
rollData["atk"] = [itemData.attackBonus, actorBonus.attack].filterJoin(" + ");
}
// Ammunition Bonus
delete this._ammo;
const consume = itemData.consume;
if ( consume?.type === "ammo" ) {
if ( !consume.target ) {
ui.notifications.warn(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name}));
return false;
}
const ammo = this.actor.items.get(consume.target);
// Ammunition Bonus
delete this._ammo;
const consume = itemData.consume;
if ( consume?.type === "ammo" ) {
const ammo = this.actor.items.get(consume.target);
if(ammo?.data){
const q = ammo.data.data.quantity;
if ( q && (q - consume.amount >= 0) ) {
const consumeAmount = consume.amount ?? 0;
if ( q && (q - consumeAmount >= 0) ) {
let ammoBonus = ammo.data.data.attackBonus;
if ( ammoBonus ) {
parts.push("@ammo");
@ -636,7 +636,10 @@ export default class Item5e extends Item {
this._ammo = ammo;
}
}
//}else{
// ui.notifications.error(game.i18n.format("SW5E.ConsumeWarningNoResource", {name: this.name, type: typeLabel}));
}
}
// Compose roll options
const rollConfig = mergeObject({
@ -856,7 +859,7 @@ export default class Item5e extends Item {
* Place an attack roll using an item (weapon, feat, power, or equipment)
* Rely upon the d20Roll logic for the core implementation
*
* @return {Promise.<Roll>} A Promise which resolves to the created Roll instance
* @return {Promise<Roll>} A Promise which resolves to the created Roll instance
*/
async rollFormula(options={}) {
if ( !this.data.data.formula ) {
@ -941,7 +944,7 @@ export default class Item5e extends Item {
// Maybe initiate template placement workflow
if ( this.hasAreaTarget && placeTemplate ) {
const template = AbilityTemplate.fromItem(this);
if ( template ) template.drawPreview(event);
if ( template ) template.drawPreview();
if ( this.owner && this.owner.sheet ) this.owner.sheet.minimize();
}
return true;
@ -1065,48 +1068,41 @@ export default class Item5e extends Item {
const isTargetted = action === "save";
if ( !( isTargetted || game.user.isGM || message.isAuthor ) ) return;
// Get the Actor from a synthetic Token
// Recover the actor for the chat card
const actor = this._getChatCardActor(card);
if ( !actor ) return;
// Get the Item
const item = actor.getOwnedItem(card.dataset.itemId);
// Get the Item from stored flag data or by the item ID on the Actor
const storedData = message.getFlag("sw5e", "itemData");
const item = storedData ? this.createOwned(storedData, actor) : actor.getOwnedItem(card.dataset.itemId);
if ( !item ) {
return ui.notifications.error(game.i18n.format("SW5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name}))
}
const powerLevel = parseInt(card.dataset.powerLevel) || null;
// Get card targets
let targets = [];
if ( isTargetted ) {
targets = this._getChatCardTargets(card);
if ( !targets.length ) {
ui.notifications.warn(game.i18n.localize("SW5E.ActionWarningNoToken"));
return button.disabled = false;
}
}
// Attack and Damage Rolls
if ( action === "attack" ) await item.rollAttack({event});
else if ( action === "damage" ) await item.rollDamage({event, powerLevel});
else if ( action === "versatile" ) await item.rollDamage({event, powerLevel, versatile: true});
else if ( action === "formula" ) await item.rollFormula({event, powerLevel});
// Saving Throws for card targets
else if ( action === "save" ) {
for ( let a of targets ) {
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: a.token});
await a.rollAbilitySave(button.dataset.ability, { event, speaker });
}
}
// Tool usage
else if ( action === "toolCheck" ) await item.rollToolCheck({event});
// Power Template Creation
else if ( action === "placeTemplate") {
const template = AbilityTemplate.fromItem(item);
if ( template ) template.drawPreview(event);
// Handle different actions
switch ( action ) {
case "attack":
await item.rollAttack({event}); break;
case "damage":
await item.rollDamage({event, powerLevel}); break;
case "versatile":
await item.rollDamage({event, powerLevel, versatile: true}); break;
case "formula":
await item.rollFormula({event, powerLevel}); break;
case "save":
const targets = this._getChatCardTargets(card);
for ( let token of targets ) {
const speaker = ChatMessage.getSpeaker({scene: canvas.scene, token: token});
await token.actor.rollAbilitySave(button.dataset.ability, { event, speaker });
}
break;
case "toolCheck":
await item.rollToolCheck({event}); break;
case "placeTemplate":
const template = AbilityTemplate.fromItem(item);
if ( template ) template.drawPreview();
break;
}
// Re-enable the button
@ -1164,10 +1160,9 @@ export default class Item5e extends Item {
* @private
*/
static _getChatCardTargets(card) {
const character = game.user.character;
const controlled = canvas.tokens.controlled;
const targets = controlled.reduce((arr, t) => t.actor ? arr.concat([t.actor]) : arr, []);
if ( character && (controlled.length === 0) ) targets.push(character);
let targets = canvas.tokens.controlled.filter(t => !!t.actor);
if ( !targets.length && game.user.character ) targets = targets.concat(game.user.character.getActiveTokens());
if ( !targets.length ) ui.notifications.warn(game.i18n.localize("SW5E.ActionWarningNoToken"));
return targets;
}

View file

@ -8,9 +8,7 @@ export default class ItemSheet5e extends ItemSheet {
constructor(...args) {
super(...args);
if ( this.object.data.type === "class" ) {
this.options.resizable = true;
this.options.width = 600;
this.options.height = 640;
}
}
@ -20,7 +18,7 @@ export default class ItemSheet5e extends ItemSheet {
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
width: 560,
height: 420,
height: "auto",
classes: ["sw5e", "sheet", "item"],
resizable: true,
scrollY: [".tab.details"],
@ -220,7 +218,9 @@ export default class ItemSheet5e extends ItemSheet {
/** @override */
setPosition(position={}) {
position.height = this._tabs[0].active === "details" ? "auto" : this.options.height;
if ( !this._minimized ) {
position.height = this._tabs[0].active === "details" ? "auto" : this.options.height;
}
return super.setPosition(position);
}
@ -249,13 +249,6 @@ export default class ItemSheet5e extends ItemSheet {
super.activateListeners(html);
html.find(".damage-control").click(this._onDamageControl.bind(this));
html.find('.trait-selector.class-skills').click(this._onConfigureClassSkills.bind(this));
// Armor properties
// html.find(".armorproperties-control").click(this._onarmorpropertiesControl.bind(this));
// Weapon properties
// html.find(".weaponproperties-control").click(this._onweaponpropertiesControl.bind(this));
}
/* -------------------------------------------- */

View file

@ -136,6 +136,34 @@ export const migrateActorData = function(actor) {
return updateData;
};
/* -------------------------------------------- */
/**
* Scrub an Actor's system data, removing all keys which are not explicitly defined in the system template
* @param {Object} actorData The data object for an Actor
* @return {Object} The scrubbed Actor data
*/
function cleanActorData(actorData) {
// Scrub system data
const model = game.system.model.Actor[actorData.type];
actorData.data = filterObject(actorData.data, model);
// Scrub system flags
const allowedFlags = CONFIG.SW5E.allowedActorFlags.reduce((obj, f) => {
obj[f] = null;
return obj;
}, {});
if ( actorData.flags.sw5e ) {
actorData.flags.sw5e = filterObject(actorData.flags.sw5e, allowedFlags);
}
// Return the scrubbed data
return actorData;
}
/* -------------------------------------------- */
/**

View file

@ -52,9 +52,8 @@ export default class AbilityTemplate extends MeasuredTemplate {
/**
* Creates a preview of the power template
* @param {Event} event The initiating click event
*/
drawPreview(event) {
drawPreview() {
const initialLayer = canvas.activeLayer;
this.draw();
this.layer.activate();