mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-01-11 19:25:21 +01:00
287 lines
13 KiB
JavaScript
287 lines
13 KiB
JavaScript
import DHAdversarySettings from '../../applications/sheets-configs/adversary-settings.mjs';
|
|
import { ActionField } from '../fields/actionField.mjs';
|
|
import BaseDataActor, { commonActorRules } from './base.mjs';
|
|
import { resourceField, bonusField } from '../fields/actorField.mjs';
|
|
import { parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
|
import { adversaryScalingData } from '../../config/actorConfig.mjs';
|
|
|
|
export default class DhpAdversary extends BaseDataActor {
|
|
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
|
|
|
|
static get metadata() {
|
|
return foundry.utils.mergeObject(super.metadata, {
|
|
label: 'TYPES.Actor.adversary',
|
|
type: 'adversary',
|
|
settingSheet: DHAdversarySettings,
|
|
hasAttribution: true,
|
|
usesSize: true
|
|
});
|
|
}
|
|
|
|
static defineSchema() {
|
|
const fields = foundry.data.fields;
|
|
return {
|
|
...super.defineSchema(),
|
|
tier: new fields.NumberField({
|
|
required: true,
|
|
integer: true,
|
|
choices: CONFIG.DH.GENERAL.tiers,
|
|
initial: CONFIG.DH.GENERAL.tiers[1].id
|
|
}),
|
|
type: new fields.StringField({
|
|
required: true,
|
|
choices: CONFIG.DH.ACTOR.allAdversaryTypes,
|
|
initial: CONFIG.DH.ACTOR.adversaryTypes.standard.id
|
|
}),
|
|
motivesAndTactics: new fields.StringField(),
|
|
notes: new fields.HTMLField(),
|
|
difficulty: new fields.NumberField({ required: true, initial: 1, integer: true }),
|
|
hordeHp: new fields.NumberField({
|
|
required: true,
|
|
initial: 1,
|
|
integer: true,
|
|
label: 'DAGGERHEART.GENERAL.hordeHp'
|
|
}),
|
|
criticalThreshold: new fields.NumberField({ required: true, integer: true, min: 1, max: 20, initial: 20 }),
|
|
damageThresholds: new fields.SchemaField({
|
|
major: new fields.NumberField({
|
|
required: true,
|
|
initial: 0,
|
|
integer: true,
|
|
label: 'DAGGERHEART.GENERAL.DamageThresholds.majorThreshold'
|
|
}),
|
|
severe: new fields.NumberField({
|
|
required: true,
|
|
initial: 0,
|
|
integer: true,
|
|
label: 'DAGGERHEART.GENERAL.DamageThresholds.severeThreshold'
|
|
})
|
|
}),
|
|
resources: new fields.SchemaField({
|
|
hitPoints: resourceField(0, 0, 'DAGGERHEART.GENERAL.HitPoints.plural', true),
|
|
stress: resourceField(0, 0, 'DAGGERHEART.GENERAL.stress', true)
|
|
}),
|
|
rules: new fields.SchemaField({
|
|
...commonActorRules()
|
|
}),
|
|
attack: new ActionField({
|
|
initial: {
|
|
name: 'Attack',
|
|
img: 'icons/skills/melee/blood-slash-foam-red.webp',
|
|
_id: foundry.utils.randomID(),
|
|
systemPath: 'attack',
|
|
chatDisplay: false,
|
|
type: 'attack',
|
|
range: 'melee',
|
|
target: {
|
|
type: 'any',
|
|
amount: 1
|
|
},
|
|
roll: {
|
|
type: 'attack'
|
|
},
|
|
damage: {
|
|
parts: [
|
|
{
|
|
type: ['physical'],
|
|
value: {
|
|
multiplier: 'flat'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}),
|
|
experiences: new fields.TypedObjectField(
|
|
new fields.SchemaField({
|
|
name: new fields.StringField(),
|
|
value: new fields.NumberField({ required: true, integer: true, initial: 1 }),
|
|
description: new fields.StringField()
|
|
})
|
|
),
|
|
bonuses: new fields.SchemaField({
|
|
roll: new fields.SchemaField({
|
|
attack: bonusField('DAGGERHEART.GENERAL.Roll.attack'),
|
|
action: bonusField('DAGGERHEART.GENERAL.Roll.action'),
|
|
reaction: bonusField('DAGGERHEART.GENERAL.Roll.reaction')
|
|
}),
|
|
damage: new fields.SchemaField({
|
|
physical: bonusField('DAGGERHEART.GENERAL.Damage.physicalDamage'),
|
|
magical: bonusField('DAGGERHEART.GENERAL.Damage.magicalDamage')
|
|
})
|
|
})
|
|
};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**@inheritdoc */
|
|
static DEFAULT_ICON = 'systems/daggerheart/assets/icons/documents/actors/dragon-head.svg';
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
get attackBonus() {
|
|
return this.attack.roll.bonus;
|
|
}
|
|
|
|
get features() {
|
|
return this.parent.items.filter(x => x.type === 'feature');
|
|
}
|
|
|
|
isItemValid(source) {
|
|
return source.type === 'feature';
|
|
}
|
|
|
|
async _preUpdate(changes, options, user) {
|
|
const allowed = await super._preUpdate(changes, options, user);
|
|
if (allowed === false) return false;
|
|
|
|
if (this.type === CONFIG.DH.ACTOR.adversaryTypes.horde.id) {
|
|
const autoHordeDamage = game.settings.get(
|
|
CONFIG.DH.id,
|
|
CONFIG.DH.SETTINGS.gameSettings.Automation
|
|
).hordeDamage;
|
|
if (autoHordeDamage && changes.system?.resources?.hitPoints?.value !== undefined) {
|
|
const hordeActiveEffect = this.parent.effects.find(x => x.type === 'horde');
|
|
if (hordeActiveEffect) {
|
|
const halfHP = Math.ceil(this.resources.hitPoints.max / 2);
|
|
const newHitPoints = changes.system.resources.hitPoints.value;
|
|
const previouslyAboveHalf = this.resources.hitPoints.value < halfHP;
|
|
const loweredBelowHalf = previouslyAboveHalf && newHitPoints >= halfHP;
|
|
const raisedAboveHalf = !previouslyAboveHalf && newHitPoints < halfHP;
|
|
if (loweredBelowHalf) {
|
|
await hordeActiveEffect.update({ disabled: false });
|
|
} else if (raisedAboveHalf) {
|
|
await hordeActiveEffect.update({ disabled: true });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_onUpdate(changes, options, userId) {
|
|
super._onUpdate(changes, options, userId);
|
|
|
|
if (game.user.id === userId) {
|
|
if (changes.system?.type) {
|
|
const existingHordeEffect = this.parent.effects.find(x => x.type === 'horde');
|
|
if (changes.system.type === CONFIG.DH.ACTOR.adversaryTypes.horde.id) {
|
|
if (!existingHordeEffect)
|
|
this.parent.createEmbeddedDocuments('ActiveEffect', [
|
|
{
|
|
type: 'horde',
|
|
name: game.i18n.localize('DAGGERHEART.CONFIG.AdversaryType.horde.label'),
|
|
img: 'icons/magic/movement/chevrons-down-yellow.webp',
|
|
disabled: true
|
|
}
|
|
]);
|
|
} else {
|
|
existingHordeEffect?.delete();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_getTags() {
|
|
const tags = [
|
|
game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${this.tier}`),
|
|
`${game.i18n.localize(`DAGGERHEART.CONFIG.AdversaryType.${this.type}.label`)}`,
|
|
`${game.i18n.localize('DAGGERHEART.GENERAL.difficulty')}: ${this.difficulty}`
|
|
];
|
|
return tags;
|
|
}
|
|
|
|
adjustForTier(tier) {
|
|
const source = this.parent.toObject(true);
|
|
console.log('Actors and source', this.parent, source);
|
|
|
|
/** @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;
|
|
}
|
|
|
|
/** Returns the mean and standard deviation of a series of average damages */
|
|
const analyzeDamageRange = range => {
|
|
if (range.length <= 1) throw Error('Unexpected damage range, must have at least two entries');
|
|
const mean = range.reduce((a, b) => a + b, 0) / range.length;
|
|
const deviations = range.map(r => r - mean);
|
|
const standardDeviation = Math.sqrt(deviations.reduce((r, d) => r + d * d, 0) / (range.length - 1));
|
|
return { mean, standardDeviation };
|
|
};
|
|
|
|
// Calculate mean and standard deviation of expected damage ranges in each tier. Also create a function to remap damage
|
|
const currentDamageRange = analyzeDamageRange(typeData[source.system.tier].damage);
|
|
const newDamageRange = analyzeDamageRange(typeData[tier].damage);
|
|
const convertDamage = (damage, newMean) => {
|
|
const hitPointParts = damage.parts.filter(d => d.applyTo === 'hitPoints');
|
|
if (hitPointParts.length === 1 && !hitPointParts[0].value.custom.enabled) {
|
|
const value = hitPointParts[0].value;
|
|
value.flatMultiplier = Math.max(0, value.flatMultiplier + tier - source.system.tier);
|
|
const baseAverage = (value.flatMultiplier * (Number(value.dice.replace('d', '')) + 1)) / 2;
|
|
value.bonus = Math.round(newMean - baseAverage);
|
|
}
|
|
};
|
|
|
|
const parseDamage = damage => {
|
|
const formula = damage.parts
|
|
.filter(p => p.applyTo === 'hitPoints')
|
|
.map(p =>
|
|
p.value.custom.enabled
|
|
? p.value.custom.formula
|
|
: [p.value.flatMultiplier ? `${p.value.flatMultiplier}${p.value.dice}` : 0, p.value.bonus ?? 0]
|
|
.filter(p => !!p)
|
|
.join('+')
|
|
)
|
|
.join('+');
|
|
const terms = parseTermsFromSimpleFormula(formula);
|
|
const mean = terms.reduce((r, t) => r + (t.modifier ?? 0) + (t.dice ? (t.dice * (t.faces + 1)) / 2 : 0), 0);
|
|
return { formula, terms, mean };
|
|
};
|
|
|
|
// Update damage of base attack
|
|
const atkAverage = parseDamage(source.system.attack.damage).mean;
|
|
const deviation = (atkAverage - currentDamageRange.mean) / currentDamageRange.standardDeviation;
|
|
const newAtkAverage = newDamageRange.mean + newDamageRange.standardDeviation * deviation;
|
|
const damage = source.system.attack.damage;
|
|
convertDamage(damage, newAtkAverage);
|
|
|
|
// Update damage of each item action, making sure to also update the description if possible
|
|
for (const item of source.items) {
|
|
// todo: damage inlines (must be done before other changes so that it doesn't get incorrectly applied)
|
|
|
|
for (const action of Object.values(item.system.actions)) {
|
|
const damage = action.damage;
|
|
if (!damage) continue;
|
|
const { formula, mean } = parseDamage(damage);
|
|
if (mean === 0) continue;
|
|
|
|
const deviation = (mean - currentDamageRange.mean) / currentDamageRange.standardDeviation;
|
|
const newMean = newDamageRange.mean + newDamageRange.standardDeviation * deviation;
|
|
convertDamage(damage, newMean);
|
|
|
|
const oldFormulaRegexp = new RegExp(formula.replace('+', '(?:\\s)?\\+(?:\\s)?'));
|
|
const newFormula = parseDamage(action.damage).formula;
|
|
item.system.description = item.system.description.replace(oldFormulaRegexp, newFormula);
|
|
action.description = action.description.replace(oldFormulaRegexp, newFormula);
|
|
}
|
|
}
|
|
|
|
// Finally set the tier of the source data, now that everything is complete
|
|
source.system.tier = tier;
|
|
return source;
|
|
}
|
|
}
|