mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-06-06 04:44:16 +02:00
Merged with main
This commit is contained in:
commit
d78927d0c2
275 changed files with 5076 additions and 4132 deletions
|
|
@ -1,4 +1,3 @@
|
|||
import { DHDamageData } from '../fields/action/damageField.mjs';
|
||||
import DHDamageAction from './damageAction.mjs';
|
||||
|
||||
export default class DHAttackAction extends DHDamageAction {
|
||||
|
|
@ -12,8 +11,19 @@ export default class DHAttackAction extends DHDamageAction {
|
|||
super.prepareData();
|
||||
if (!!this.item?.system?.attack) {
|
||||
if (this.damage.includeBase) {
|
||||
const baseDamage = this.getParentDamage();
|
||||
this.damage.parts.hitPoints = new DHDamageData(baseDamage);
|
||||
const baseDamage = this.getParentHitPointDamage();
|
||||
if (baseDamage) {
|
||||
if (!this.damage.parts.hitPoints) {
|
||||
this.damage.parts.hitPoints = baseDamage;
|
||||
} else {
|
||||
for (const type of baseDamage.type) this.damage.parts.hitPoints.type.add(type);
|
||||
|
||||
this.damage.parts.hitPoints.value.custom = {
|
||||
enabled: true,
|
||||
formula: `${baseDamage.value.getFormula()} + ${this.damage.parts.hitPoints.value.getFormula()}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.roll.useDefault) {
|
||||
this.roll.trait = this.item.system.attack.roll.trait;
|
||||
|
|
@ -22,16 +32,8 @@ export default class DHAttackAction extends DHDamageAction {
|
|||
}
|
||||
}
|
||||
|
||||
getParentDamage() {
|
||||
return {
|
||||
value: {
|
||||
multiplier: 'prof',
|
||||
dice: this.item?.system?.attack.damage.parts.hitPoints.value.dice,
|
||||
bonus: this.item?.system?.attack.damage.parts.hitPoints.value.bonus ?? 0
|
||||
},
|
||||
type: this.item?.system?.attack.damage.parts.hitPoints.type,
|
||||
base: true
|
||||
};
|
||||
getParentHitPointDamage() {
|
||||
return this.item?.system?.attack.damage.parts.hitPoints;
|
||||
}
|
||||
|
||||
get damageFormula() {
|
||||
|
|
@ -73,7 +75,12 @@ export default class DHAttackAction extends DHDamageAction {
|
|||
const useAltDamage = this.actor?.effects?.find(x => x.type === 'horde')?.active;
|
||||
for (const { value, valueAlt, type } of damage.parts) {
|
||||
const usedValue = useAltDamage ? valueAlt : value;
|
||||
const str = Roll.replaceFormulaData(usedValue.getFormula(), this.actor?.getRollData() ?? {});
|
||||
const damageString = Roll.replaceFormulaData(usedValue.getFormula(), this.actor?.getRollData() ?? {});
|
||||
const str = damageString
|
||||
? damageString
|
||||
: game.i18n.format('DAGGERHEART.GENERAL.missingX', {
|
||||
x: game.i18n.localize('DAGGERHEART.GENERAL.damage')
|
||||
});
|
||||
|
||||
const icons = Array.from(type)
|
||||
.map(t => CONFIG.DH.GENERAL.damageTypes[t]?.icon)
|
||||
|
|
|
|||
|
|
@ -148,10 +148,14 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
|
|||
: null;
|
||||
}
|
||||
|
||||
/** Returns true if the action is usable */
|
||||
/**
|
||||
* Returns true if the action is usable.
|
||||
* An action is usable on any actor type. For example, an adversary might have a base attack action.
|
||||
*/
|
||||
get usable() {
|
||||
const actor = this.actor;
|
||||
return this.isOwner && actor?.type === 'character';
|
||||
const pack = actor?.pack ? game.packs.get(actor.pack) : null;
|
||||
return !pack?.locked && this.isOwner;
|
||||
}
|
||||
|
||||
static getRollType(parent) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default class DhCountdownAction extends DHBaseAction {
|
|||
|
||||
/** @inheritDoc */
|
||||
static migrateData(source) {
|
||||
for (const countdown of source.countdown) {
|
||||
for (const countdown of Object.values(source.countdown)) {
|
||||
if (countdown.progress.max) {
|
||||
countdown.progress.startFormula = countdown.progress.max;
|
||||
countdown.progress.start = 1;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default class BeastformEffect extends BaseEffect {
|
|||
static migrateData(source) {
|
||||
if (!source.characterTokenData.tokenSize.height) source.characterTokenData.tokenSize.height = 1;
|
||||
if (!source.characterTokenData.tokenSize.width) source.characterTokenData.tokenSize.width = 1;
|
||||
if (!source.characterTokenData.tokenSize.depth) source.characterTokenData.tokenSize.depth = 1;
|
||||
|
||||
return super.migrateData(source);
|
||||
}
|
||||
|
|
@ -52,7 +53,8 @@ export default class BeastformEffect extends BaseEffect {
|
|||
if (this.parent.parent.type === 'character') {
|
||||
const baseUpdate = {
|
||||
height: this.characterTokenData.tokenSize.height,
|
||||
width: this.characterTokenData.tokenSize.width
|
||||
width: this.characterTokenData.tokenSize.width,
|
||||
depth: this.characterTokenData.tokenSize.depth
|
||||
};
|
||||
const update = {
|
||||
...baseUpdate,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import DhCharacter from './character.mjs';
|
||||
import DhCompanion from './companion.mjs';
|
||||
import DhAdversary from './adversary.mjs';
|
||||
import DhNPC from './npc.mjs';
|
||||
import DhEnvironment from './environment.mjs';
|
||||
import DhParty from './party.mjs';
|
||||
|
||||
export { DhCharacter, DhCompanion, DhAdversary, DhEnvironment, DhParty };
|
||||
export { DhCharacter, DhCompanion, DhAdversary, DhNPC, DhEnvironment, DhParty };
|
||||
|
||||
export const config = {
|
||||
character: DhCharacter,
|
||||
companion: DhCompanion,
|
||||
adversary: DhAdversary,
|
||||
npc: DhNPC,
|
||||
environment: DhEnvironment,
|
||||
party: DhParty
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { ActionField } from '../fields/actionField.mjs';
|
|||
import { commonActorRules } from './base.mjs';
|
||||
import DhCreature from './creature.mjs';
|
||||
import { bonusField } from '../fields/actorField.mjs';
|
||||
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
||||
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
||||
import { getTierAdjustedAdversary } from './tierAdjustment.mjs';
|
||||
|
||||
export default class DhpAdversary extends DhCreature {
|
||||
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
|
||||
|
|
@ -189,6 +188,9 @@ export default class DhpAdversary extends DhCreature {
|
|||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
this.attack.roll.isStandardAttack = true;
|
||||
|
||||
// Clamp resources (must be done last to ensure all updates occur)
|
||||
this.resources.clamp();
|
||||
}
|
||||
|
||||
_getTags() {
|
||||
|
|
@ -203,205 +205,6 @@ export default class DhpAdversary extends DhCreature {
|
|||
/** Returns source data for this actor adjusted to a new tier, which can be used to create a new actor. */
|
||||
adjustForTier(tier) {
|
||||
const source = this.parent.toObject(true);
|
||||
|
||||
/** @type {(2 | 3 | 4)[]} */
|
||||
const tiers = new Array(Math.abs(tier - this.tier))
|
||||
.fill(0)
|
||||
.map((_, idx) => idx + Math.min(tier, this.tier) + 1);
|
||||
if (tier < this.tier) tiers.reverse();
|
||||
const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard];
|
||||
const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] }));
|
||||
|
||||
// Apply simple tier changes
|
||||
const scale = tier > this.tier ? 1 : -1;
|
||||
for (const entry of tierEntries) {
|
||||
source.system.difficulty += scale * entry.difficulty;
|
||||
source.system.damageThresholds.major += scale * entry.majorThreshold;
|
||||
source.system.damageThresholds.severe += scale * entry.severeThreshold;
|
||||
source.system.resources.hitPoints.max += scale * entry.hp;
|
||||
source.system.resources.stress.max += scale * entry.stress;
|
||||
source.system.attack.roll.bonus += scale * entry.attack;
|
||||
}
|
||||
|
||||
// Get the mean and standard deviation of expected damage in the previous and new tier
|
||||
// The data we have is for attack scaling, but we reuse this for action scaling later
|
||||
const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
|
||||
const damageMeta = {
|
||||
currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] },
|
||||
newDamageRange: { tier, ...expectedDamageData[tier] },
|
||||
type: 'attack'
|
||||
};
|
||||
|
||||
// Update damage of base attack
|
||||
try {
|
||||
this.#adjustActionDamage(source.system.attack, damageMeta);
|
||||
} catch (err) {
|
||||
ui.notifications.warn('Failed to convert attack damage of adversary');
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// Update damage of each item action, making sure to also update the description if possible
|
||||
const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g;
|
||||
for (const item of source.items) {
|
||||
// Replace damage inlines with new formulas
|
||||
for (const withDescription of [item.system, ...Object.values(item.system.actions)]) {
|
||||
withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => {
|
||||
const { value: formula } = parseInlineParams(inner);
|
||||
if (!formula || !type) return match;
|
||||
|
||||
try {
|
||||
const adjusted = this.#calculateAdjustedDamage(formula, { ...damageMeta, type: 'action' });
|
||||
const newFormula = [
|
||||
adjusted.diceQuantity ? `${adjusted.diceQuantity}d${adjusted.faces}` : null,
|
||||
adjusted.bonus
|
||||
]
|
||||
.filter(p => !!p)
|
||||
.join('+');
|
||||
return match.replace(formula, newFormula);
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update damage in item actions
|
||||
// Parse damage, and convert all formula matches in the descriptions to the new damage
|
||||
for (const action of Object.values(item.system.actions)) {
|
||||
try {
|
||||
const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' });
|
||||
if (!result) continue;
|
||||
|
||||
for (const { previousFormula, formula } of Object.values(result)) {
|
||||
const oldFormulaRegexp = new RegExp(
|
||||
previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?')
|
||||
);
|
||||
item.system.description = item.system.description.replace(oldFormulaRegexp, formula);
|
||||
action.description = action.description.replace(oldFormulaRegexp, formula);
|
||||
}
|
||||
} catch (err) {
|
||||
ui.notifications.warn(`Failed to convert action damage for item ${item.name}`);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally set the tier of the source data, now that everything is complete
|
||||
source.system.tier = tier;
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a damage object to a new damage range
|
||||
* @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term
|
||||
* @throws error if the formula is the wrong type
|
||||
*/
|
||||
#calculateAdjustedDamage(formula, { currentDamageRange, newDamageRange, type }) {
|
||||
const terms = parseTermsFromSimpleFormula(formula);
|
||||
const flatTerms = terms.filter(t => t.diceQuantity === 0);
|
||||
const diceTerms = terms.filter(t => t.diceQuantity > 0);
|
||||
if (flatTerms.length > 1 || diceTerms.length > 1) {
|
||||
throw new Error('invalid formula for conversion');
|
||||
}
|
||||
const value = {
|
||||
...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }),
|
||||
bonus: flatTerms[0]?.bonus ?? 0
|
||||
};
|
||||
const previousExpected = calculateExpectedValue(value);
|
||||
if (previousExpected === 0) return value; // nothing to do
|
||||
|
||||
const dieSizes = [4, 6, 8, 10, 12, 20];
|
||||
const steps = newDamageRange.tier - currentDamageRange.tier;
|
||||
const increasing = steps > 0;
|
||||
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
|
||||
const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation);
|
||||
|
||||
// If this was just a flat number, convert to the expected damage and exit
|
||||
if (value.diceQuantity === 0) {
|
||||
value.bonus = Math.round(expected);
|
||||
return value;
|
||||
}
|
||||
|
||||
const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1;
|
||||
const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 });
|
||||
|
||||
// Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die
|
||||
const baseOverages = Math.floor(value.bonus / getExpectedDie());
|
||||
|
||||
// Prestep. Change number of dice for attacks, bump up/down for actions
|
||||
// We never bump up to d20, though we might bump down from it
|
||||
if (type === 'attack') {
|
||||
const minimum = increasing ? value.diceQuantity : 0;
|
||||
value.diceQuantity = Math.max(minimum, newDamageRange.tier);
|
||||
} else {
|
||||
const currentIdx = dieSizes.indexOf(value.faces);
|
||||
value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)];
|
||||
}
|
||||
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
|
||||
// Attempt to handle negative values.
|
||||
// If we can do it with only step downs, do so. Otherwise remove tier dice, and try again
|
||||
if (value.bonus < 0) {
|
||||
let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||
const currentIdx = dieSizes.indexOf(value.faces);
|
||||
|
||||
// If step downs alone don't suffice, change the flat modifier, then calculate steps required again
|
||||
// If this isn't sufficient, the result will be slightly off. This is unlikely to happen
|
||||
if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) {
|
||||
value.diceQuantity -= increasing ? 1 : Math.abs(steps);
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
if (value.bonus >= 0) return value; // complete
|
||||
}
|
||||
|
||||
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||
value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)];
|
||||
value.bonus = Math.max(0, Math.round(expected - getBaseAverage()));
|
||||
}
|
||||
|
||||
// If value is really high, we add a number of dice based on the number of overages
|
||||
// This attempts to preserve a similar amount of variance when increasing an action
|
||||
const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages;
|
||||
if (type !== 'attack' && increasing && overagesToRemove > 0) {
|
||||
value.diceQuantity += overagesToRemove;
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates damage to reflect a specific value.
|
||||
* @throws if damage structure is invalid for conversion
|
||||
* @returns the converted formula and value as a simplified term, or null if it doesn't deal HP damage
|
||||
*/
|
||||
#adjustActionDamage(action, damageMeta) {
|
||||
if (!action.damage?.parts.hitPoints) return null;
|
||||
|
||||
const result = {};
|
||||
for (const property of ['value', 'valueAlt']) {
|
||||
const data = action.damage.parts.hitPoints[property];
|
||||
const previousFormula = data.custom.enabled
|
||||
? data.custom.formula
|
||||
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0]
|
||||
.filter(p => !!p)
|
||||
.join('+');
|
||||
const value = this.#calculateAdjustedDamage(previousFormula, damageMeta);
|
||||
const formula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus]
|
||||
.filter(p => !!p)
|
||||
.join('+');
|
||||
if (value.diceQuantity) {
|
||||
data.custom.enabled = false;
|
||||
data.bonus = value.bonus;
|
||||
data.dice = `d${value.faces}`;
|
||||
data.flatMultiplier = value.diceQuantity;
|
||||
} else if (!value.diceQuantity) {
|
||||
data.custom.enabled = true;
|
||||
data.custom.formula = formula;
|
||||
}
|
||||
|
||||
result[property] = { previousFormula, formula, value };
|
||||
}
|
||||
|
||||
return result;
|
||||
return getTierAdjustedAdversary(source, tier);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import DHBaseActorSettings from '../../applications/sheets/api/actor-setting.mjs';
|
||||
import DHItem from '../../documents/item.mjs';
|
||||
import { getScrollTextData } from '../../helpers/utils.mjs';
|
||||
import { createShallowProxy, getScrollTextData } from '../../helpers/utils.mjs';
|
||||
|
||||
const fields = foundry.data.fields;
|
||||
|
||||
|
|
@ -180,8 +180,7 @@ export default class BaseDataActor extends foundry.abstract.TypeDataModel {
|
|||
* @returns {object}
|
||||
*/
|
||||
getRollData() {
|
||||
const data = { ...this };
|
||||
return data;
|
||||
return createShallowProxy(this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -399,8 +399,9 @@ export default class DhCharacter extends DhCreature {
|
|||
return this.domains.map(key => {
|
||||
const domain = allDomainData[key];
|
||||
return {
|
||||
id: key,
|
||||
...domain,
|
||||
label: game.i18n.localize(domain.label)
|
||||
label: game.i18n.localize(domain?.label) ?? key
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -418,14 +419,11 @@ export default class DhCharacter extends DhCreature {
|
|||
}
|
||||
|
||||
get loadoutSlot() {
|
||||
const loadoutCount = this.domainCards.loadout?.length ?? 0,
|
||||
worldSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxLoadout,
|
||||
max = !worldSetting ? null : worldSetting + this.bonuses.maxLoadout;
|
||||
|
||||
const loadoutCount = this.domainCards.loadout?.length ?? 0;
|
||||
const worldSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxLoadout;
|
||||
return {
|
||||
current: loadoutCount,
|
||||
available: !max ? true : Math.max(max - loadoutCount, 0),
|
||||
max
|
||||
available: loadoutCount < worldSetting
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -600,6 +598,8 @@ export default class DhCharacter extends DhCreature {
|
|||
communityFeatures = [],
|
||||
classFeatures = [],
|
||||
subclassFeatures = [],
|
||||
multiclassFeatures = [],
|
||||
multiclassSubclassFeatures = [],
|
||||
companionFeatures = [],
|
||||
features = [];
|
||||
|
||||
|
|
@ -609,9 +609,9 @@ export default class DhCharacter extends DhCreature {
|
|||
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.community.id) {
|
||||
communityFeatures.push(item);
|
||||
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.class.id) {
|
||||
classFeatures.push(item);
|
||||
(item.system.multiclassOrigin ? multiclassFeatures : classFeatures).push(item);
|
||||
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.subclass.id) {
|
||||
subclassFeatures.push(item);
|
||||
(item.system.multiclassOrigin ? multiclassSubclassFeatures : subclassFeatures).push(item);
|
||||
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.companion.id) {
|
||||
companionFeatures.push(item);
|
||||
} else if (item.type === 'feature' && !item.system.type) {
|
||||
|
|
@ -640,6 +640,24 @@ export default class DhCharacter extends DhCreature {
|
|||
type: 'subclass',
|
||||
values: subclassFeatures
|
||||
},
|
||||
...(multiclassFeatures.length
|
||||
? {
|
||||
multiclassFeatures: {
|
||||
title: `${game.i18n.localize('DAGGERHEART.GENERAL.multiclass')} - ${this.multiclass.value?.name}`,
|
||||
type: 'multiclass',
|
||||
values: multiclassFeatures
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(multiclassSubclassFeatures.length
|
||||
? {
|
||||
multiclassSubclassFeatures: {
|
||||
title: `${game.i18n.localize('DAGGERHEART.GENERAL.multiclass')} ${game.i18n.localize('TYPES.Item.subclass')} - ${this.multiclass.subclass?.name}`,
|
||||
type: 'multiclassSubclass',
|
||||
values: multiclassSubclassFeatures
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
companionFeatures: {
|
||||
title: game.i18n.localize('DAGGERHEART.ACTORS.Character.companionFeatures'),
|
||||
type: 'companion',
|
||||
|
|
@ -804,6 +822,8 @@ export default class DhCharacter extends DhCreature {
|
|||
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData();
|
||||
|
||||
this.resources.hope.max -= this.scars;
|
||||
if (this.companion) {
|
||||
for (let levelKey in this.companion.system.levelData.levelups) {
|
||||
const level = this.companion.system.levelData.levelups[levelKey];
|
||||
|
|
@ -817,7 +837,6 @@ export default class DhCharacter extends DhCreature {
|
|||
}
|
||||
}
|
||||
|
||||
this.resources.hope.max -= this.scars;
|
||||
this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait;
|
||||
|
||||
this.resources.armor = {
|
||||
|
|
@ -835,6 +854,9 @@ export default class DhCharacter extends DhCreature {
|
|||
}
|
||||
|
||||
this.attack.damage.parts.hitPoints.value.custom.formula = `@prof${this.basicAttackDamageDice}${this.rules.attack.damage.bonus ? ` + ${this.rules.attack.damage.bonus}` : ''}`;
|
||||
|
||||
// Clamp resources (must be done last to ensure all updates occur)
|
||||
this.resources.clamp();
|
||||
}
|
||||
|
||||
getRollData() {
|
||||
|
|
@ -866,16 +888,17 @@ export default class DhCharacter extends DhCreature {
|
|||
|
||||
/* Scars can alter the amount of current hope */
|
||||
if (changes.system?.scars) {
|
||||
const diff = this.system.scars - changes.system.scars;
|
||||
const newHopeMax = this.system.resources.hope.max + diff;
|
||||
const newHopeValue = Math.min(newHopeMax, this.system.resources.hope.value);
|
||||
if (newHopeValue != this.system.resources.hope.value) {
|
||||
if (!changes.system.resources.hope) changes.system.resources.hope = { value: 0 };
|
||||
|
||||
changes.system.resources.hope = {
|
||||
...changes.system.resources.hope,
|
||||
value: changes.system.resources.hope.value + newHopeValue
|
||||
};
|
||||
const diff = this.scars - changes.system.scars;
|
||||
const newHopeMax = this.resources.hope.max + diff;
|
||||
const newHopeValue = Math.min(newHopeMax, this.resources.hope.value);
|
||||
if (newHopeValue != this.resources.hope.value) {
|
||||
changes.system = foundry.utils.mergeObject(changes.system ?? {}, {
|
||||
resources: {
|
||||
hope: {
|
||||
value: (changes.system?.resources?.hope?.value ?? 0) + newHopeMax
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,9 +146,6 @@ export default class DhCompanion extends DhCreature {
|
|||
const level = this.levelData.levelups[levelKey];
|
||||
for (let selection of level.selections) {
|
||||
switch (selection.type) {
|
||||
case 'hope':
|
||||
this.resources.hope += selection.value;
|
||||
break;
|
||||
case 'vicious':
|
||||
if (selection.data[0] === 'damage') {
|
||||
this.attack.damage.parts.hitPoints.value.dice = adjustDice(
|
||||
|
|
@ -185,6 +182,9 @@ export default class DhCompanion extends DhCreature {
|
|||
return acc;
|
||||
}, this.partner.system.companionData.levelupChoices);
|
||||
}
|
||||
|
||||
// Clamp resources (must be done last to ensure all updates occur)
|
||||
this.resources.clamp();
|
||||
}
|
||||
|
||||
async _preUpdate(changes, options, userId) {
|
||||
|
|
|
|||
|
|
@ -60,14 +60,4 @@ export default class DhCreature extends BaseDataActor {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepareDerivedData() {
|
||||
const minLimitResource = resource => {
|
||||
if (resource) resource.value = Math.min(resource.value, resource.max);
|
||||
};
|
||||
|
||||
minLimitResource(this.resources.stress);
|
||||
minLimitResource(this.resources.hitPoints);
|
||||
minLimitResource(this.resources.hope);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
module/data/actor/npc.mjs
Normal file
43
module/data/actor/npc.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import DHNPCSettings from '../../applications/sheets-configs/npc-settings.mjs';
|
||||
import BaseDataActor from './base.mjs';
|
||||
|
||||
export default class DhpNPC extends BaseDataActor {
|
||||
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.NPC'];
|
||||
|
||||
static get metadata() {
|
||||
return foundry.utils.mergeObject(super.metadata, {
|
||||
label: 'TYPES.Actor.npc',
|
||||
type: 'npc',
|
||||
settingSheet: DHNPCSettings,
|
||||
hasResistances: false,
|
||||
hasAttribution: true
|
||||
});
|
||||
}
|
||||
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields;
|
||||
return {
|
||||
...super.defineSchema(),
|
||||
difficulty: new fields.NumberField({
|
||||
nullable: true,
|
||||
initial: null,
|
||||
integer: true,
|
||||
label: 'DAGGERHEART.GENERAL.difficulty'
|
||||
}),
|
||||
description: new fields.HTMLField({ label: 'DAGGERHEART.GENERAL.description' }),
|
||||
motives: new fields.StringField(),
|
||||
notes: new fields.HTMLField()
|
||||
};
|
||||
}
|
||||
|
||||
/**@inheritdoc */
|
||||
static DEFAULT_ICON = 'systems/daggerheart/assets/icons/documents/actors/drama-masks.svg';
|
||||
|
||||
get features() {
|
||||
return this.parent.items.filter(x => x.type === 'feature');
|
||||
}
|
||||
|
||||
isItemValid(source) {
|
||||
return super.isItemValid(source) || source.type === 'feature';
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ export default class DhParty extends BaseDataActor {
|
|||
const fields = foundry.data.fields;
|
||||
return {
|
||||
...super.defineSchema(),
|
||||
partyMembers: new ForeignDocumentUUIDArrayField({ type: 'Actor' }, { prune: true }),
|
||||
partyMembers: new ForeignDocumentUUIDArrayField({ type: 'Actor' }),
|
||||
notes: new fields.HTMLField(),
|
||||
gold: new GoldField(),
|
||||
tagTeam: new fields.EmbeddedDataField(TagTeamData),
|
||||
|
|
|
|||
219
module/data/actor/tierAdjustment.mjs
Normal file
219
module/data/actor/tierAdjustment.mjs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
||||
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
||||
import { parseInlineParams } from '../../enrichers/parser.mjs';
|
||||
|
||||
export function getTierAdjustedAdversary(source, tier) {
|
||||
const currentTier = source.tier ?? 1;
|
||||
|
||||
/** @type {(2 | 3 | 4)[]} */
|
||||
const tiers = new Array(Math.abs(tier - currentTier))
|
||||
.fill(0)
|
||||
.map((_, idx) => idx + Math.min(tier, currentTier) + 1);
|
||||
if (tier < currentTier) tiers.reverse();
|
||||
const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard];
|
||||
const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] }));
|
||||
|
||||
// Apply simple tier changes
|
||||
const scale = tier > currentTier ? 1 : -1;
|
||||
for (const entry of tierEntries) {
|
||||
source.system.difficulty += scale * entry.difficulty;
|
||||
source.system.damageThresholds.major += scale * entry.majorThreshold;
|
||||
source.system.damageThresholds.severe += scale * entry.severeThreshold;
|
||||
source.system.resources.hitPoints.max += scale * entry.hp;
|
||||
source.system.resources.stress.max += scale * entry.stress;
|
||||
source.system.attack.roll.bonus += scale * entry.attack;
|
||||
}
|
||||
|
||||
// Get the mean and standard deviation of expected damage in the previous and new tier
|
||||
// The data we have is for attack scaling, but we reuse this for action scaling later
|
||||
const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
|
||||
const damageMeta = {
|
||||
currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] },
|
||||
newDamageRange: { tier, ...expectedDamageData[tier] }
|
||||
};
|
||||
|
||||
// Store initial attack damage for abilities that have you deal a "standard attack"
|
||||
const initialAttack = {
|
||||
type: source.system.attack.damage?.parts.hitPoints?.type?.toSorted(),
|
||||
value: getDamagePartsFormula(source.system.attack.damage?.parts.hitPoints?.value)
|
||||
};
|
||||
|
||||
// Update damage of base attack.
|
||||
try {
|
||||
const damage = source.system.attack.damage;
|
||||
if (!damage?.parts.hitPoints) throw new Error('Unexpected missing attack in adversary');
|
||||
|
||||
for (const property of ['value', 'valueAlt']) {
|
||||
const data = damage.parts.hitPoints[property];
|
||||
const previousFormula = getDamagePartsFormula(data);
|
||||
const { value, formula } = calculateAdjustedDamage(previousFormula, 'attack', damageMeta);
|
||||
applyAdjustedDamage(data, value, formula);
|
||||
}
|
||||
} catch (err) {
|
||||
ui.notifications.warn('Failed to convert attack damage of adversary');
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// Update damage of each item action, making sure to also update the description if possible
|
||||
const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g;
|
||||
for (const item of source.items) {
|
||||
// Replace damage inlines with new formulas. Keep a record for a specific check later
|
||||
const descriptionFormulas = [];
|
||||
for (const withDescription of [item.system, ...Object.values(item.system.actions)]) {
|
||||
withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => {
|
||||
const { value: formula } = parseInlineParams(inner, { first: 'value' });
|
||||
if (!formula) return match;
|
||||
|
||||
try {
|
||||
const newFormula = calculateAdjustedDamage(formula, 'action', damageMeta)?.formula;
|
||||
descriptionFormulas.push(formula);
|
||||
return match.replace(formula, newFormula);
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update damage in item actions and convert all formula matches in the descriptions to the new damage
|
||||
for (const action of Object.values(item.system.actions)) {
|
||||
if (!action.damage?.parts.hitPoints) continue;
|
||||
try {
|
||||
// Apply conversions and save a record. If it matches attack damage *and* Its not in the description, use attack conversion instead
|
||||
const result = [];
|
||||
for (const property of ['value', 'valueAlt']) {
|
||||
const { [property]: data, type: damageType } = action.damage.parts.hitPoints;
|
||||
const previousFormula = getDamagePartsFormula(data);
|
||||
const isActuallyAttack =
|
||||
previousFormula === initialAttack.value &&
|
||||
foundry.utils.equals(damageType.toSorted(), initialAttack.type) &&
|
||||
!descriptionFormulas.includes(previousFormula);
|
||||
const type = isActuallyAttack ? 'attack' : 'action';
|
||||
const { value, formula } = calculateAdjustedDamage(previousFormula, type, damageMeta);
|
||||
applyAdjustedDamage(data, value, formula);
|
||||
result.push({ previousFormula, formula });
|
||||
}
|
||||
|
||||
// Override text in the description with those values
|
||||
for (const { previousFormula, formula } of Object.values(result)) {
|
||||
const oldFormulaRegexp = new RegExp(
|
||||
previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?')
|
||||
);
|
||||
item.system.description = item.system.description.replace(oldFormulaRegexp, formula);
|
||||
action.description = action.description.replace(oldFormulaRegexp, formula);
|
||||
}
|
||||
} catch (err) {
|
||||
ui.notifications.warn(`Failed to convert action damage for item ${item.name}`);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally set the tier of the source data, now that everything is complete
|
||||
source.system.tier = tier;
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a damage object to a new damage range
|
||||
* @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term
|
||||
* @throws error if the formula is the wrong type
|
||||
*/
|
||||
function calculateAdjustedDamage(formula, type, { currentDamageRange, newDamageRange }) {
|
||||
const terms = parseTermsFromSimpleFormula(formula);
|
||||
const flatTerms = terms.filter(t => t.diceQuantity === 0);
|
||||
const diceTerms = terms.filter(t => t.diceQuantity > 0);
|
||||
if (flatTerms.length > 1 || diceTerms.length > 1) {
|
||||
throw new Error('invalid formula for conversion');
|
||||
}
|
||||
const value = {
|
||||
...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }),
|
||||
bonus: flatTerms[0]?.bonus ?? 0
|
||||
};
|
||||
const previousExpected = calculateExpectedValue(value);
|
||||
if (previousExpected === 0) return value; // nothing to do
|
||||
|
||||
const dieSizes = [4, 6, 8, 10, 12, 20];
|
||||
const steps = newDamageRange.tier - currentDamageRange.tier;
|
||||
const increasing = steps > 0;
|
||||
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
|
||||
const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation);
|
||||
|
||||
// If this was just a flat number, convert to the expected damage and exit
|
||||
if (value.diceQuantity === 0) {
|
||||
value.bonus = Math.round(expected);
|
||||
return value;
|
||||
}
|
||||
|
||||
const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1;
|
||||
const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 });
|
||||
|
||||
// Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die
|
||||
const baseOverages = Math.floor(value.bonus / getExpectedDie());
|
||||
|
||||
// Prestep. Change number of dice for attacks, bump up/down for actions
|
||||
// We never bump up to d20, though we might bump down from it
|
||||
if (type === 'attack') {
|
||||
const minimum = increasing ? value.diceQuantity : 0;
|
||||
value.diceQuantity = Math.max(minimum, newDamageRange.tier);
|
||||
} else {
|
||||
const currentIdx = dieSizes.indexOf(value.faces);
|
||||
value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)];
|
||||
}
|
||||
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
|
||||
// Attempt to handle negative values.
|
||||
// If we can do it with only step downs, do so. Otherwise remove tier dice, and try again
|
||||
if (value.bonus < 0) {
|
||||
let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||
const currentIdx = dieSizes.indexOf(value.faces);
|
||||
|
||||
// If step downs alone don't suffice, change the flat modifier, then calculate steps required again
|
||||
// If this isn't sufficient, the result will be slightly off. This is unlikely to happen
|
||||
if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) {
|
||||
value.diceQuantity -= increasing ? 1 : Math.abs(steps);
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
if (value.bonus >= 0) return value; // complete
|
||||
}
|
||||
|
||||
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||
value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)];
|
||||
value.bonus = Math.max(0, Math.round(expected - getBaseAverage()));
|
||||
}
|
||||
|
||||
// If value is really high, we add a number of dice based on the number of overages
|
||||
// This attempts to preserve a similar amount of variance when increasing an action
|
||||
const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages;
|
||||
if (type !== 'attack' && increasing && overagesToRemove > 0) {
|
||||
value.diceQuantity += overagesToRemove;
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
}
|
||||
|
||||
const newFormula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus]
|
||||
.filter(p => !!p)
|
||||
.join('+');
|
||||
return { value, formula: newFormula };
|
||||
}
|
||||
|
||||
function getDamagePartsFormula(data) {
|
||||
return data.custom.enabled
|
||||
? data.custom.formula
|
||||
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0].filter(p => !!p).join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates damage to reflect a specific value.
|
||||
* @throws if damage structure is invalid for conversion
|
||||
* @returns the converted formula and value as a simplified term, or null if it doesn't deal HP damage
|
||||
*/
|
||||
function applyAdjustedDamage(diceData, value, formula) {
|
||||
if (value.diceQuantity) {
|
||||
diceData.custom.enabled = false;
|
||||
diceData.bonus = value.bonus;
|
||||
diceData.dice = `d${value.faces}`;
|
||||
diceData.flatMultiplier = value.diceQuantity;
|
||||
} else if (!value.diceQuantity) {
|
||||
diceData.custom.enabled = true;
|
||||
diceData.custom.formula = formula;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { triggerChatRollFx } from '../../helpers/utils.mjs';
|
||||
|
||||
const fields = foundry.data.fields;
|
||||
|
||||
const targetsField = () =>
|
||||
|
|
@ -130,6 +132,35 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
|
|||
});
|
||||
}
|
||||
|
||||
/* TODO: Change how damage data is stored somehow to enable better rerolling */
|
||||
async getRerolledDamage() {
|
||||
if (!this.damage) return;
|
||||
|
||||
const rerolls = [];
|
||||
const update = { system: { damage: {} } };
|
||||
for (const partKey in this.damage) {
|
||||
const part = this.damage[partKey];
|
||||
const testRoll = Roll.fromData(part.parts[0].roll);
|
||||
const rerolled = await testRoll.reroll();
|
||||
rerolls.push(rerolled);
|
||||
|
||||
if (!update.system.damage[partKey]) update.system.damage[partKey] = { parts: [part.parts[0]] };
|
||||
const partData = update.system.damage[partKey].parts[0];
|
||||
update.system.damage[partKey].total = rerolled.total;
|
||||
partData.modifierTotal = rerolled.terms.reduce((acc, x) => {
|
||||
if (x.isDeterministic && !x.operator) acc += x.total;
|
||||
return acc;
|
||||
}, 0);
|
||||
partData.dice = rerolled.dice.map(d => ({ ...d.toJSON(), dice: d.denomination }));
|
||||
partData.total = rerolled.total;
|
||||
partData.roll = rerolled.toJSON();
|
||||
}
|
||||
|
||||
await triggerChatRollFx(rerolls);
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
registerTargetHook() {
|
||||
if (!this.parent.isAuthor || !this.hasTarget) return;
|
||||
if (this.targetMode && this.parent.targetHook !== null) {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ export default class CompendiumBrowserSettings extends foundry.abstract.DataMode
|
|||
})
|
||||
),
|
||||
excludedPacks: new fields.TypedObjectField(
|
||||
new fields.SchemaField({
|
||||
excludedDocumentTypes: new fields.ArrayField(
|
||||
new fields.StringField({ required: true, choices: CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES })
|
||||
)
|
||||
})
|
||||
new fields.TypedObjectField(
|
||||
new fields.SchemaField({
|
||||
excludedDocumentTypes: new fields.ArrayField(
|
||||
new fields.StringField({ required: true, choices: CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES })
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
|
@ -28,7 +30,7 @@ export default class CompendiumBrowserSettings extends foundry.abstract.DataMode
|
|||
const excludedSourceData = this.excludedSources[packageName];
|
||||
if (excludedSourceData && excludedSourceData.excludedDocumentTypes.includes(pack.metadata.type)) return true;
|
||||
|
||||
const excludedPackData = this.excludedPacks[item.pack];
|
||||
const excludedPackData = this.excludedPacks[packageName]?.[pack.metadata.name];
|
||||
if (excludedPackData && excludedPackData.excludedDocumentTypes.includes(pack.metadata.type)) return true;
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export default class CostField extends fields.ArrayField {
|
|||
static calcCosts(costs) {
|
||||
const resources = CostField.getResources.call(this, costs);
|
||||
let filteredCosts = costs;
|
||||
if (this.parent?.metadata.isQuantifiable && this.parent.consumeOnUse === false) {
|
||||
if (this.parent?.isInventoryItem && this.parent.consumeOnUse === false) {
|
||||
filteredCosts = filteredCosts.filter(c => c.key !== 'quantity');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../../../systemRegistration/socket.mjs';
|
||||
import { emitGMUpdate, GMUpdateEvent, RefreshType, socketEvent } from '../../../systemRegistration/socket.mjs';
|
||||
|
||||
const fields = foundry.data.fields;
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ export default class CountdownField extends fields.ArrayField {
|
|||
);
|
||||
}
|
||||
|
||||
await emitAsGM(
|
||||
await emitGMUpdate(
|
||||
GMUpdateEvent.UpdateCountdowns,
|
||||
async () => {
|
||||
const countdownSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
|
||||
|
|
|
|||
|
|
@ -72,9 +72,6 @@ export default class DamageField extends fields.SchemaField {
|
|||
damageConfig.source.message = messageId;
|
||||
damageConfig.directDamage = !!damageConfig.source?.message;
|
||||
|
||||
// if(damageConfig.source?.message && game.modules.get('dice-so-nice')?.active)
|
||||
// await game.dice3d.waitFor3DAnimationByMessageID(damageConfig.source.message);
|
||||
|
||||
const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig);
|
||||
if (!damageResult) return false;
|
||||
if (damageResult.actionChatMessageHandled) config.actionChatMessageHandled = true;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { emitAsGM, GMUpdateEvent } from '../../../systemRegistration/socket.mjs';
|
||||
|
||||
const fields = foundry.data.fields;
|
||||
|
||||
export default class EffectsField extends fields.ArrayField {
|
||||
|
|
@ -34,8 +32,7 @@ export default class EffectsField extends fields.ArrayField {
|
|||
}
|
||||
if (EffectsField.getAutomation() || force) {
|
||||
targets ??= (message.system?.targets ?? config.targets).filter(t => !config.hasRoll || t.hit);
|
||||
await emitAsGM(GMUpdateEvent.UpdateEffect, EffectsField.applyEffects.bind(this), targets, this.uuid);
|
||||
// EffectsField.applyEffects.call(this, config.targets.filter(t => !config.hasRoll || t.hit));
|
||||
EffectsField.applyEffects.call(this, targets);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +56,7 @@ export default class EffectsField extends fields.ArrayField {
|
|||
if (!token) return;
|
||||
|
||||
const messageToken = token.document ?? token;
|
||||
const conditionImmunities = messageToken.actor.system.rules.conditionImmunities ?? {};
|
||||
const conditionImmunities = messageToken.actor.system.rules?.conditionImmunities ?? {};
|
||||
messageTargets.push({
|
||||
token: messageToken,
|
||||
conditionImmunities: Object.values(conditionImmunities).some(x => x)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { itemAbleRollParse } from '../../../helpers/utils.mjs';
|
||||
import { itemAbleRollParse, triggerChatRollFx } from '../../../helpers/utils.mjs';
|
||||
import FormulaField from '../formulaField.mjs';
|
||||
|
||||
const fields = foundry.data.fields;
|
||||
|
|
@ -40,7 +40,7 @@ export default class DHSummonField extends fields.ArrayField {
|
|||
const roll = new Roll(itemAbleRollParse(summon.count, this.actor, this.item));
|
||||
await roll.evaluate();
|
||||
const count = roll.total;
|
||||
if (!roll.isDeterministic && game.modules.get('dice-so-nice')?.active) rolls.push(roll);
|
||||
if (!roll.isDeterministic) rolls.push(roll);
|
||||
|
||||
const actor = await DHSummonField.getWorldActor(await foundry.utils.fromUuid(summon.actorUUID));
|
||||
/* Extending summon data in memory so it's available in actionField.toChat. Think it's harmless, but ugly. Could maybe find a better way. */
|
||||
|
|
@ -56,7 +56,7 @@ export default class DHSummonField extends fields.ArrayField {
|
|||
}
|
||||
}
|
||||
|
||||
if (rolls.length) await Promise.all(rolls.map(roll => game.dice3d.showForRoll(roll, game.user, true)));
|
||||
if (rolls.length) await triggerChatRollFx(rolls);
|
||||
|
||||
this.actor.sheet?.minimize();
|
||||
DHSummonField.handleSummon(summonData, this.actor);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,18 @@ class ResourcesField extends fields.TypedObjectField {
|
|||
value.isReversed = resources[key].reverse;
|
||||
value.max = typeof resource.max === 'number' ? (value.max ?? resource.max) : null;
|
||||
}
|
||||
Object.defineProperty(data, 'clamp', {
|
||||
value: function () {
|
||||
for (const key of Object.keys(this)) {
|
||||
const resource = this[key];
|
||||
if (typeof resource?.max === 'number') {
|
||||
resource.value = Math.clamp(resource.value, 0, resource.max);
|
||||
}
|
||||
}
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default class ForeignDocumentUUIDArrayField extends foundry.data.fields.A
|
|||
*/
|
||||
constructor(fieldOption = {}, options = {}, context = {}) {
|
||||
super(new ForeignDocumentUUIDField(fieldOption), options, context);
|
||||
this.options.prune ??= true;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class CharacterData extends foundry.abstract.DataModel {
|
|||
}),
|
||||
rollData: new fields.JSONField({ nullable: true, initial: null }),
|
||||
selected: new fields.BooleanField({ initial: false }),
|
||||
successfull: new fields.BooleanField({ nullable: true, initial: null })
|
||||
successful: new fields.BooleanField({ nullable: true, initial: null })
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import BaseDataItem from './base.mjs';
|
||||
import ItemLinkFields from '../../data/fields/itemLinkFields.mjs';
|
||||
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
||||
import { fromUuids, getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
||||
|
||||
export default class DHAncestry extends BaseDataItem {
|
||||
/** @inheritDoc */
|
||||
|
|
@ -45,6 +45,10 @@ export default class DHAncestry extends BaseDataItem {
|
|||
|
||||
/**@inheritdoc */
|
||||
async getDescriptionData() {
|
||||
// Preload all ancestry features for acquisition from the cache
|
||||
// todo: make feature acquisition async and replace feature helpers for methods
|
||||
await fromUuids(this._source.features.map(f => f.item));
|
||||
|
||||
const baseDescription = this.description;
|
||||
const features = await getFeaturesHTMLData(this.features);
|
||||
|
||||
|
|
|
|||
|
|
@ -141,16 +141,6 @@ export default class DHArmor extends AttachableItem {
|
|||
}
|
||||
}
|
||||
|
||||
_onUpdate(a, b, c) {
|
||||
super._onUpdate(a, b, c);
|
||||
|
||||
if (this.actor?.type === 'character') {
|
||||
for (const party of this.actor.parties) {
|
||||
party.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateDocumentData(source) {
|
||||
if (!source.system.armor) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@
|
|||
* @property {boolean} isInventoryItem- Indicates whether items of this type is a Inventory Item
|
||||
*/
|
||||
|
||||
import { addLinkedItemsDiff, getScrollTextData, updateLinkedItemApps } from '../../helpers/utils.mjs';
|
||||
import {
|
||||
addLinkedItemsDiff,
|
||||
getScrollTextData,
|
||||
createShallowProxy,
|
||||
updateLinkedItemApps
|
||||
} from '../../helpers/utils.mjs';
|
||||
import { ActionsField } from '../fields/actionField.mjs';
|
||||
import FormulaField from '../fields/formulaField.mjs';
|
||||
|
||||
|
|
@ -159,8 +164,8 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
|
|||
* @returns {object}
|
||||
*/
|
||||
getRollData(options = {}) {
|
||||
const actorRollData = this.actor?.getRollData() ?? {};
|
||||
const data = { ...actorRollData, item: { ...this } };
|
||||
const data = this.actor?.getRollData() ?? {};
|
||||
data.item = createShallowProxy(this);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +200,7 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
|
|||
const features = [];
|
||||
for (let f of this.features) {
|
||||
const fBase = f.item ?? f;
|
||||
const feature = fBase.system ? fBase : await foundry.utils.fromUuid(fBase.uuid);
|
||||
const feature = fBase.pack ? await foundry.utils.fromUuid(fBase.uuid) : fBase;
|
||||
features.push(
|
||||
foundry.utils.mergeObject(
|
||||
feature.toObject(),
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ export default class DHBeastform extends BaseDataItem {
|
|||
}),
|
||||
scale: new fields.NumberField({ nullable: false, min: 0.2, max: 3, step: 0.05, initial: 1 }),
|
||||
height: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true }),
|
||||
width: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true })
|
||||
width: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true }),
|
||||
depth: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true })
|
||||
}),
|
||||
mainTrait: new fields.StringField({
|
||||
required: true,
|
||||
|
|
@ -192,7 +193,8 @@ export default class DHBeastform extends BaseDataItem {
|
|||
tokenSize: {
|
||||
scale: this.parent.parent.prototypeToken.texture.scaleX,
|
||||
height: this.parent.parent.prototypeToken.height,
|
||||
width: this.parent.parent.prototypeToken.width
|
||||
width: this.parent.parent.prototypeToken.width,
|
||||
depth: this.parent.parent.prototypeToken.depth
|
||||
}
|
||||
},
|
||||
advantageOn: this.advantageOn,
|
||||
|
|
@ -211,10 +213,12 @@ export default class DHBeastform extends BaseDataItem {
|
|||
: null;
|
||||
const width = autoTokenSize ?? this.tokenSize.width;
|
||||
const height = autoTokenSize ?? this.tokenSize.height;
|
||||
const depth = autoTokenSize ?? this.tokenSize.depth;
|
||||
|
||||
const prototypeTokenUpdate = {
|
||||
height,
|
||||
width,
|
||||
depth,
|
||||
texture: {
|
||||
src: this.tokenImg,
|
||||
scaleX: this.tokenSize.scale,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import BaseDataItem from './base.mjs';
|
|||
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
|
||||
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
|
||||
import ItemLinkFields from '../fields/itemLinkFields.mjs';
|
||||
import { addLinkedItemsDiff, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs';
|
||||
import { addLinkedItemsDiff, fromUuids, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs';
|
||||
import { DhLevelOption } from '../levelTier.mjs';
|
||||
|
||||
export default class DHClass extends BaseDataItem {
|
||||
|
|
@ -32,7 +32,6 @@ export default class DHClass extends BaseDataItem {
|
|||
}),
|
||||
evasion: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.evasion' }),
|
||||
features: new ItemLinkFields(),
|
||||
subclasses: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
|
||||
inventory: new fields.SchemaField({
|
||||
take: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
|
||||
choiceA: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
|
||||
|
|
@ -78,6 +77,25 @@ export default class DHClass extends BaseDataItem {
|
|||
return this.features.filter(x => x.type === CONFIG.DH.ITEM.featureSubTypes.class).map(x => x.item);
|
||||
}
|
||||
|
||||
async fetchSubclasses() {
|
||||
const uuids = [this.parent.uuid, this.parent._stats?.compendiumSource].filter(u => !!u);
|
||||
const subclasses = game.items.filter(x => x.type === 'subclass' && uuids.includes(x.system.linkedClass));
|
||||
for (const pack of game.packs) {
|
||||
const packIds = [];
|
||||
const indexes = await pack.getIndex({ fields: ['system.linkedClass'] });
|
||||
for (const index of indexes) {
|
||||
if (index.type !== 'subclass') continue;
|
||||
if (!uuids.includes(index.system?.linkedClass)) continue;
|
||||
if (subclasses.find(x => x.uuid === index.uuid)) continue;
|
||||
packIds.push(index._id);
|
||||
}
|
||||
|
||||
if (packIds.length > 0) subclasses.push(...(await pack.getDocuments({ _id__in: packIds })));
|
||||
}
|
||||
|
||||
return subclasses;
|
||||
}
|
||||
|
||||
async _preCreate(data, options, user) {
|
||||
if (this.actor?.type === 'character') {
|
||||
const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto;
|
||||
|
|
@ -207,6 +225,10 @@ export default class DHClass extends BaseDataItem {
|
|||
classItems.push(contentLink.outerHTML);
|
||||
}
|
||||
|
||||
// Preload all class features for acquisition from the cache
|
||||
// todo: make feature acquisition async and replace feature helpers for methods
|
||||
await fromUuids(this._source.features.map(f => f.item));
|
||||
|
||||
const hopeFeatures = await getFeaturesHTMLData(this.hopeFeatures);
|
||||
const classFeatures = await getFeaturesHTMLData(this.classFeatures);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
||||
import { fromUuids, getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
||||
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
|
||||
import BaseDataItem from './base.mjs';
|
||||
|
||||
|
|
@ -27,6 +27,10 @@ export default class DHCommunity extends BaseDataItem {
|
|||
|
||||
/**@inheritdoc */
|
||||
async getDescriptionData() {
|
||||
// Preload all community features for acquisition from the cache
|
||||
// todo: make feature acquisition async and replace feature helpers for methods
|
||||
await fromUuids(this._source.features);
|
||||
|
||||
const baseDescription = this.description;
|
||||
const features = await getFeaturesHTMLData(this.features);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
||||
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
|
||||
import { fromUuids, getFeaturesHTMLData } from '../../helpers/utils.mjs';
|
||||
import ItemLinkFields from '../fields/itemLinkFields.mjs';
|
||||
import BaseDataItem from './base.mjs';
|
||||
|
||||
|
|
@ -28,7 +27,7 @@ export default class DHSubclass extends BaseDataItem {
|
|||
features: new ItemLinkFields(),
|
||||
featureState: new fields.NumberField({ required: true, initial: 1, min: 1 }),
|
||||
isMulticlass: new fields.BooleanField({ initial: false }),
|
||||
linkedClass: new ForeignDocumentUUIDField({ type: 'Item', nullable: true, initial: null })
|
||||
linkedClass: new fields.DocumentUUIDField({ type: 'Item', nullable: true, initial: null })
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -56,37 +55,30 @@ export default class DHSubclass extends BaseDataItem {
|
|||
if (allowed === false) return;
|
||||
|
||||
if (this.actor?.type === 'character') {
|
||||
const dataUuid = data.uuid ?? data._stats.compendiumSource ?? `Item.${data._id}`;
|
||||
if (this.actor.system.class.subclass) {
|
||||
if (this.actor.system.multiclass.subclass) {
|
||||
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassesAlreadyPresent'));
|
||||
return false;
|
||||
} else {
|
||||
const multiclass = this.actor.items.find(x => x.type === 'class' && x.system.isMulticlass);
|
||||
if (!multiclass) {
|
||||
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.missingMulticlass'));
|
||||
return false;
|
||||
}
|
||||
const { value: actorClass, subclass: existingSubclass } = this.actor.system.class;
|
||||
const { value: multiclass, subclass: existingMultisubclass } = this.actor.system.multiclass;
|
||||
if (!actorClass && !multiclass) {
|
||||
ui.notifications.warn('DAGGERHEART.UI.Notifications.missingClass', { localize: true });
|
||||
return false;
|
||||
}
|
||||
if (existingSubclass && existingMultisubclass) {
|
||||
ui.notifications.warn('DAGGERHEART.UI.Notifications.subclassesAlreadyPresent', { localize: true });
|
||||
return false;
|
||||
}
|
||||
if (existingSubclass && !multiclass) {
|
||||
ui.notifications.warn('DAGGERHEART.UI.Notifications.missingMulticlass', { localize: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (multiclass.system.subclasses.every(x => x.uuid !== dataUuid)) {
|
||||
ui.notifications.error(
|
||||
game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInMulticlass')
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.updateSource({ isMulticlass: true });
|
||||
}
|
||||
} else {
|
||||
const actorClass = this.actor.items.find(x => x.type === 'class' && !x.system.isMulticlass);
|
||||
if (!actorClass) {
|
||||
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.missingClass'));
|
||||
return false;
|
||||
}
|
||||
if (actorClass.system.subclasses.every(x => x.uuid !== dataUuid)) {
|
||||
ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInClass'));
|
||||
return false;
|
||||
}
|
||||
const match = [multiclass, actorClass].find(
|
||||
c => c && (c._stats.compendiumSource ?? c.uuid) === this.linkedClass
|
||||
);
|
||||
if (!match) {
|
||||
const key = multiclass ? 'subclassNotInMulticlass' : 'subclassNotInClass';
|
||||
ui.notifications.warn(`DAGGERHEART.UI.Notifications.${key}`, { localize: true });
|
||||
return false;
|
||||
} else if (match.system.isMulticlass) {
|
||||
await this.updateSource({ isMulticlass: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +90,11 @@ export default class DHSubclass extends BaseDataItem {
|
|||
const spellcastTrait = this.spellcastingTrait
|
||||
? game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.spellcastingTrait].label)
|
||||
: null;
|
||||
|
||||
// Preload all subclass features for acquisition from the cache
|
||||
// todo: make feature acquisition async and replace feature helpers for methods
|
||||
await fromUuids(this._source.features.map(f => f.item));
|
||||
|
||||
const foundationFeatures = await getFeaturesHTMLData(this.foundationFeatures);
|
||||
const specializationFeatures = await getFeaturesHTMLData(this.specializationFeatures);
|
||||
const masteryFeatures = await getFeaturesHTMLData(this.masteryFeatures);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ export default class DHWeapon extends AttachableItem {
|
|||
equipped: new fields.BooleanField({ initial: false }),
|
||||
|
||||
//SETTINGS
|
||||
secondary: new fields.BooleanField({ initial: false, label: 'DAGGERHEART.ITEMS.Weapon.secondaryWeapon' }),
|
||||
secondary: new fields.BooleanField({
|
||||
initial: false,
|
||||
label: 'DAGGERHEART.ITEMS.Weapon.secondaryWeapon.full'
|
||||
}),
|
||||
burden: new fields.StringField({
|
||||
required: true,
|
||||
choices: CONFIG.DH.GENERAL.burden,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default class DHScene extends foundry.abstract.DataModel {
|
|||
close: new fields.NumberField({ integer: true, label: 'DAGGERHEART.CONFIG.Range.close.name' }),
|
||||
far: new fields.NumberField({ integer: true, label: 'DAGGERHEART.CONFIG.Range.far.name' })
|
||||
}),
|
||||
sceneEnvironments: new ForeignDocumentUUIDArrayField({ type: 'Actor', prune: true })
|
||||
sceneEnvironments: new ForeignDocumentUUIDArrayField({ type: 'Actor' })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { defaultRestOptions } from '../../config/generalConfig.mjs';
|
||||
import { resetAndRerenderActors } from '../../helpers/utils.mjs';
|
||||
import { ActionsField } from '../fields/actionField.mjs';
|
||||
|
||||
const currencyField = (initial, label, icon) =>
|
||||
|
|
@ -54,7 +55,7 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
|
|||
maxDomains: new fields.NumberField({
|
||||
required: true,
|
||||
integer: true,
|
||||
min: 1,
|
||||
min: 0,
|
||||
initial: 2,
|
||||
label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxDomains.label'
|
||||
}),
|
||||
|
|
@ -196,6 +197,12 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
|
|||
return source;
|
||||
}
|
||||
|
||||
_initialize(options) {
|
||||
super._initialize(options);
|
||||
this.maxDomains ||= Infinity;
|
||||
this.maxLoadout ||= Infinity;
|
||||
}
|
||||
|
||||
/** Invoked by the setting when data changes */
|
||||
handleChange() {
|
||||
if (this.maxFear) {
|
||||
|
|
@ -203,7 +210,7 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
|
|||
}
|
||||
|
||||
this.refreshConfig();
|
||||
this.#resetActors();
|
||||
resetAndRerenderActors();
|
||||
}
|
||||
|
||||
/** Update config values based on homebrew data. Make sure the references don't change */
|
||||
|
|
@ -224,29 +231,6 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a reset and non-forced re-render on all given actors (if given)
|
||||
* or all world actors and actors in all scenes to show immediate results for a changed setting.
|
||||
*/
|
||||
#resetActors() {
|
||||
const actors = new Set(
|
||||
[
|
||||
game.actors.contents,
|
||||
game.scenes.contents.flatMap(s => s.tokens.contents).flatMap(t => t.actor ?? [])
|
||||
].flat()
|
||||
);
|
||||
for (const actor of actors) {
|
||||
for (const app of Object.values(actor.apps)) {
|
||||
for (const element of app.element?.querySelectorAll('prose-mirror.active')) {
|
||||
element.open = false; // This triggers a save
|
||||
}
|
||||
}
|
||||
|
||||
actor.reset();
|
||||
actor.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Resource extends foundry.abstract.DataModel {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { resetAndRerenderActors } from '../../helpers/utils.mjs';
|
||||
|
||||
export default class DhMetagaming extends foundry.abstract.DataModel {
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields;
|
||||
|
|
@ -6,7 +8,24 @@ export default class DhMetagaming extends foundry.abstract.DataModel {
|
|||
initial: false,
|
||||
label: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hideObserverPermissionInChat.label',
|
||||
hint: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hideObserverPermissionInChat.hint'
|
||||
}),
|
||||
hidePartyStats: new fields.StringField({
|
||||
initial: 'never',
|
||||
label: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.label',
|
||||
hint: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.hint',
|
||||
required: true,
|
||||
nullable: false,
|
||||
choices: {
|
||||
never: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.choices.never',
|
||||
players: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.choices.players',
|
||||
always: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.choices.always'
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/** Invoked by the setting when data changes */
|
||||
handleChange() {
|
||||
resetAndRerenderActors();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue