Merge branch 'main' into feature/reload-check

This commit is contained in:
WBHarry 2026-07-03 16:17:04 +02:00
commit 8158c1f9ff
66 changed files with 1623 additions and 1723 deletions

View file

@ -7,7 +7,7 @@ export default class AdversarySheet extends DHBaseActorSheet {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ['adversary'],
position: { width: 660, height: 766 },
position: { width: 645, height: 750 },
window: { resizable: true },
actions: {
toggleHitPoints: AdversarySheet.#toggleHitPoints,
@ -58,12 +58,13 @@ export default class AdversarySheet extends DHBaseActorSheet {
template: 'systems/daggerheart/templates/sheets/actors/adversary/features.hbs',
scrollable: ['.feature-section']
},
notes: {
template: 'systems/daggerheart/templates/sheets/actors/adversary/notes.hbs'
},
effects: {
template: 'systems/daggerheart/templates/sheets/actors/adversary/effects.hbs',
scrollable: ['.effects-sections']
},
notes: {
template: 'systems/daggerheart/templates/sheets/actors/adversary/notes.hbs',
scrollable: ['.editor-content']
}
};

View file

@ -1057,7 +1057,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
direction: 'DOWN'
});
html.querySelectorAll('.armor-slot').forEach(element => {
html.querySelectorAll('.armor .slot').forEach(element => {
element.addEventListener('click', CharacterSheet.armorSourcePipUpdate);
});
}
@ -1072,7 +1072,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
/** Update specific armor source */
static async armorSourcePipUpdate(event) {
const target = event.target.closest('.armor-slot');
const target = event.target.closest('.slot');
const { uuid, value } = target.dataset;
const document = await foundry.utils.fromUuid(uuid);
@ -1100,7 +1100,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
}
const container = target.closest('.slot-bar');
for (const armorSlot of container.querySelectorAll('.armor-slot i')) {
for (const armorSlot of container.querySelectorAll('.armor .slot i')) {
const index = Number.parseInt(armorSlot.dataset.index);
if (decreasing && index >= newCurrent) {
armorSlot.classList.remove('fa-shield');

View file

@ -305,7 +305,7 @@ export default function DHApplicationMixin(Base) {
_preSyncPartState(partId, newElement, priorElement, state) {
super._preSyncPartState(partId, newElement, priorElement, state);
for (const el of priorElement.querySelectorAll('.extensible.extended')) {
const { actionId, itemUuid } = el.parentElement.dataset;
const { actionId, itemUuid } = el.closest('[data-item-uuid], [data-action-id]').dataset;
const selector = `${actionId ? `[data-action-id="${actionId}"]` : `[data-item-uuid="${itemUuid}"]`} .extensible`;
const newExtensible = newElement.querySelector(selector);
newExtensible?.classList.add('extended');

View file

@ -39,6 +39,12 @@ export default class DhEffectsDisplay extends HandlebarsApplicationMixin(Applica
}
};
/**
* Debounce and slightly delayed request to re-render this panel. Necessary for situations where it is not possible
* to properly wait for promises to resolve before refreshing the UI.
*/
refresh = foundry.utils.debounce(this.render.bind(this), 50);
get element() {
return document.body.querySelector('.daggerheart.dh-style.effects-display');
}

View file

@ -96,7 +96,7 @@ export default class DhRegionLayer extends foundry.canvas.layers.RegionLayer {
return inBounds.length === 1 ? inBounds[0] : null;
}
static getTemplateShape({ type, angle, range, direction } = {}) {
static getTemplateShape({ shapeType, angle, range, direction, hasHole } = {}) {
const { line, rectangle, inFront, cone, circle, emanation } = CONFIG.DH.GENERAL.templateTypes;
/* Length calculation */
@ -112,11 +112,11 @@ export default class DhRegionLayer extends foundry.canvas.layers.RegionLayer {
const shapeData = {
...canvas.mousePosition,
type: type,
type: shapeType,
direction: direction ?? 0
};
switch (type) {
switch (shapeType) {
case rectangle.id:
shapeData.width = length;
shapeData.height = length;
@ -145,7 +145,8 @@ export default class DhRegionLayer extends foundry.canvas.layers.RegionLayer {
y: 0,
width: 1,
height: 1,
shape: game.canvas.grid.isHexagonal ? CONST.TOKEN_SHAPES.ELLIPSE_1 : CONST.TOKEN_SHAPES.RECTANGLE_1
shape: game.canvas.grid.isHexagonal ? CONST.TOKEN_SHAPES.ELLIPSE_1 : CONST.TOKEN_SHAPES.RECTANGLE_1,
hole: hasHole
};
break;
}

View file

@ -119,6 +119,10 @@ export const advantageState = {
export const areaTypes = {
placed: {
id: 'placed',
label: 'Placed Area'
label: 'DAGGERHEART.CONFIG.AreaTypes.placed.label'
},
attached: {
id: 'attached',
label: 'DAGGERHEART.CONFIG.AreaTypes.attached.label'
}
};

View file

@ -114,7 +114,10 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
return this._id;
}
/** Returns true if the current user is the owner of the containing item */
/**
* Returns true if the current user is the owner of the containing item.
* @returns {boolean}
*/
get isOwner() {
return this.item?.isOwner ?? true;
}
@ -143,6 +146,7 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
/**
* Return the first Actor parent found.
* @returns {DhpActor | null}
*/
get actor() {
return this.item instanceof DhpActor
@ -155,6 +159,7 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
/**
* 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.
* @returns {boolean}
*/
get usable() {
const actor = this.actor;

View file

@ -2,8 +2,14 @@ import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpe
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
import { parseInlineParams } from '../../enrichers/parser.mjs';
/**
* Accepts source data for an adversary and a target tier, and returns new source data
* @type {object} source
* @type {number} tier
* @returns {object} adjusted source data
*/
export function getTierAdjustedAdversary(source, tier) {
const currentTier = source.tier ?? 1;
const currentTier = source.system.tier ?? 1;
/** @type {(2 | 3 | 4)[]} */
const tiers = new Array(Math.abs(tier - currentTier))
@ -35,7 +41,7 @@ export function getTierAdjustedAdversary(source, 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)
value: getFormula(source.system.attack.damage?.parts.hitPoints?.value)
};
// Update damage of base attack.
@ -45,9 +51,9 @@ export function getTierAdjustedAdversary(source, tier) {
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);
const previousFormula = getFormula(data);
const value = calculateAdjustedDamage(previousFormula, 'attack', damageMeta);
applyAdjustedDamage(data, value);
}
} catch (err) {
ui.notifications.warn('Failed to convert attack damage of adversary');
@ -65,7 +71,7 @@ export function getTierAdjustedAdversary(source, tier) {
if (!formula) return match;
try {
const newFormula = calculateAdjustedDamage(formula, 'action', damageMeta)?.formula;
const newFormula = getFormula(calculateAdjustedDamage(formula, 'action', damageMeta));
descriptionFormulas.push(formula);
return match.replace(formula, newFormula);
} catch {
@ -82,15 +88,15 @@ export function getTierAdjustedAdversary(source, tier) {
const result = [];
for (const property of ['value', 'valueAlt']) {
const { [property]: data, type: damageType } = action.damage.parts.hitPoints;
const previousFormula = getDamagePartsFormula(data);
const previousFormula = getFormula(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 });
const value = calculateAdjustedDamage(previousFormula, type, damageMeta);
applyAdjustedDamage(data, value);
result.push({ previousFormula, formula: getFormula(value) });
}
// Override text in the description with those values
@ -189,24 +195,30 @@ function calculateAdjustedDamage(formula, type, { currentDamageRange, newDamageR
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 };
return value;
}
function getDamagePartsFormula(data) {
return data.custom.enabled
? data.custom.formula
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0].filter(p => !!p).join('+');
/**
* Get formula from either damage parts *or* a simple formula object.
* @returns {string} the new formula data
*/
function getFormula(data) {
if (data.custom?.enabled) {
return data.custom.formula;
}
const diceQuantity = data.flatMultiplier ?? data.diceQuantity;
const dice = data.faces ? `d${data.faces}` : data.dice;
const mod = data.bonus;
return [diceQuantity ? `${diceQuantity}${dice}` : 0, mod].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
* @param {object} diceData
* @param {object} value
*/
function applyAdjustedDamage(diceData, value, formula) {
function applyAdjustedDamage(diceData, value) {
if (value.diceQuantity) {
diceData.custom.enabled = false;
diceData.bonus = value.bonus;
@ -214,6 +226,6 @@ function applyAdjustedDamage(diceData, value, formula) {
diceData.flatMultiplier = value.diceQuantity;
} else if (!value.diceQuantity) {
diceData.custom.enabled = true;
diceData.custom.formula = formula;
diceData.custom.formula = getFormula(value);
}
}

View file

@ -33,6 +33,10 @@ export default class AreasField extends fields.ArrayField {
initial: CONFIG.DH.GENERAL.range.veryClose.id,
label: 'DAGGERHEART.ACTIONS.Config.area.size'
}),
hasHole: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.ACTIONS.Config.area.hasHole'
}),
effects: new fields.ArrayField(new fields.DocumentIdField())
});
super(element, options, context);

View file

@ -1,5 +1,11 @@
import { getWorldActor } from '../../../helpers/utils.mjs';
const fields = foundry.data.fields;
/**
* @import DHSummonAction from '../../action/summonAction.mjs'
*/
export default class DHSummonField extends fields.SchemaField {
/**
* Action Workflow order
@ -20,6 +26,11 @@ export default class DHSummonField extends fields.SchemaField {
super(transformFields, options, context);
}
/**
* Runs the execute. This is run on behalf of DHSummonAction.
* @todo move this function to be on the summon action.
* @this DHSummonAction
*/
static async execute() {
if (!this.transform.actorUUID) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.noTransformActor'));
@ -37,26 +48,37 @@ export default class DHSummonField extends fields.SchemaField {
return false;
}
if (this.actor.prototypeToken.actorLink) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.actorLinkError'));
const activeTokens = this.actor.getActiveTokens(false, true);
const controlledMatchingTokens = canvas.tokens.controlled
.filter(x => x.actor && x.actor.uuid === this.actor.uuid)
.map(x => x.document);
/** @type {typeof game.system.api.documents.DhToken | null} */
const token = this.actor.token ?? (
activeTokens.length === 1 ? activeTokens[0] :
(controlledMatchingTokens.length === 1 ? controlledMatchingTokens[0] : null)
);
if (!this.actor.token && !token) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.linkedSelectedError'));
return false;
}
if (!this.actor.token) {
if (!token) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.ACTIONS.TYPES.transform.prototypeError'));
return false;
}
const actor = await DHSummonField.getWorldActor(baseActor);
const actor = await getWorldActor(baseActor);
const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes;
const tokenSize = actor?.system.metadata.usesSize ? tokenSizes[actor.system.size] : actor.prototypeToken.width;
await this.actor.token.update(
{ ...actor.prototypeToken.toJSON(), actorId: actor.id, width: tokenSize, height: tokenSize },
{ diff: false, recursive: false, noHook: true }
// Update token. Avoid using recursive: false, since that prevents animations
await token.update(
{ ...actor.prototypeToken.toObject(), actorId: actor.id, width: tokenSize, height: tokenSize },
{ diff: false, noHook: true }
);
if (this.actor.token.combatant) {
if (token.combatant) {
this.actor.token.combatant.update({ actorId: actor.id, img: actor.prototypeToken.texture.src });
}
@ -64,17 +86,17 @@ export default class DHSummonField extends fields.SchemaField {
if (!this.transform.resourceRefresh.hitPoints) {
marks.hitPoints = Math.min(
this.actor.system.resources.hitPoints.value,
this.actor.token.actor.system.resources.hitPoints.max - 1
token.actor.system.resources.hitPoints.max - 1
);
}
if (!this.transform.resourceRefresh.stress) {
marks.stress = Math.min(
this.actor.system.resources.stress.value,
this.actor.token.actor.system.resources.stress.max - 1
token.actor.system.resources.stress.max - 1
);
}
if (marks.hitPoints || marks.stress) {
this.actor.token.actor.update({
token.actor.update({
'system.resources': {
hitPoints: { value: marks.hitPoints },
stress: { value: marks.stress }
@ -84,20 +106,9 @@ export default class DHSummonField extends fields.SchemaField {
const prevPosition = { ...this.actor.sheet.position };
this.actor.sheet.close();
this.actor.token.actor.sheet.render({ force: true, position: prevPosition });
}
/* Check for any available instances of the actor present in the world, or create a world actor based on compendium */
static async getWorldActor(baseActor) {
if (!baseActor.inCompendium) return baseActor;
const dataType = game.system.api.data.actors[`Dh${baseActor.type.capitalize()}`];
if (dataType && baseActor.img === dataType.DEFAULT_ICON) {
const worldActorCopy = game.actors.find(x => x.name === baseActor.name);
if (worldActorCopy) return worldActorCopy;
token.actor.sheet.render({ force: true, position: prevPosition });
if (token.object.controlled) {
ui.effectsDisplay.refresh();
}
const worldActor = await game.system.api.documents.DhpActor.create(baseActor.toObject());
return worldActor;
}
}

View file

@ -9,7 +9,8 @@ export default class DHWeapon extends AttachableItem {
type: 'weapon',
hasDescription: true,
isInventoryItem: true,
hasActions: true
hasActions: true,
hasResource: true
});
}

View file

@ -3,17 +3,26 @@ import DHItem from './item.mjs';
import BaseDataItem from '../data/item/base.mjs';
import DhActiveEffect from './activeEffect.mjs';
import EmbeddedCollection from '@common/abstract/embedded-collection.mjs';
import DHToken from './token.mjs';
import Actor from '@client/documents/actor.mjs';
import Item from '@client/documents/item.mjs';
declare module './actor.mjs' {
export default interface DhpActor<T extends BaseDataActor = BaseDataActor> {
export default interface DhpActor<T extends BaseDataActor = BaseDataActor> extends Actor {
system: T;
items: EmbeddedCollection<DHItem>;
effects: EmbeddedCollection<DhActiveEffect>;
get token(): DHToken | null;
/** @inheritdoc */
getActiveTokens(linked?: boolean, document?: boolean): (DHToken | foundry.canvas.placeables.Token)[];
getActiveTokens(linked?: boolean, document: true): DHToken[];
getActiveTokens(linked?: boolean, document: false): foundry.canvas.placeables.Token[];
}
}
declare module './item.mjs' {
export default interface DHItem<T extends BaseDataItem = BaseDataItem> {
export default interface DHItem<T extends BaseDataItem = BaseDataItem> extends Item {
parent: DhpActor;
actor: DhpActor;
system: T;

View file

@ -34,12 +34,14 @@ export default class DhpActor extends Actor {
super.prepareData();
// Update effects if it is the user's character or is controlled
if (canvas.ready) {
// A timeout avoids an infinite loop when accessing token actors before the delta is finished constructing
window.setTimeout(() => {
if (!canvas.ready) return;
const controlled = canvas.tokens.controlled.some(t => t.actor === this);
if (game.user.character === this || controlled) {
ui.effectsDisplay.render();
ui.effectsDisplay.refresh();
}
}
}, 0);
}
/* -------------------------------------------- */

View file

@ -276,8 +276,12 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
async onCreateAreas(event) {
const createArea = async selectedArea => {
const effects = selectedArea.effects.map(effect => this.system.action.item.effects.get(effect).uuid);
const { shape: type, size: range } = selectedArea;
const shapeData = CONFIG.Canvas.layers.regions.layerClass.getTemplateShape({ type, range });
const { shape: shapeType, size: range, hasHole } = selectedArea;
const shapeData = CONFIG.Canvas.layers.regions.layerClass.getTemplateShape({
shapeType,
range,
hasHole
});
const scene = game.scenes.get(game.user.viewedScene);
const level = scene.levels.find(x => x.isView);
@ -305,7 +309,10 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
visibility: CONST.REGION_VISIBILITY.ALWAYS
};
const placeRegion = data => {
canvas.regions.placeRegion(data, { create: true });
canvas.regions.placeRegion(data, {
create: true,
attachToToken: selectedArea.type === CONFIG.DH.ACTIONS.areaTypes.attached.id
});
};
// Regions with effects must be placed by the GM

View file

@ -38,7 +38,8 @@ export default class DHToken extends CONFIG.Token.documentClass {
tokens.filter(x => x.actor).map(x => x.actor)
);
}
super.createCombatants(tokens, combat ?? {});
await super.createCombatants(tokens, combat ?? {});
}
/**@inheritdoc */

View file

@ -58,10 +58,11 @@ export const renderMeasuredTemplate = async event => {
if (!type || !range || !game.canvas.scene) return;
const shapeData = CONFIG.Canvas.layers.regions.layerClass.getTemplateShape({
type,
shapetype: type,
angle,
range,
direction
direction,
hasHole: false
});
await canvas.regions.placeRegion(

View file

@ -1,6 +1,10 @@
import { diceTypes, getDiceSoNicePresets, getDiceSoNicePreset, range } from '../config/generalConfig.mjs';
import Tagify from '@yaireo/tagify';
/**
* @import DhpActor from '../documents/actor.mjs';
*/
export const capitalize = string => {
return string.charAt(0).toUpperCase() + string.slice(1);
};
@ -891,15 +895,21 @@ export function shouldUseHopeFearAutomation(options = { gmAsPlayer: true }) {
return (!game.user.isGM || options.gmAsPlayer) ? hopeFear.players : hopeFear.gm;
}
/**
* Returns the given actor if its a world actor,
* finds a world actor equivalent,
* or imports the actor and returns the imported actor.
* @param {DhpActor} baseActor
* @returns {Promise<DhpActor>} a world actor
*/
export async function getWorldActor(baseActor) {
if (baseActor.inCompendium) {
const worldActorCopy = game.actors.find(x =>
x._stats.compendiumSource === baseActor.uuid &&
(!x.prototypeToken.actorLink || x.name === baseActor.name)
const worldActorCandidates = game.actors.filter(x =>
x._stats.compendiumSource === baseActor.uuid &&
x.prototypeToken.actorLink === baseActor.prototypeToken.actorLink
);
if (worldActorCopy)
return worldActorCopy;
const worldActorCopy = worldActorCandidates.find(a => a.name === baseActor.name) ?? worldActorCandidates[0];
if (worldActorCopy) return worldActorCopy;
const baseActorData = baseActor;
return await game.system.api.documents.DhpActor.create({