mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-01-11 19:25:21 +01:00
947 lines
38 KiB
JavaScript
947 lines
38 KiB
JavaScript
import { emitAsGM, GMUpdateEvent } from '../systemRegistration/socket.mjs';
|
|
import { LevelOptionType } from '../data/levelTier.mjs';
|
|
import DHFeature from '../data/item/feature.mjs';
|
|
import { createScrollText, damageKeyToNumber, getDamageKey } from '../helpers/utils.mjs';
|
|
import DhCompanionLevelUp from '../applications/levelup/companionLevelup.mjs';
|
|
import { ResourceUpdateMap } from '../data/action/baseAction.mjs';
|
|
|
|
export default class DhpActor extends Actor {
|
|
parties = new Set();
|
|
|
|
#scrollTextQueue = [];
|
|
#scrollTextInterval;
|
|
|
|
/**
|
|
* Return the first Actor active owner.
|
|
*/
|
|
get owner() {
|
|
const user =
|
|
this.hasPlayerOwner && game.users.players.find(u => this.testUserPermission(u, 'OWNER') && u.active);
|
|
if (!user) return game.users.activeGM;
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* Whether this actor is an NPC.
|
|
* @returns {boolean}
|
|
*/
|
|
get isNPC() {
|
|
return this.system.metadata.isNPC;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
static migrateData(source) {
|
|
if (source.system?.attack && !source.system.attack.type) source.system.attack.type = 'attack';
|
|
return super.migrateData(source);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**@inheritdoc */
|
|
static getDefaultArtwork(actorData) {
|
|
const { type } = actorData;
|
|
const Model = CONFIG.Actor.dataModels[type];
|
|
const img = Model.DEFAULT_ICON ?? this.DEFAULT_ICON;
|
|
return {
|
|
img,
|
|
texture: {
|
|
src: img
|
|
}
|
|
};
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
getEmbeddedDocument(embeddedName, id, options) {
|
|
let doc;
|
|
switch (embeddedName) {
|
|
case 'Action':
|
|
doc = this.system.actions?.get(id);
|
|
if (!doc && this.system.attack?.id === id) doc = this.system.attack;
|
|
break;
|
|
default:
|
|
return super.getEmbeddedDocument(embeddedName, id, options);
|
|
}
|
|
if (options?.strict && !doc) {
|
|
throw new Error(`The key ${id} does not exist in the ${embeddedName} Collection`);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
/**@inheritdoc */
|
|
async _preCreate(data, options, user) {
|
|
if ((await super._preCreate(data, options, user)) === false) return false;
|
|
const update = {};
|
|
|
|
// Set default token size. Done here as we do not want to set a datamodel default, since that would apply the sizing to third party actor modules that aren't set up with the size system.
|
|
if (this.system.metadata.usesSize && !data.system?.size) {
|
|
Object.assign(update, {
|
|
system: {
|
|
size: CONFIG.DH.ACTOR.tokenSize.medium.id
|
|
}
|
|
});
|
|
}
|
|
|
|
// Configure prototype token settings
|
|
if (['character', 'companion', 'party'].includes(this.type))
|
|
Object.assign(update, {
|
|
prototypeToken: {
|
|
sight: { enabled: true },
|
|
actorLink: true,
|
|
disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
|
|
}
|
|
});
|
|
this.updateSource(update);
|
|
}
|
|
|
|
_onUpdate(changes, options, userId) {
|
|
super._onUpdate(changes, options, userId);
|
|
for (const party of this.parties) {
|
|
party.render();
|
|
}
|
|
}
|
|
|
|
_onDelete(options, userId) {
|
|
super._onDelete(options, userId);
|
|
for (const party of this.parties) {
|
|
party.render();
|
|
}
|
|
}
|
|
|
|
async updateLevel(newLevel) {
|
|
if (!['character', 'companion'].includes(this.type) || newLevel === this.system.levelData.level.changed) return;
|
|
|
|
if (newLevel > this.system.levelData.level.current) {
|
|
const maxLevel = Object.values(
|
|
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.LevelTiers).tiers
|
|
).reduce((acc, tier) => Math.max(acc, tier.levels.end), 0);
|
|
if (newLevel > maxLevel) {
|
|
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.tooHighLevel'));
|
|
}
|
|
|
|
await this.update({ 'system.levelData.level.changed': Math.min(newLevel, maxLevel) });
|
|
} else {
|
|
const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto;
|
|
|
|
const usedLevel = Math.max(newLevel, 1);
|
|
if (newLevel < 1) {
|
|
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.tooLowLevel'));
|
|
}
|
|
|
|
const updatedLevelups = Object.keys(this.system.levelData.levelups).reduce((acc, level) => {
|
|
if (Number(level) > usedLevel) acc[`-=${level}`] = null;
|
|
|
|
return acc;
|
|
}, {});
|
|
|
|
if (levelupAuto) {
|
|
const features = [];
|
|
const domainCards = [];
|
|
const experiences = [];
|
|
const subclassFeatureState = { class: null, multiclass: null };
|
|
let multiclass = null;
|
|
Object.keys(this.system.levelData.levelups)
|
|
.filter(x => x > usedLevel)
|
|
.forEach(levelKey => {
|
|
const level = this.system.levelData.levelups[levelKey];
|
|
const achievementCards = level.achievements.domainCards.map(x => x.itemUuid);
|
|
const advancementCards = level.selections
|
|
.filter(x => x.type === 'domainCard')
|
|
.map(x => x.itemUuid);
|
|
domainCards.push(...achievementCards, ...advancementCards);
|
|
experiences.push(...Object.keys(level.achievements.experiences));
|
|
features.push(...level.selections.flatMap(x => x.features));
|
|
|
|
const subclass = level.selections.find(x => x.type === 'subclass');
|
|
if (subclass) {
|
|
const path = subclass.secondaryData.isMulticlass === 'true' ? 'multiclass' : 'class';
|
|
const subclassState = Number(subclass.secondaryData.featureState) - 1;
|
|
subclassFeatureState[path] = subclassFeatureState[path]
|
|
? Math.min(subclassState, subclassFeatureState[path])
|
|
: subclassState;
|
|
}
|
|
|
|
multiclass = level.selections.find(x => x.type === 'multiclass');
|
|
});
|
|
|
|
for (let feature of features) {
|
|
if (feature.onPartner && !this.system.partner) continue;
|
|
|
|
const document = feature.onPartner ? this.system.partner : this;
|
|
document.items.get(feature.id)?.delete();
|
|
}
|
|
|
|
if (experiences.length > 0) {
|
|
const getUpdate = () => ({
|
|
'system.experiences': experiences.reduce((acc, key) => {
|
|
acc[`-=${key}`] = null;
|
|
return acc;
|
|
}, {})
|
|
});
|
|
this.update(getUpdate());
|
|
}
|
|
|
|
if (subclassFeatureState.class) {
|
|
this.system.class.subclass.update({ 'system.featureState': subclassFeatureState.class });
|
|
}
|
|
|
|
if (subclassFeatureState.multiclass) {
|
|
this.system.multiclass.subclass.update({ 'system.featureState': subclassFeatureState.multiclass });
|
|
}
|
|
|
|
if (multiclass) {
|
|
const multiclassItem = this.items.find(x => x.uuid === multiclass.itemUuid);
|
|
const multiclassFeatures = this.items.filter(
|
|
x => x.system.originItemType === 'class' && x.system.multiclassOrigin
|
|
);
|
|
const subclassFeatures = this.items.filter(
|
|
x => x.system.originItemType === 'subclass' && x.system.multiclassOrigin
|
|
);
|
|
|
|
this.deleteEmbeddedDocuments(
|
|
'Item',
|
|
[multiclassItem, ...multiclassFeatures, ...subclassFeatures].map(x => x.id)
|
|
);
|
|
|
|
this.update({
|
|
'system.multiclass': {
|
|
value: null,
|
|
subclass: null
|
|
}
|
|
});
|
|
}
|
|
|
|
for (let domainCard of domainCards) {
|
|
const itemCard = this.items.find(x => x.uuid === domainCard);
|
|
itemCard?.delete();
|
|
}
|
|
}
|
|
|
|
await this.update({
|
|
system: {
|
|
levelData: {
|
|
level: {
|
|
current: usedLevel,
|
|
changed: usedLevel
|
|
},
|
|
levelups: updatedLevelups
|
|
}
|
|
}
|
|
});
|
|
this.sheet.render();
|
|
}
|
|
}
|
|
|
|
async levelUp(levelupData) {
|
|
const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto;
|
|
|
|
const levelups = {};
|
|
for (var levelKey of Object.keys(levelupData)) {
|
|
const level = levelupData[levelKey];
|
|
|
|
if (levelupAuto) {
|
|
for (var experienceKey in level.achievements.experiences) {
|
|
const experience = level.achievements.experiences[experienceKey];
|
|
await this.update({
|
|
[`system.experiences.${experienceKey}`]: {
|
|
name: experience.name,
|
|
value: experience.modifier,
|
|
core: true
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
let multiclass = null;
|
|
const featureAdditions = [];
|
|
const domainCards = [];
|
|
const subclassFeatureState = { class: null, multiclass: null };
|
|
const selections = [];
|
|
for (var optionKey of Object.keys(level.choices)) {
|
|
const selection = level.choices[optionKey];
|
|
for (var checkboxNr of Object.keys(selection)) {
|
|
const checkbox = selection[checkboxNr];
|
|
|
|
const tierOption = LevelOptionType[checkbox.type];
|
|
if (tierOption.features?.length > 0) {
|
|
featureAdditions.push({
|
|
checkbox: {
|
|
...checkbox,
|
|
level: Number(levelKey),
|
|
optionKey: optionKey,
|
|
checkboxNr: Number(checkboxNr)
|
|
},
|
|
features: tierOption.features
|
|
});
|
|
} else if (checkbox.type === 'multiclass') {
|
|
multiclass = {
|
|
...checkbox,
|
|
level: Number(levelKey),
|
|
optionKey: optionKey,
|
|
checkboxNr: Number(checkboxNr)
|
|
};
|
|
} else if (checkbox.type === 'domainCard') {
|
|
domainCards.push({
|
|
...checkbox,
|
|
level: Number(levelKey),
|
|
optionKey: optionKey,
|
|
checkboxNr: Number(checkboxNr)
|
|
});
|
|
} else {
|
|
if (checkbox.type === 'subclass') {
|
|
const path = checkbox.secondaryData.isMulticlass === 'true' ? 'multiclass' : 'class';
|
|
subclassFeatureState[path] = Math.max(
|
|
Number(checkbox.secondaryData.featureState),
|
|
subclassFeatureState[path]
|
|
);
|
|
}
|
|
|
|
selections.push({
|
|
...checkbox,
|
|
level: Number(levelKey),
|
|
optionKey: optionKey,
|
|
checkboxNr: Number(checkboxNr)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (var addition of featureAdditions) {
|
|
if (levelupAuto) {
|
|
for (var featureData of addition.features) {
|
|
const feature = new DHFeature({
|
|
...featureData,
|
|
description: game.i18n.localize(featureData.description)
|
|
});
|
|
|
|
const document = featureData.toPartner && this.system.partner ? this.system.partner : this;
|
|
const embeddedItem = await document.createEmbeddedDocuments('Item', [
|
|
{
|
|
...featureData,
|
|
name: game.i18n.localize(featureData.name),
|
|
type: 'feature',
|
|
system: feature
|
|
}
|
|
]);
|
|
const newFeature = {
|
|
onPartner: Boolean(featureData.toPartner && this.system.partner),
|
|
id: embeddedItem[0].id
|
|
};
|
|
addition.checkbox.features = !addition.checkbox.features
|
|
? [newFeature]
|
|
: [...addition.checkbox.features, newFeature];
|
|
}
|
|
}
|
|
|
|
selections.push(addition.checkbox);
|
|
}
|
|
|
|
if (multiclass) {
|
|
if (levelupAuto) {
|
|
const subclassItem = await foundry.utils.fromUuid(multiclass.secondaryData.subclass);
|
|
const subclassData = subclassItem.toObject();
|
|
const multiclassItem = await foundry.utils.fromUuid(multiclass.data[0]);
|
|
const multiclassData = multiclassItem.toObject();
|
|
|
|
const embeddedItem = await this.createEmbeddedDocuments('Item', [
|
|
{
|
|
...multiclassData,
|
|
uuid: multiclassItem.uuid,
|
|
_stats: multiclassItem._stats,
|
|
system: {
|
|
...multiclassData.system,
|
|
features: multiclassData.system.features.filter(x => x.type !== 'hope'),
|
|
domains: [multiclass.secondaryData.domain],
|
|
isMulticlass: true
|
|
}
|
|
}
|
|
]);
|
|
|
|
await this.createEmbeddedDocuments('Item', [
|
|
{
|
|
...subclassData,
|
|
uuid: subclassItem.uuid,
|
|
_stats: subclassItem._stats,
|
|
system: {
|
|
...subclassData.system,
|
|
isMulticlass: true
|
|
}
|
|
}
|
|
]);
|
|
selections.push({ ...multiclass, itemUuid: embeddedItem[0].uuid });
|
|
} else {
|
|
selections.push({ ...multiclass });
|
|
}
|
|
}
|
|
|
|
for (var domainCard of domainCards) {
|
|
if (levelupAuto) {
|
|
const cardItem = await foundry.utils.fromUuid(domainCard.data[0]);
|
|
const cardData = cardItem.toObject();
|
|
const embeddedItem = await this.createEmbeddedDocuments('Item', [
|
|
{
|
|
...cardData,
|
|
uuid: cardItem.uuid,
|
|
_stats: cardItem._stats,
|
|
system: {
|
|
...cardData.system,
|
|
inVault: true
|
|
}
|
|
}
|
|
]);
|
|
selections.push({ ...domainCard, itemUuid: embeddedItem[0].uuid });
|
|
} else {
|
|
selections.push({ ...domainCard });
|
|
}
|
|
}
|
|
|
|
const achievementDomainCards = [];
|
|
if (levelupAuto) {
|
|
for (var card of Object.values(level.achievements.domainCards)) {
|
|
const cardItem = await foundry.utils.fromUuid(card.uuid);
|
|
const cardData = cardItem.toObject();
|
|
const embeddedItem = await this.createEmbeddedDocuments('Item', [
|
|
{
|
|
...cardData,
|
|
uuid: cardItem.uuid,
|
|
_stats: cardItem._stats,
|
|
system: {
|
|
...cardData.system,
|
|
inVault: true
|
|
}
|
|
}
|
|
]);
|
|
card.itemUuid = embeddedItem[0].uuid;
|
|
achievementDomainCards.push(card);
|
|
}
|
|
}
|
|
|
|
if (subclassFeatureState.class) {
|
|
await this.system.class.subclass.update({ 'system.featureState': subclassFeatureState.class });
|
|
}
|
|
|
|
if (subclassFeatureState.multiclass) {
|
|
await this.system.multiclass.subclass.update({
|
|
'system.featureState': subclassFeatureState.multiclass
|
|
});
|
|
}
|
|
|
|
levelups[levelKey] = {
|
|
achievements: {
|
|
...level.achievements,
|
|
domainCards: achievementDomainCards
|
|
},
|
|
selections: selections
|
|
};
|
|
}
|
|
|
|
const levelChange = this.system.levelData.level.changed - this.system.levelData.level.current;
|
|
await this.update({
|
|
system: {
|
|
levelData: {
|
|
level: {
|
|
current: this.system.levelData.level.changed
|
|
},
|
|
levelups: levelups
|
|
}
|
|
}
|
|
});
|
|
this.sheet.render();
|
|
|
|
if (this.system.companion && !this.system.companion.system.levelData.canLevelUp) {
|
|
const confirmed = await foundry.applications.api.DialogV2.confirm({
|
|
window: {
|
|
title: game.i18n.localize('DAGGERHEART.ACTORS.Character.companionLevelup.confirmTitle')
|
|
},
|
|
content: game.i18n.format('DAGGERHEART.ACTORS.Character.companionLevelup.confirmText', {
|
|
name: this.system.companion.name,
|
|
levelChange: levelChange
|
|
})
|
|
});
|
|
|
|
if (!confirmed) return;
|
|
|
|
await this.system.companion.updateLevel(this.system.companion.system.levelData.level.current + levelChange);
|
|
new DhCompanionLevelUp(this.system.companion).render({ force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {object} config
|
|
* @param {Event} config.event
|
|
* @param {string} config.title
|
|
* @param {object} config.roll
|
|
* @param {number} config.roll.modifier
|
|
* @param {boolean} [config.roll.simple=false]
|
|
* @param {string} [config.roll.type]
|
|
* @param {number} [config.roll.difficulty]
|
|
* @param {boolean} [config.hasDamage]
|
|
* @param {boolean} [config.hasEffect]
|
|
* @param {object} [config.chatMessage]
|
|
* @param {string} config.chatMessage.template
|
|
* @param {boolean} [config.chatMessage.mute]
|
|
* @param {object} [config.targets]
|
|
* @param {object} [config.costs]
|
|
*/
|
|
async diceRoll(config) {
|
|
config.source = { ...(config.source ?? {}), actor: this.uuid };
|
|
config.data = this.getRollData();
|
|
config.resourceUpdates = new ResourceUpdateMap(this);
|
|
const rollClass = config.roll.lite ? CONFIG.Dice.daggerheart['DHRoll'] : this.rollClass;
|
|
return await rollClass.build(config);
|
|
}
|
|
|
|
get rollClass() {
|
|
return CONFIG.Dice.daggerheart[['character', 'companion'].includes(this.type) ? 'DualityRoll' : 'D20Roll'];
|
|
}
|
|
|
|
get baseSaveDifficulty() {
|
|
return this.system.difficulty ?? 10;
|
|
}
|
|
|
|
/** @inheritDoc */
|
|
async toggleStatusEffect(statusId, { active, overlay = false } = {}) {
|
|
const status = CONFIG.statusEffects.find(e => e.id === statusId);
|
|
if (!status) throw new Error(`Invalid status ID "${statusId}" provided to Actor#toggleStatusEffect`);
|
|
const existing = [];
|
|
|
|
// Find the effect with the static _id of the status effect
|
|
if (status._id) {
|
|
const effect = this.effects.get(status._id);
|
|
if (effect) existing.push(effect.id);
|
|
}
|
|
|
|
// If no static _id, find all effects that have this status
|
|
else {
|
|
for (const effect of this.effects) {
|
|
if (effect.statuses.has(status.id)) existing.push(effect.id);
|
|
}
|
|
}
|
|
|
|
// Remove the existing effects unless the status effect is forced active
|
|
if (existing.length) {
|
|
if (active) return true;
|
|
await this.deleteEmbeddedDocuments('ActiveEffect', existing);
|
|
return false;
|
|
}
|
|
|
|
// Create a new effect unless the status effect is forced inactive
|
|
if (!active && active !== undefined) return;
|
|
|
|
const ActiveEffect = getDocumentClass('ActiveEffect');
|
|
const effect = await ActiveEffect.fromStatusEffect(statusId);
|
|
if (overlay) effect.updateSource({ 'flags.core.overlay': true });
|
|
return ActiveEffect.implementation.create(effect, { parent: this, keepId: true });
|
|
}
|
|
|
|
/**@inheritdoc */
|
|
getRollData() {
|
|
const rollData = foundry.utils.deepClone(super.getRollData());
|
|
/* system gets repeated infinately which causes issues when trying to use the data for document creation */
|
|
delete rollData.system;
|
|
|
|
rollData.id = this.id;
|
|
rollData.name = this.name;
|
|
rollData.system = this.system.getRollData();
|
|
rollData.prof = this.system.proficiency ?? 1;
|
|
rollData.cast = this.system.spellcastModifier ?? 1;
|
|
return rollData;
|
|
}
|
|
|
|
#canReduceDamage(hpDamage, type) {
|
|
const { stressDamageReduction, disabledArmor } = this.system.rules.damageReduction;
|
|
if (disabledArmor) return false;
|
|
|
|
const availableStress = this.system.resources.stress.max - this.system.resources.stress.value;
|
|
|
|
const canUseArmor =
|
|
this.system.armor &&
|
|
this.system.armor.system.marks.value < this.system.armorScore &&
|
|
type.every(t => this.system.armorApplicableDamageTypes[t] === true);
|
|
const canUseStress = Object.keys(stressDamageReduction).reduce((acc, x) => {
|
|
const rule = stressDamageReduction[x];
|
|
if (damageKeyToNumber(x) <= hpDamage) return acc || (rule.enabled && availableStress >= rule.cost);
|
|
return acc;
|
|
}, false);
|
|
|
|
return canUseArmor || canUseStress;
|
|
}
|
|
|
|
async takeDamage(damages, isDirect = false) {
|
|
if (Hooks.call(`${CONFIG.DH.id}.preTakeDamage`, this, damages) === false) return null;
|
|
|
|
if (this.type === 'companion') {
|
|
await this.modifyResource([{ value: 1, key: 'stress' }]);
|
|
return;
|
|
}
|
|
|
|
const updates = [];
|
|
|
|
Object.entries(damages).forEach(([key, damage]) => {
|
|
damage.parts.forEach(part => {
|
|
if (part.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id)
|
|
part.total = this.calculateDamage(part.total, part.damageTypes);
|
|
const update = updates.find(u => u.key === key);
|
|
if (update) {
|
|
update.value += part.total;
|
|
update.damageTypes.add(...new Set(part.damageTypes));
|
|
} else updates.push({ value: part.total, key, damageTypes: new Set(part.damageTypes) });
|
|
});
|
|
});
|
|
|
|
if (Hooks.call(`${CONFIG.DH.id}.postCalculateDamage`, this, damages) === false) return null;
|
|
|
|
if (!updates.length) return;
|
|
|
|
const hpDamage = updates.find(u => u.key === CONFIG.DH.GENERAL.healingTypes.hitPoints.id);
|
|
if (hpDamage) {
|
|
hpDamage.value = this.convertDamageToThreshold(hpDamage.value);
|
|
if (
|
|
this.type === 'character' &&
|
|
!isDirect &&
|
|
this.system.armor &&
|
|
this.#canReduceDamage(hpDamage.value, hpDamage.damageTypes)
|
|
) {
|
|
const armorSlotResult = await this.owner.query(
|
|
'armorSlot',
|
|
{
|
|
actorId: this.uuid,
|
|
damage: hpDamage.value,
|
|
type: [...hpDamage.damageTypes]
|
|
},
|
|
{
|
|
timeout: 30000
|
|
}
|
|
);
|
|
if (armorSlotResult) {
|
|
const { modifiedDamage, armorSpent, stressSpent } = armorSlotResult;
|
|
updates.find(u => u.key === 'hitPoints').value = modifiedDamage;
|
|
if (armorSpent) {
|
|
const armorUpdate = updates.find(u => u.key === 'armor');
|
|
if (armorUpdate) armorUpdate.value += armorSpent;
|
|
else updates.push({ value: armorSpent, key: 'armor' });
|
|
}
|
|
if (stressSpent) {
|
|
const stressUpdate = updates.find(u => u.key === 'stress');
|
|
if (stressUpdate) stressUpdate.value += stressSpent;
|
|
else updates.push({ value: stressSpent, key: 'stress' });
|
|
}
|
|
}
|
|
}
|
|
if (this.type === 'adversary') {
|
|
const reducedSeverity = hpDamage.damageTypes.reduce((value, curr) => {
|
|
return Math.max(this.system.rules.damageReduction.reduceSeverity[curr], value);
|
|
}, 0);
|
|
hpDamage.value = Math.max(hpDamage.value - reducedSeverity, 0);
|
|
|
|
if (
|
|
hpDamage.value &&
|
|
this.system.rules.damageReduction.thresholdImmunities[getDamageKey(hpDamage.value)]
|
|
) {
|
|
hpDamage.value -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
updates.forEach(
|
|
u =>
|
|
(u.value =
|
|
u.key === 'fear' || this.system?.resources?.[u.key]?.isReversed === false ? u.value * -1 : u.value)
|
|
);
|
|
|
|
await this.modifyResource(updates);
|
|
|
|
if (Hooks.call(`${CONFIG.DH.id}.postTakeDamage`, this, updates) === false) return null;
|
|
|
|
return updates;
|
|
}
|
|
|
|
calculateDamage(baseDamage, type) {
|
|
if (this.canResist(type, 'immunity')) return 0;
|
|
if (this.canResist(type, 'resistance')) baseDamage = Math.ceil(baseDamage / 2);
|
|
|
|
const flatReduction = this.getDamageTypeReduction(type);
|
|
const damage = Math.max(baseDamage - (flatReduction ?? 0), 0);
|
|
|
|
return damage;
|
|
}
|
|
|
|
canResist(type, resistance) {
|
|
if (!type?.length) return false;
|
|
return type.every(t => this.system.resistance[t]?.[resistance] === true);
|
|
}
|
|
|
|
getDamageTypeReduction(type) {
|
|
if (!type?.length) return 0;
|
|
const reduction = Object.entries(this.system.resistance).reduce(
|
|
(a, [index, value]) => (type.includes(index) ? Math.min(value.reduction, a) : a),
|
|
Infinity
|
|
);
|
|
return reduction === Infinity ? 0 : reduction;
|
|
}
|
|
|
|
async takeHealing(healings) {
|
|
if (Hooks.call(`${CONFIG.DH.id}.preTakeHealing`, this, healings) === false) return null;
|
|
|
|
const updates = [];
|
|
Object.entries(healings).forEach(([key, healing]) => {
|
|
healing.parts.forEach(part => {
|
|
const update = updates.find(u => u.key === key);
|
|
if (update) update.value += part.total;
|
|
else updates.push({ value: part.total, key });
|
|
});
|
|
});
|
|
|
|
updates.forEach(
|
|
u =>
|
|
(u.value = !(u.key === 'fear' || this.system?.resources?.[u.key]?.isReversed === false)
|
|
? u.value * -1
|
|
: u.value)
|
|
);
|
|
|
|
await this.modifyResource(updates);
|
|
|
|
if (Hooks.call(`${CONFIG.DH.id}.postTakeHealing`, this, updates) === false) return null;
|
|
|
|
return updates;
|
|
}
|
|
|
|
/**
|
|
* Resources are modified asynchronously, so be careful not to update the same resource in
|
|
* quick succession.
|
|
*/
|
|
async modifyResource(resources) {
|
|
if (!resources?.length) return;
|
|
|
|
if (resources.find(r => r.key === 'stress')) this.convertStressDamageToHP(resources);
|
|
let updates = {
|
|
actor: { target: this, resources: {} },
|
|
armor: { target: this.system.armor, resources: {} },
|
|
items: {}
|
|
};
|
|
|
|
resources.forEach(r => {
|
|
if (r.itemId) {
|
|
const { path, value } = game.system.api.fields.ActionFields.CostField.getItemIdCostUpdate(r);
|
|
|
|
if (
|
|
r.key === 'quantity' &&
|
|
r.target.type === 'consumable' &&
|
|
value === 0 &&
|
|
r.target.system.destroyOnEmpty
|
|
) {
|
|
r.target.delete();
|
|
} else {
|
|
updates.items[r.key] = {
|
|
target: r.target,
|
|
resources: { [path]: value }
|
|
};
|
|
}
|
|
} else {
|
|
switch (r.key) {
|
|
case 'fear':
|
|
ui.resources.updateFear(
|
|
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear) + r.value
|
|
);
|
|
break;
|
|
case 'armor':
|
|
if (this.system.armor?.system?.marks) {
|
|
updates.armor.resources['system.marks.value'] = Math.max(
|
|
Math.min(this.system.armor.system.marks.value + r.value, this.system.armorScore),
|
|
0
|
|
);
|
|
}
|
|
break;
|
|
default:
|
|
if (this.system.resources?.[r.key]) {
|
|
updates.actor.resources[`system.resources.${r.key}.value`] = Math.max(
|
|
Math.min(
|
|
this.system.resources[r.key].value + r.value,
|
|
this.system.resources[r.key].max
|
|
),
|
|
0
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
Object.keys(updates).forEach(async key => {
|
|
const u = updates[key];
|
|
if (key === 'items') {
|
|
Object.values(u).forEach(async item => {
|
|
await emitAsGM(
|
|
GMUpdateEvent.UpdateDocument,
|
|
item.target.update.bind(item.target),
|
|
item.resources,
|
|
item.target.uuid
|
|
);
|
|
});
|
|
} else {
|
|
if (Object.keys(u.resources).length > 0) {
|
|
await emitAsGM(
|
|
GMUpdateEvent.UpdateDocument,
|
|
u.target.update.bind(u.target),
|
|
u.resources,
|
|
u.target.uuid
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
convertDamageToThreshold(damage) {
|
|
const massiveDamageEnabled = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules)
|
|
.massiveDamage.enabled;
|
|
if (massiveDamageEnabled && damage >= this.system.damageThresholds.severe * 2) {
|
|
return 4;
|
|
}
|
|
return damage >= this.system.damageThresholds.severe ? 3 : damage >= this.system.damageThresholds.major ? 2 : 1;
|
|
}
|
|
|
|
convertStressDamageToHP(resources) {
|
|
const stressDamage = resources.find(r => r.key === 'stress'),
|
|
newValue = this.system.resources.stress.value + stressDamage.value;
|
|
if (newValue <= this.system.resources.stress.max) return;
|
|
const hpDamage = resources.find(r => r.key === 'hitPoints');
|
|
if (hpDamage) hpDamage.value++;
|
|
else
|
|
resources.push({
|
|
key: 'hitPoints',
|
|
value: 1
|
|
});
|
|
}
|
|
|
|
async toggleDefeated(defeatedState) {
|
|
const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).defeated;
|
|
const { unconscious, defeated, dead } = CONFIG.DH.GENERAL.conditions();
|
|
const defeatedConditions = new Set([unconscious.id, defeated.id, dead.id]);
|
|
if (!defeatedState) {
|
|
for (let defeatedId of defeatedConditions) {
|
|
await this.toggleStatusEffect(defeatedId, { overlay: settings.overlay, active: defeatedState });
|
|
}
|
|
} else {
|
|
const noDefeatedConditions = this.statuses.intersection(defeatedConditions).size === 0;
|
|
if (noDefeatedConditions) {
|
|
const condition = settings[`${this.type}Default`];
|
|
await this.toggleStatusEffect(condition, { overlay: settings.overlay, active: defeatedState });
|
|
}
|
|
}
|
|
}
|
|
|
|
queueScrollText(scrollingTextData) {
|
|
this.#scrollTextQueue.push(...scrollingTextData.map(data => () => createScrollText(this, data)));
|
|
if (!this.#scrollTextInterval) {
|
|
const scrollFunc = this.#scrollTextQueue.pop();
|
|
scrollFunc?.();
|
|
|
|
const intervalFunc = () => {
|
|
const scrollFunc = this.#scrollTextQueue.pop();
|
|
scrollFunc?.();
|
|
if (this.#scrollTextQueue.length === 0) {
|
|
clearInterval(this.#scrollTextInterval);
|
|
this.#scrollTextInterval = null;
|
|
}
|
|
};
|
|
|
|
this.#scrollTextInterval = setInterval(intervalFunc.bind(this), 600);
|
|
}
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
async importFromJSON(json) {
|
|
if (!this.type === 'character') return await super.importFromJSON(json);
|
|
|
|
if (!CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName)) {
|
|
throw new Error('Only world Documents may be imported');
|
|
}
|
|
|
|
const parsedJSON = JSON.parse(json);
|
|
if (foundry.utils.isNewerVersion('1.1.0', parsedJSON._stats.systemVersion)) {
|
|
const confirmed = await foundry.applications.api.DialogV2.confirm({
|
|
window: {
|
|
title: game.i18n.localize('DAGGERHEART.ACTORS.Character.InvalidOldCharacterImportTitle')
|
|
},
|
|
content: game.i18n.localize('DAGGERHEART.ACTORS.Character.InvalidOldCharacterImportText')
|
|
});
|
|
if (!confirmed) return;
|
|
}
|
|
|
|
return await super.importFromJSON(json);
|
|
}
|
|
|
|
/**
|
|
* Generate an array of localized tag.
|
|
* @returns {string[]} An array of localized tag strings.
|
|
*/
|
|
_getTags() {
|
|
const tags = [];
|
|
if (this.system._getTags) tags.push(...this.system._getTags());
|
|
return tags;
|
|
}
|
|
|
|
/** Get active effects */
|
|
getActiveEffects() {
|
|
const statusMap = new Map(foundry.CONFIG.statusEffects.map(status => [status.id, status]));
|
|
return this.effects
|
|
.filter(x => !x.disabled)
|
|
.reduce((acc, effect) => {
|
|
acc.push(effect);
|
|
|
|
const currentStatusActiveEffects = acc.filter(
|
|
x => x.statuses.size === 1 && x.name === game.i18n.localize(statusMap.get(x.statuses.first())?.name)
|
|
);
|
|
|
|
for (var status of effect.statuses) {
|
|
if (!currentStatusActiveEffects.find(x => x.statuses.has(status))) {
|
|
const statusData = statusMap.get(status);
|
|
if (statusData) {
|
|
acc.push({
|
|
condition: status,
|
|
appliedBy: game.i18n.localize(effect.name),
|
|
name: game.i18n.localize(statusData.name),
|
|
statuses: new Set([status]),
|
|
img: statusData.icon ?? statusData.img,
|
|
description: game.i18n.localize(statusData.description),
|
|
tint: effect.tint
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return acc;
|
|
}, []);
|
|
}
|
|
|
|
/* Temporarily copying the foundry method to add a fix to a bug with scenes
|
|
https://discord.com/channels/170995199584108546/1296292044011995136/1446693077443149856
|
|
*/
|
|
getDependentTokens({ scenes, linked = false } = {}) {
|
|
if (this.isToken && !scenes) return [this.token];
|
|
if (scenes) scenes = Array.isArray(scenes) ? scenes : [scenes];
|
|
else scenes = Array.from(this._dependentTokens.keys());
|
|
|
|
/* Code to filter out nonexistant scenes */
|
|
scenes = scenes.filter(scene => game.scenes.some(x => x.id === scene.id));
|
|
|
|
if (this.isToken) {
|
|
const parent = this.token.parent;
|
|
return scenes.includes(parent) ? [this.token] : [];
|
|
}
|
|
|
|
const allTokens = [];
|
|
for (const scene of scenes) {
|
|
if (!scene) continue;
|
|
const tokens = this._dependentTokens.get(scene);
|
|
for (const token of tokens ?? []) {
|
|
if (!linked || token.actorLink) allTokens.push(token);
|
|
}
|
|
}
|
|
|
|
return allTokens;
|
|
}
|
|
}
|