Merged with main

This commit is contained in:
WBHarry 2026-02-26 16:20:24 +01:00
commit 2123ae1965
177 changed files with 2677 additions and 657 deletions

View file

@ -1,9 +1,12 @@
import DHAdversarySettings from '../../applications/sheets-configs/adversary-settings.mjs';
import { ActionField } from '../fields/actionField.mjs';
import BaseDataActor, { commonActorRules } from './base.mjs';
import { commonActorRules } from './base.mjs';
import DhCreature from './creature.mjs';
import { resourceField, bonusField } from '../fields/actorField.mjs';
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
export default class DhpAdversary extends BaseDataActor {
export default class DhpAdversary extends DhCreature {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
static get metadata() {
@ -40,7 +43,14 @@ export default class DhpAdversary extends BaseDataActor {
integer: true,
label: 'DAGGERHEART.GENERAL.hordeHp'
}),
criticalThreshold: new fields.NumberField({ required: true, integer: true, min: 1, max: 20, initial: 20 }),
criticalThreshold: new fields.NumberField({
required: true,
integer: true,
min: 1,
max: 20,
initial: 20,
label: 'DAGGERHEART.ACTIONS.Settings.criticalThreshold'
}),
damageThresholds: new fields.SchemaField({
major: new fields.NumberField({
required: true,
@ -180,6 +190,10 @@ export default class DhpAdversary extends BaseDataActor {
}
}
prepareDerivedData() {
this.attack.roll.isStandardAttack = true;
}
_getTags() {
const tags = [
game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${this.tier}`),
@ -188,4 +202,211 @@ export default class DhpAdversary extends BaseDataActor {
];
return tags;
}
/** 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
for (const action of Object.values(item.system.actions)) {
if (!action.damage) continue;
// Parse damage, and convert all formula matches in the descriptions to the new damage
try {
const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' });
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
*/
#adjustActionDamage(action, damageMeta) {
// The current algorithm only returns a value if there is a single damage part
const hpDamageParts = action.damage.parts.filter(d => d.applyTo === 'hitPoints');
if (hpDamageParts.length !== 1) throw new Error('incorrect number of hp parts');
const result = {};
for (const property of ['value', 'valueAlt']) {
const data = hpDamageParts[0][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;
}
}

View file

@ -29,17 +29,40 @@ const resistanceField = (resistanceLabel, immunityLabel, reductionLabel) =>
/* Common rules applying to Characters and Adversaries */
export const commonActorRules = (extendedData = { damageReduction: {}, attack: { damage: {} } }) => ({
conditionImmunities: new fields.SchemaField({
hidden: new fields.BooleanField({ initial: false }),
restrained: new fields.BooleanField({ initial: false }),
vulnerable: new fields.BooleanField({ initial: false })
hidden: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.hidden'
}),
restrained: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.restrained'
}),
vulnerable: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.vulnerable'
})
}),
damageReduction: new fields.SchemaField({
thresholdImmunities: new fields.SchemaField({
minor: new fields.BooleanField({ initial: false })
minor: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.thresholdImmunities.minor.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.thresholdImmunities.minor.hint'
})
}),
reduceSeverity: new fields.SchemaField({
magical: new fields.NumberField({ initial: 0, min: 0 }),
physical: new fields.NumberField({ initial: 0, min: 0 })
magical: new fields.NumberField({
initial: 0,
min: 0,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.magical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.magical.hint'
}),
physical: new fields.NumberField({
initial: 0,
min: 0,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.physical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.physical.hint'
})
}),
...(extendedData.damageReduction ?? {})
}),
@ -49,12 +72,16 @@ export const commonActorRules = (extendedData = { damageReduction: {}, attack: {
hpDamageMultiplier: new fields.NumberField({
required: true,
nullable: false,
initial: 1
initial: 1,
label: 'DAGGERHEART.GENERAL.Attack.hpDamageMultiplier.label',
hint: 'DAGGERHEART.GENERAL.Attack.hpDamageMultiplier.hint'
}),
hpDamageTakenMultiplier: new fields.NumberField({
required: true,
nullable: false,
initial: 1
initial: 1,
label: 'DAGGERHEART.GENERAL.Attack.hpDamageTakenMultiplier.label',
hint: 'DAGGERHEART.GENERAL.Attack.hpDamageTakenMultiplier.hint'
}),
...(extendedData.attack?.damage ?? {})
})

View file

@ -1,13 +1,14 @@
import { burden } from '../../config/generalConfig.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import DhLevelData from '../levelData.mjs';
import BaseDataActor, { commonActorRules } from './base.mjs';
import { commonActorRules } from './base.mjs';
import DhCreature from './creature.mjs';
import { attributeField, resourceField, stressDamageReductionRule, bonusField } from '../fields/actorField.mjs';
import { ActionField } from '../fields/actionField.mjs';
import DHCharacterSettings from '../../applications/sheets-configs/character-settings.mjs';
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
export default class DhCharacter extends BaseDataActor {
export default class DhCharacter extends DhCreature {
/**@override */
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Character'];
@ -36,14 +37,18 @@ export default class DhCharacter extends BaseDataActor {
'DAGGERHEART.ACTORS.Character.maxHPBonus'
),
stress: resourceField(6, 0, 'DAGGERHEART.GENERAL.stress', true),
hope: new fields.SchemaField({
value: new fields.NumberField({
initial: 2,
min: 0,
integer: true,
label: 'DAGGERHEART.GENERAL.hope'
})
})
hope: new fields.SchemaField(
{
value: new fields.NumberField({
initial: 2,
min: 0,
integer: true,
label: 'DAGGERHEART.GENERAL.hope'
}),
isReversed: new fields.BooleanField({ initial: false })
},
{ label: 'DAGGERHEART.GENERAL.hope' }
)
}),
traits: new fields.SchemaField({
agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'),
@ -128,14 +133,6 @@ export default class DhCharacter extends BaseDataActor {
}
}
}),
advantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.advantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint'
}),
disadvantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.disadvantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.disadvantageSources.hint'
}),
levelData: new fields.EmbeddedDataField(DhLevelData),
bonuses: new fields.SchemaField({
roll: new fields.SchemaField({
@ -222,8 +219,16 @@ export default class DhCharacter extends BaseDataActor {
rules: new fields.SchemaField({
...commonActorRules({
damageReduction: {
magical: new fields.BooleanField({ initial: false }),
physical: new fields.BooleanField({ initial: false }),
magical: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.magical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.magical.hint'
}),
physical: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.physical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.physical.hint'
}),
maxArmorMarked: new fields.SchemaField({
value: new fields.NumberField({
required: true,
@ -253,7 +258,10 @@ export default class DhCharacter extends BaseDataActor {
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.increasePerArmorMark.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.increasePerArmorMark.hint'
}),
disabledArmor: new fields.BooleanField({ intial: false })
disabledArmor: new fields.BooleanField({
intial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.disabledArmor.label'
})
},
attack: {
damage: {
@ -301,12 +309,14 @@ export default class DhCharacter extends BaseDataActor {
label: 'DAGGERHEART.ACTORS.Character.defaultFearDice'
})
}),
runeWard: new fields.BooleanField({ initial: false }),
burden: new fields.SchemaField({
ignore: new fields.BooleanField()
ignore: new fields.BooleanField({ label: 'DAGGERHEART.ACTORS.Character.burden.ignore.label' })
}),
roll: new fields.SchemaField({
guaranteedCritical: new fields.BooleanField()
guaranteedCritical: new fields.BooleanField({
label: 'DAGGERHEART.ACTORS.Character.roll.guaranteedCritical.label',
hint: 'DAGGERHEART.ACTORS.Character.roll.guaranteedCritical.hint'
})
})
}),
sidebarFavorites: new ForeignDocumentUUIDArrayField({ type: 'Item' })
@ -640,7 +650,7 @@ export default class DhCharacter extends BaseDataActor {
};
const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope;
this.resources.hope.max = globalHopeMax - this.scars;
this.resources.hope.max = globalHopeMax;
this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0;
/* Companion Related Data */
@ -664,6 +674,7 @@ export default class DhCharacter extends BaseDataActor {
}
}
this.resources.hope.max -= this.scars;
this.resources.hope.value = Math.min(baseHope, this.resources.hope.max);
this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait;

View file

@ -1,4 +1,4 @@
import BaseDataActor from './base.mjs';
import DhCreature from './creature.mjs';
import DhLevelData from '../levelData.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import { ActionField } from '../fields/actionField.mjs';
@ -6,7 +6,7 @@ import { adjustDice, adjustRange } from '../../helpers/utils.mjs';
import DHCompanionSettings from '../../applications/sheets-configs/companion-settings.mjs';
import { resourceField, bonusField } from '../fields/actorField.mjs';
export default class DhCompanion extends BaseDataActor {
export default class DhCompanion extends DhCreature {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Companion'];
/**@inheritdoc */
@ -53,9 +53,18 @@ export default class DhCompanion extends BaseDataActor {
),
rules: new fields.SchemaField({
conditionImmunities: new fields.SchemaField({
hidden: new fields.BooleanField({ initial: false }),
restrained: new fields.BooleanField({ initial: false }),
vulnerable: new fields.BooleanField({ initial: false })
hidden: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.hidden'
}),
restrained: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.restrained'
}),
vulnerable: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.vulnerable'
})
})
}),
attack: new ActionField({

View file

@ -0,0 +1,20 @@
import BaseDataActor from './base.mjs';
export default class DhCreature extends BaseDataActor {
/**@inheritdoc */
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
advantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.advantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint'
}),
disadvantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.disadvantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.disadvantageSources.hint'
})
};
}
}