From 0b343c9f52752b0eb26f27ac7ddb0078aaa75f72 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:21:44 +0100 Subject: [PATCH 1/4] Fixed a lot of cases where we expected a combatant to have an attached actor (#1520) --- daggerheart.mjs | 2 +- lang/en.json | 4 +++- module/applications/hud/tokenHUD.mjs | 29 ++++++++++++++++++++---- module/applications/ui/combatTracker.mjs | 4 ++-- module/config/encounterConfig.mjs | 2 +- module/data/combat.mjs | 5 ++-- module/documents/token.mjs | 4 ++-- module/documents/tooltipManager.mjs | 6 ++--- system.json | 2 +- 9 files changed, 41 insertions(+), 17 deletions(-) diff --git a/daggerheart.mjs b/daggerheart.mjs index d8ebb713..48f4a615 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -316,7 +316,7 @@ const updateActorsRangeDependentEffects = async token => { CONFIG.DH.SETTINGS.gameSettings.variantRules ).rangeMeasurement; - for (let effect of token.actor.allApplicableEffects()) { + for (let effect of token.actor?.allApplicableEffects() ?? []) { if (!effect.system.rangeDependence?.enabled) continue; const { target, range, type } = effect.system.rangeDependence; diff --git a/lang/en.json b/lang/en.json index 4f1007c4..a78ed588 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2781,7 +2781,9 @@ "gmRequired": "This action requires an online GM", "gmOnly": "This can only be accessed by the GM", "noActorOwnership": "You do not have permissions for this character", - "documentIsMissing": "The {documentType} is missing from the world." + "documentIsMissing": "The {documentType} is missing from the world.", + "tokenActorMissing": "{name} is missing an Actor", + "tokenActorsMissing": "[{names}] missing Actors" }, "Sidebar": { "actorDirectory": { diff --git a/module/applications/hud/tokenHUD.mjs b/module/applications/hud/tokenHUD.mjs index b1136995..87c3e88e 100644 --- a/module/applications/hud/tokenHUD.mjs +++ b/module/applications/hud/tokenHUD.mjs @@ -21,6 +21,8 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD { async _prepareContext(options) { const context = await super._prepareContext(options); + if (!this.actor) return context; + context.partyOnCanvas = this.actor.type === 'party' && this.actor.system.partyMembers.some(member => member.getActiveTokens().length > 0); @@ -58,14 +60,33 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD { } static async #onToggleCombat() { + const tokensWithoutActors = canvas.tokens.controlled.filter(t => !t.actor); + const warning = + tokensWithoutActors.length === 1 + ? game.i18n.format('DAGGERHEART.UI.Notifications.tokenActorMissing', { + name: tokensWithoutActors[0].name + }) + : game.i18n.format('DAGGERHEART.UI.Notifications.tokenActorsMissing', { + names: tokensWithoutActors.map(x => x.name).join(', ') + }); + const tokens = canvas.tokens.controlled - .filter(t => !t.actor || !DHTokenHUD.#nonCombatTypes.includes(t.actor.type)) + .filter(t => t.actor && !DHTokenHUD.#nonCombatTypes.includes(t.actor.type)) .map(t => t.document); - if (!this.object.controlled) tokens.push(this.document); + if (!this.object.controlled && this.document.actor) tokens.push(this.document); try { - if (this.document.inCombat) await TokenDocument.implementation.deleteCombatants(tokens); - else await TokenDocument.implementation.createCombatants(tokens); + if (this.document.inCombat) { + const tokensInCombat = tokens.filter(t => t.inCombat); + await TokenDocument.implementation.deleteCombatants([...tokensInCombat, ...tokensWithoutActors]); + } else { + if (tokensWithoutActors.length) { + ui.notifications.warn(warning); + } + + const tokensOutOfCombat = tokens.filter(t => !t.inCombat); + await TokenDocument.implementation.createCombatants(tokensOutOfCombat); + } } catch (err) { ui.notifications.warn(err.message); } diff --git a/module/applications/ui/combatTracker.mjs b/module/applications/ui/combatTracker.mjs index babc4a65..288ba8ad 100644 --- a/module/applications/ui/combatTracker.mjs +++ b/module/applications/ui/combatTracker.mjs @@ -127,7 +127,7 @@ export default class DhCombatTracker extends foundry.applications.sidebar.tabs.C resource, active: index === combat.turn, canPing: combatant.sceneId === canvas.scene?.id && game.user.hasPermission('PING_CANVAS'), - type: combatant.actor.system.type, + type: combatant.actor?.system?.type, img: await this._getCombatantThumbnail(combatant) }; @@ -165,7 +165,7 @@ export default class DhCombatTracker extends foundry.applications.sidebar.tabs.C if (this.viewed.turn !== toggleTurn) { const { updateCountdowns } = game.system.api.applications.ui.DhCountdowns; - if (combatant.actor.type === 'character') { + if (combatant.actor?.type === 'character') { await updateCountdowns( CONFIG.DH.GENERAL.countdownProgressionTypes.spotlight.id, CONFIG.DH.GENERAL.countdownProgressionTypes.characterSpotlight.id diff --git a/module/config/encounterConfig.mjs b/module/config/encounterConfig.mjs index 7565652f..4e0f8a6e 100644 --- a/module/config/encounterConfig.mjs +++ b/module/config/encounterConfig.mjs @@ -9,7 +9,7 @@ export const AdversaryBPPerEncounter = (adversaries, characters) => { ); if (existingEntry) { existingEntry.nr += 1; - } else { + } else if (adversary.type) { acc.push({ adversary, nr: 1 }); } return acc; diff --git a/module/data/combat.mjs b/module/data/combat.mjs index 565afffc..3671a2cd 100644 --- a/module/data/combat.mjs +++ b/module/data/combat.mjs @@ -15,8 +15,9 @@ export default class DhCombat extends foundry.abstract.TypeDataModel { get extendedBattleToggles() { const modifiers = CONFIG.DH.ENCOUNTER.BPModifiers; const adversaries = - this.parent.turns?.filter(x => x.isNPC)?.map(x => ({ ...x.actor, type: x.actor.system.type })) ?? []; - const characters = this.parent.turns?.filter(x => !x.isNPC) ?? []; + this.parent.turns?.filter(x => x.actor && x.isNPC)?.map(x => ({ ...x.actor, type: x.actor.system.type })) ?? + []; + const characters = this.parent.turns?.filter(x => x.actor && !x.isNPC) ?? []; const activeAutomatic = Object.keys(modifiers).reduce((acc, categoryKey) => { const category = modifiers[categoryKey]; diff --git a/module/documents/token.mjs b/module/documents/token.mjs index c3babaa1..4ac29264 100644 --- a/module/documents/token.mjs +++ b/module/documents/token.mjs @@ -83,7 +83,7 @@ export default class DHToken extends CONFIG.Token.documentClass { if (combat?.system?.battleToggles?.length) { await combat.toggleModifierEffects( true, - tokens.map(x => x.actor) + tokens.filter(x => x.actor).map(x => x.actor) ); } super.createCombatants(tokens, combat ?? {}); @@ -95,7 +95,7 @@ export default class DHToken extends CONFIG.Token.documentClass { if (combat?.system?.battleToggles?.length) { await combat.toggleModifierEffects( false, - tokens.map(x => x.actor) + tokens.filter(x => x.actor).map(x => x.actor) ); } super.deleteCombatants(tokens, combat ?? {}); diff --git a/module/documents/tooltipManager.mjs b/module/documents/tooltipManager.mjs index b0a107b9..3ea6703c 100644 --- a/module/documents/tooltipManager.mjs +++ b/module/documents/tooltipManager.mjs @@ -262,7 +262,7 @@ export default class DhTooltipManager extends foundry.helpers.interaction.Toolti const combat = game.combats.get(combatId); const adversaries = combat.turns?.filter(x => x.actor?.isNPC)?.map(x => ({ ...x.actor, type: x.actor.system.type })) ?? []; - const characters = combat.turns?.filter(x => !x.isNPC) ?? []; + const characters = combat.turns?.filter(x => !x.isNPC && x.actor) ?? []; const nrCharacters = characters.length; const currentBP = AdversaryBPPerEncounter(adversaries, characters); @@ -272,7 +272,7 @@ export default class DhTooltipManager extends foundry.helpers.interaction.Toolti ); const categories = combat.combatants.reduce((acc, combatant) => { - if (combatant.actor.type === 'adversary') { + if (combatant.actor?.type === 'adversary') { const keyData = Object.keys(acc).reduce((identifiers, categoryKey) => { if (identifiers) return identifiers; const category = acc[categoryKey]; @@ -352,7 +352,7 @@ export default class DhTooltipManager extends foundry.helpers.interaction.Toolti await combat.toggleModifierEffects( event.target.checked, - combat.combatants.filter(x => x.actor.type === 'adversary').map(x => x.actor), + combat.combatants.filter(x => x.actor?.type === 'adversary').map(x => x.actor), category, grouping ); diff --git a/system.json b/system.json index c5fd61f2..5570bdbf 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "daggerheart", "title": "Daggerheart", "description": "An unofficial implementation of the Daggerheart system", - "version": "1.4.5", + "version": "1.4.6", "compatibility": { "minimum": "13.346", "verified": "13.351", From 454507ba7bf10597e8acd3e2674c260831169551 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:51:05 +0100 Subject: [PATCH 2/4] [Feature] Trigger System (#1500) * Initial * . * Added StrangePattern trigger * Set command codeblock to expandable * Added automation setting * Added ferocity trigger * Improved StrangePatterns trigger to handle multiple matches --- daggerheart.mjs | 49 +++++++++++ lang/en.json | 42 +++++++++- .../sheets-configs/action-base-config.mjs | 84 ++++++++++++++++++- module/config/_module.mjs | 1 + module/config/hooksConfig.mjs | 4 +- module/config/system.mjs | 4 +- module/config/triggerConfig.mjs | 42 ++++++++++ module/data/action/baseAction.mjs | 8 +- module/data/fields/_module.mjs | 1 + module/data/fields/triggerField.mjs | 24 ++++++ module/data/item/base.mjs | 26 +++++- module/data/settings/Automation.mjs | 7 ++ module/dice/dualityRoll.mjs | 24 ++++++ module/documents/actor.mjs | 13 +++ ...ure_Strange_Patterns_6YsfFjmCGuFYVhT4.json | 9 +- .../domainCard_Ferocity_jSQsSP61CX4MhSN7.json | 22 ++++- styles/less/sheets/actions/actions.less | 55 ++++++++++++ styles/less/sheets/index.less | 2 + .../settings/automation-settings/roll.hbs | 11 +++ .../action-settings/trigger.hbs | 37 ++++++++ 20 files changed, 450 insertions(+), 15 deletions(-) create mode 100644 module/config/triggerConfig.mjs create mode 100644 module/data/fields/triggerField.mjs create mode 100644 styles/less/sheets/actions/actions.less create mode 100644 templates/sheets-settings/action-settings/trigger.hbs diff --git a/daggerheart.mjs b/daggerheart.mjs index 48f4a615..0cd82014 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -84,6 +84,8 @@ Hooks.once('init', () => { fields }; + game.system.registeredTriggers = new RegisteredTriggers(); + const { DocumentSheetConfig } = foundry.applications.apps; DocumentSheetConfig.unregisterSheet(TokenDocument, 'core', foundry.applications.sheets.TokenConfig); DocumentSheetConfig.registerSheet(TokenDocument, SYSTEM.id, applications.sheetConfigs.DhTokenConfig, { @@ -379,3 +381,50 @@ Hooks.on('refreshToken', (_, options) => { Hooks.on('renderCompendiumDirectory', (app, html) => applications.ui.ItemBrowser.injectSidebarButton(html)); Hooks.on('renderDocumentDirectory', (app, html) => applications.ui.ItemBrowser.injectSidebarButton(html)); + +class RegisteredTriggers extends Map { + constructor() { + super(); + } + + async registerTriggers(trigger, actor, triggeringActorType, uuid, commands) { + const existingTrigger = this.get(trigger); + if (!existingTrigger) this.set(trigger, new Map()); + + this.get(trigger).set(uuid, { actor, triggeringActorType, commands }); + } + + async runTrigger(trigger, currentActor, ...args) { + const updates = []; + const triggerSettings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).triggers; + if (!triggerSettings.enabled) return updates; + + const dualityTrigger = this.get(trigger); + if (dualityTrigger) { + for (let { actor, triggeringActorType, commands } of dualityTrigger.values()) { + const triggerData = CONFIG.DH.TRIGGER.triggers[trigger]; + if (triggerData.usesActor && triggeringActorType !== 'any') { + if (triggeringActorType === 'self' && currentActor?.uuid !== actor) continue; + else if (triggeringActorType === 'other' && currentActor?.uuid === actor) continue; + } + + for (let command of commands) { + try { + const result = await command(...args); + if (result?.updates?.length) updates.push(...result.updates); + } catch (_) { + const triggerName = game.i18n.localize(triggerData.label); + ui.notifications.error( + game.i18n.format('DAGGERHEART.CONFIG.Triggers.triggerError', { + trigger: triggerName, + actor: currentActor?.name + }) + ); + } + } + } + } + + return updates; + } +} diff --git a/lang/en.json b/lang/en.json index a78ed588..1378daef 100755 --- a/lang/en.json +++ b/lang/en.json @@ -90,7 +90,9 @@ "customFormula": "Custom Formula", "formula": "Formula" }, - "displayInChat": "Display in chat" + "displayInChat": "Display in chat", + "deleteTriggerTitle": "Delete Trigger", + "deleteTriggerContent": "Are you sure you want to delete the {trigger} trigger?" }, "RollField": { "diceRolling": { @@ -1144,7 +1146,8 @@ "any": "Any", "friendly": "Friendly", "hostile": "Hostile", - "self": "Self" + "self": "Self", + "other": "Other" }, "TemplateTypes": { "circle": "Circle", @@ -1218,6 +1221,29 @@ } } }, + "Triggers": { + "postDamageReduction": { + "label": "After Damage Reduction" + }, + "preDamageReduction": { + "label": "Before Damage Reduction" + }, + "dualityRoll": { + "label": "Duality Roll" + }, + "fearRoll": { + "label": "Fear Roll" + }, + "triggerTexts": { + "strangePatternsContentTitle": "Matched {nr} times.", + "strangePatternsContentSubTitle": "Increase hope and stress to a total of {nr}.", + "ferocityContent": "Spend 2 Hope to gain {bonus} bonus Evasion until after the next attack against you?", + "ferocityEffectDescription": "Your evasion is increased by {bonus}. This bonus lasts until after the next attack made against you." + }, + "triggerType": "Trigger Type", + "triggeringActorType": "Triggering Actor Type", + "triggerError": "{trigger} trigger failed for {actor}. It's probably configured wrong." + }, "WeaponFeature": { "barrier": { "name": "Barrier", @@ -2058,7 +2084,8 @@ "itemFeatures": "Item Features", "questions": "Questions", "configuration": "Configuration", - "base": "Base" + "base": "Base", + "triggers": "Triggers" }, "Tiers": { "singular": "Tier", @@ -2432,6 +2459,12 @@ "hint": "Automatically apply effects. Targets must be selected before the action is made and Reaction Roll Automation must be different than Never. Bypass users permissions." } }, + "triggers": { + "enabled": { + "label": "Enabled", + "hint": "Advanced automation such as triggering a popup for a wizard's Strange Patterns" + } + }, "summaryMessages": { "label": "Summary Messages" } @@ -2441,6 +2474,9 @@ }, "roll": { "title": "Actions" + }, + "trigger": { + "title": "Triggers" } }, "Homebrew": { diff --git a/module/applications/sheets-configs/action-base-config.mjs b/module/applications/sheets-configs/action-base-config.mjs index 96790a5b..16ebcab5 100644 --- a/module/applications/sheets-configs/action-base-config.mjs +++ b/module/applications/sheets-configs/action-base-config.mjs @@ -7,6 +7,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) this.action = action; this.openSection = null; + this.openTrigger = this.action.triggers.length > 0 ? 0 : null; } get title() { @@ -15,7 +16,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) static DEFAULT_OPTIONS = { tag: 'form', - classes: ['daggerheart', 'dh-style', 'dialog', 'max-800'], + classes: ['daggerheart', 'dh-style', 'action-config', 'dialog', 'max-800'], window: { icon: 'fa-solid fa-wrench', resizable: false @@ -29,7 +30,10 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) removeElement: this.removeElement, editEffect: this.editEffect, addDamage: this.addDamage, - removeDamage: this.removeDamage + removeDamage: this.removeDamage, + addTrigger: this.addTrigger, + removeTrigger: this.removeTrigger, + expandTrigger: this.expandTrigger }, form: { handler: this.updateForm, @@ -55,6 +59,10 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) effect: { id: 'effect', template: 'systems/daggerheart/templates/sheets-settings/action-settings/effect.hbs' + }, + trigger: { + id: 'trigger', + template: 'systems/daggerheart/templates/sheets-settings/action-settings/trigger.hbs' } }; @@ -82,6 +90,14 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) id: 'effect', icon: null, label: 'DAGGERHEART.GENERAL.Tabs.effects' + }, + trigger: { + active: false, + cssClass: '', + group: 'primary', + id: 'trigger', + icon: null, + label: 'DAGGERHEART.GENERAL.Tabs.triggers' } }; @@ -111,6 +127,16 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) context.baseSaveDifficulty = this.action.actor?.baseSaveDifficulty; context.baseAttackBonus = this.action.actor?.system.attack?.roll.bonus; context.hasRoll = this.action.hasRoll; + context.triggers = context.source.triggers.map((trigger, index) => { + const { hint, returns, usesActor } = CONFIG.DH.TRIGGER.triggers[trigger.trigger]; + return { + ...trigger, + hint, + returns, + usesActor, + revealed: this.openTrigger === index + }; + }); const settingsTiers = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.LevelTiers).tiers; context.tierOptions = [ @@ -224,6 +250,60 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); } + static addTrigger() { + const data = this.action.toObject(); + data.triggers.push({ + trigger: CONFIG.DH.TRIGGER.triggers.dualityRoll.id, + triggeringActor: CONFIG.DH.TRIGGER.triggerActorTargetType.any.id + }); + this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); + } + + static async removeTrigger(_event, button) { + const trigger = CONFIG.DH.TRIGGER.triggers[this.action.triggers[button.dataset.index].trigger]; + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { + title: game.i18n.localize('DAGGERHEART.ACTIONS.Config.deleteTriggerTitle') + }, + content: game.i18n.format('DAGGERHEART.ACTIONS.Config.deleteTriggerContent', { + trigger: game.i18n.localize(trigger.label) + }) + }); + + if (!confirmed) return; + + const data = this.action.toObject(); + data.triggers = data.triggers.filter((_, index) => index !== Number.parseInt(button.dataset.index)); + this.constructor.updateForm.bind(this)(null, null, { object: foundry.utils.flattenObject(data) }); + } + + static async expandTrigger(_event, button) { + const index = Number.parseInt(button.dataset.index); + const toggle = (element, codeMirror) => { + codeMirror.classList.toggle('revealed'); + const button = element.querySelector('a > i'); + button.classList.toggle('fa-angle-up'); + button.classList.toggle('fa-angle-down'); + }; + + const fieldset = button.closest('fieldset'); + const codeMirror = fieldset.querySelector('.code-mirror-wrapper'); + toggle(fieldset, codeMirror); + + if (this.openTrigger !== null && this.openTrigger !== index) { + const previouslyExpanded = fieldset + .closest(`section`) + .querySelector(`fieldset[data-index="${this.openTrigger}"]`); + const codeMirror = previouslyExpanded.querySelector('.code-mirror-wrapper'); + toggle(previouslyExpanded, codeMirror); + this.openTrigger = index; + } else if (this.openTrigger === index) { + this.openTrigger = null; + } else { + this.openTrigger = index; + } + } + /** Specific implementation in extending classes **/ static async addEffect(_event) {} static removeEffect(_event, _button) {} diff --git a/module/config/_module.mjs b/module/config/_module.mjs index ef26b958..560f3fec 100644 --- a/module/config/_module.mjs +++ b/module/config/_module.mjs @@ -10,3 +10,4 @@ export * as itemConfig from './itemConfig.mjs'; export * as settingsConfig from './settingsConfig.mjs'; export * as systemConfig from './system.mjs'; export * as itemBrowserConfig from './itemBrowserConfig.mjs'; +export * as triggerConfig from './triggerConfig.mjs'; diff --git a/module/config/hooksConfig.mjs b/module/config/hooksConfig.mjs index d316c4d4..9140ea0a 100644 --- a/module/config/hooksConfig.mjs +++ b/module/config/hooksConfig.mjs @@ -1,5 +1,3 @@ -const hooksConfig = { +export const hooksConfig = { effectDisplayToggle: 'DHEffectDisplayToggle' }; - -export default hooksConfig; diff --git a/module/config/system.mjs b/module/config/system.mjs index ac15b1d9..47a41e8d 100644 --- a/module/config/system.mjs +++ b/module/config/system.mjs @@ -7,7 +7,8 @@ import * as SETTINGS from './settingsConfig.mjs'; import * as EFFECTS from './effectConfig.mjs'; import * as ACTIONS from './actionConfig.mjs'; import * as FLAGS from './flagsConfig.mjs'; -import HOOKS from './hooksConfig.mjs'; +import * as HOOKS from './hooksConfig.mjs'; +import * as TRIGGER from './triggerConfig.mjs'; import * as ITEMBROWSER from './itemBrowserConfig.mjs'; export const SYSTEM_ID = 'daggerheart'; @@ -24,5 +25,6 @@ export const SYSTEM = { ACTIONS, FLAGS, HOOKS, + TRIGGER, ITEMBROWSER }; diff --git a/module/config/triggerConfig.mjs b/module/config/triggerConfig.mjs new file mode 100644 index 00000000..35609bd1 --- /dev/null +++ b/module/config/triggerConfig.mjs @@ -0,0 +1,42 @@ +/* hints and returns are intentionally not translated. They are programatical terms and best understood in english */ +export const triggers = { + dualityRoll: { + id: 'dualityRoll', + usesActor: true, + args: ['roll', 'actor'], + label: 'DAGGERHEART.CONFIG.Triggers.dualityRoll.label', + hint: 'this: Action, roll: DhRoll, actor: DhActor', + returns: '{ updates: [{ key, value, total }] }' + }, + fearRoll: { + id: 'fearRoll', + usesActor: true, + args: ['roll', 'actor'], + label: 'DAGGERHEART.CONFIG.Triggers.fearRoll.label', + hint: 'this: Action, roll: DhRoll, actor: DhActor', + returns: '{ updates: [{ key, value, total }] }' + }, + postDamageReduction: { + id: 'postDamageReduction', + usesActor: true, + args: ['damageUpdates', 'actor'], + label: 'DAGGERHEART.CONFIG.Triggers.postDamageReduction.label', + hint: 'damageUpdates: ResourceUpdates, actor: DhActor', + returns: '{ updates: [{ originActor: this.actor, updates: [{ key, value, total }] }] }' + } +}; + +export const triggerActorTargetType = { + any: { + id: 'any', + label: 'DAGGERHEART.CONFIG.TargetTypes.any' + }, + self: { + id: 'self', + label: 'DAGGERHEART.CONFIG.TargetTypes.self' + }, + other: { + id: 'other', + label: 'DAGGERHEART.CONFIG.TargetTypes.other' + } +}; diff --git a/module/data/action/baseAction.mjs b/module/data/action/baseAction.mjs index 18a09904..3bf97564 100644 --- a/module/data/action/baseAction.mjs +++ b/module/data/action/baseAction.mjs @@ -2,6 +2,7 @@ import DhpActor from '../../documents/actor.mjs'; import D20RollDialog from '../../applications/dialogs/d20RollDialog.mjs'; import { ActionMixin } from '../fields/actionField.mjs'; import { originItemField } from '../chat-message/actorRoll.mjs'; +import TriggerField from '../fields/triggerField.mjs'; const fields = foundry.data.fields; @@ -34,7 +35,8 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel nullable: false, required: true }), - targetUuid: new fields.StringField({ initial: undefined }) + targetUuid: new fields.StringField({ initial: undefined }), + triggers: new fields.ArrayField(new TriggerField()) }; this.extraSchemas.forEach(s => { @@ -343,6 +345,10 @@ export class ResourceUpdateMap extends Map { } addResources(resources) { + if (!resources?.length) return; + const invalidResources = resources.some(resource => !resource.key); + if (invalidResources) return; + for (const resource of resources) { if (!resource.key) continue; diff --git a/module/data/fields/_module.mjs b/module/data/fields/_module.mjs index 8d36b76d..2a8ba454 100644 --- a/module/data/fields/_module.mjs +++ b/module/data/fields/_module.mjs @@ -2,5 +2,6 @@ export { ActionCollection } from './actionField.mjs'; export { default as FormulaField } from './formulaField.mjs'; export { default as ForeignDocumentUUIDField } from './foreignDocumentUUIDField.mjs'; export { default as ForeignDocumentUUIDArrayField } from './foreignDocumentUUIDArrayField.mjs'; +export { default as TriggerField } from './triggerField.mjs'; export { default as MappingField } from './mappingField.mjs'; export * as ActionFields from './action/_module.mjs'; diff --git a/module/data/fields/triggerField.mjs b/module/data/fields/triggerField.mjs new file mode 100644 index 00000000..8378ea19 --- /dev/null +++ b/module/data/fields/triggerField.mjs @@ -0,0 +1,24 @@ +export default class TriggerField extends foundry.data.fields.SchemaField { + constructor(context) { + super( + { + trigger: new foundry.data.fields.StringField({ + nullable: false, + blank: false, + initial: CONFIG.DH.TRIGGER.triggers.dualityRoll.id, + choices: CONFIG.DH.TRIGGER.triggers, + label: 'DAGGERHEART.CONFIG.Triggers.triggerType' + }), + triggeringActorType: new foundry.data.fields.StringField({ + nullable: false, + blank: false, + initial: CONFIG.DH.TRIGGER.triggerActorTargetType.any.id, + choices: CONFIG.DH.TRIGGER.triggerActorTargetType, + label: 'DAGGERHEART.CONFIG.Triggers.triggeringActorType' + }), + command: new foundry.data.fields.JavaScriptField({ async: true }) + }, + context + ); + } +} diff --git a/module/data/item/base.mjs b/module/data/item/base.mjs index 11be0a52..60d29792 100644 --- a/module/data/item/base.mjs +++ b/module/data/item/base.mjs @@ -8,7 +8,7 @@ * @property {boolean} isInventoryItem- Indicates whether items of this type is a Inventory Item */ -import { addLinkedItemsDiff, createScrollText, getScrollTextData, updateLinkedItemApps } from '../../helpers/utils.mjs'; +import { addLinkedItemsDiff, getScrollTextData, updateLinkedItemApps } from '../../helpers/utils.mjs'; import { ActionsField } from '../fields/actionField.mjs'; import FormulaField from '../fields/formulaField.mjs'; @@ -135,6 +135,30 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel { return data; } + prepareBaseData() { + super.prepareBaseData(); + + for (const action of this.actions ?? []) { + if (!action.actor) continue; + + const actionsToRegister = []; + for (let i = 0; i < action.triggers.length; i++) { + const trigger = action.triggers[i]; + const { args } = CONFIG.DH.TRIGGER.triggers[trigger.trigger]; + const fn = new foundry.utils.AsyncFunction(...args, `{${trigger.command}\n}`); + actionsToRegister.push(fn.bind(action)); + if (i === action.triggers.length - 1) + game.system.registeredTriggers.registerTriggers( + trigger.trigger, + action.actor?.uuid, + trigger.triggeringActorType, + this.parent.uuid, + actionsToRegister + ); + } + } + } + async _preCreate(data, options, user) { // Skip if no initial action is required or actions already exist if (this.metadata.hasInitialAction && foundry.utils.isEmpty(this.actions)) { diff --git a/module/data/settings/Automation.mjs b/module/data/settings/Automation.mjs index 2aec990f..3376b153 100644 --- a/module/data/settings/Automation.mjs +++ b/module/data/settings/Automation.mjs @@ -173,6 +173,13 @@ export default class DhAutomation extends foundry.abstract.DataModel { label: 'DAGGERHEART.GENERAL.player.plurial' }) }) + }), + triggers: new fields.SchemaField({ + enabled: new fields.BooleanField({ + nullable: false, + initial: true, + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.triggers.enabled.label' + }) }) }; } diff --git a/module/dice/dualityRoll.mjs b/module/dice/dualityRoll.mjs index d2e20213..f15e0b09 100644 --- a/module/dice/dualityRoll.mjs +++ b/module/dice/dualityRoll.mjs @@ -224,6 +224,30 @@ export default class DualityRoll extends D20Roll { await super.buildPost(roll, config, message); await DualityRoll.dualityUpdate(config); + await DualityRoll.handleTriggers(roll, config); + } + + static async handleTriggers(roll, config) { + const updates = []; + const dualityUpdates = await game.system.registeredTriggers.runTrigger( + CONFIG.DH.TRIGGER.triggers.dualityRoll.id, + roll.data?.parent, + roll, + roll.data?.parent + ); + if (dualityUpdates?.length) updates.push(...dualityUpdates); + + if (config.roll.result.duality === -1) { + const fearUpdates = await game.system.registeredTriggers.runTrigger( + CONFIG.DH.TRIGGER.triggers.fearRoll.id, + roll.data?.parent, + roll, + roll.data?.parent + ); + if (fearUpdates?.length) updates.push(...fearUpdates); + } + + config.resourceUpdates.addResources(updates); } static async addDualityResourceUpdates(config) { diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index f6666a5e..1a4153ad 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -646,6 +646,19 @@ export default class DhpActor extends Actor { } } + const results = await game.system.registeredTriggers.runTrigger( + CONFIG.DH.TRIGGER.triggers.postDamageReduction.id, + this, + updates, + this + ); + + if (results?.length) { + const resourceMap = new ResourceUpdateMap(results[0].originActor); + for (var result of results) resourceMap.addResources(result.updates); + resourceMap.updateResources(); + } + updates.forEach( u => (u.value = diff --git a/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json b/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json index bd364e6f..95f42c06 100644 --- a/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json +++ b/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json @@ -80,7 +80,14 @@ }, "name": "Clear Stress", "img": "icons/magic/symbols/rune-sigil-black-pink.webp", - "range": "" + "range": "", + "triggers": [ + { + "trigger": "dualityRoll", + "triggeringActorType": "self", + "command": "/* Ignore if it's a TagTeam roll */\nconst tagTeam = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll);\nif (tagTeam.members[actor.id]) return;\n\n/* Check if there's a Strange Pattern match */\nconst dice = [roll.dFear.total, roll.dHope.total];\nconst resource = this.parent.resource?.diceStates ? Object.values(this.parent.resource.diceStates).map(x => x.value)[0] : null;\nconst nrMatches = dice.filter(x => x === resource).length;\n\nif (!nrMatches) return;\n\n/* Create a dialog to choose Hope or Stress - or to cancel*/\nconst content = `\n
${game.i18n.format('DAGGERHEART.CONFIG.Triggers.triggerTexts.strangePatternsContentTitle', { nr: nrMatches })}
\n
${game.i18n.format('DAGGERHEART.CONFIG.Triggers.triggerTexts.strangePatternsContentSubTitle', { nr: nrMatches })}
\n
\n \n \n
\n
`;\n\nconst result = await foundry.applications.api.DialogV2.input({\n classes: ['dh-style', 'two-big-buttons'],\n window: { title: this.item.name },\n content: content,\n render: (_, dialog) => {\n const hopeButton = dialog.element.querySelector('#hopeButton');\n const stressButton = dialog.element.querySelector('#stressButton');\ndialog.element.querySelector('button[type=\"submit\"]').disabled = true;\n \n const updateFunc = (event, selector, adding, clamp) => {\n const button = event.target.closest(`#${selector}Button`);\n const parent = event.target.closest('.flexrow');\n const hope = Number.parseInt(parent.querySelector('#hopeButton label').innerHTML);\n const stress = Number.parseInt(parent.querySelector('#stressButton label').innerHTML);\n const currentTotal = (Number.isNumeric(hope) ? hope : 0) + (Number.isNumeric(stress) ? stress : 0);\n if (adding && currentTotal === nrMatches) return;\n \n const current = Number.parseInt(button.querySelector('label').innerHTML);\n if (!adding && current === 0) return;\n \n const value = Number.isNumeric(current) ? adding ? current+1 : current-1 : 1;\n if (!dialog.data) dialog.data = {};\n dialog.data[selector] = clamp(value);\n button.querySelector('label').innerHTML = dialog.data[selector];\n\n event.target.closest('.dialog-form').querySelector('button[type=\"submit\"]').disabled = !adding || currentTotal < (nrMatches-1);\n \n };\n hopeButton.addEventListener('click', event => updateFunc(event, 'hope', true, x => Math.min(x, nrMatches)));\n hopeButton.addEventListener('contextmenu', event => updateFunc(event, 'hope', false, x => Math.max(x, 0)));\n stressButton.addEventListener('click', event => updateFunc(event, 'stress', true, x => Math.min(x, nrMatches)));\n stressButton.addEventListener('contextmenu', event => updateFunc(event, 'stress', false, x => Math.max(x, 0)));\n },\n ok: { callback: (_event, _result, dialog) => {\n const hope = dialog.data.hope ?? 0;\n const stress = dialog.data.stress ?? 0;\n if (!hope && !stress) return;\n\n /* Return resource update according to choices */\n const hopeUpdate = hope ? { key: 'hope', value: hope, total: -hope, enabled: true } : null;\n const stressUpdate = stress ? { key: 'stress', value: -stress, total: stress, enabled: true } : null;\n return { updates: [hopeUpdate, stressUpdate].filter(x => x) };\n }}\n});\n\nreturn result;" + } + ] } }, "originItemType": null, diff --git a/src/packs/domains/domainCard_Ferocity_jSQsSP61CX4MhSN7.json b/src/packs/domains/domainCard_Ferocity_jSQsSP61CX4MhSN7.json index 9e46e6ba..78593c62 100644 --- a/src/packs/domains/domainCard_Ferocity_jSQsSP61CX4MhSN7.json +++ b/src/packs/domains/domainCard_Ferocity_jSQsSP61CX4MhSN7.json @@ -17,7 +17,16 @@ "description": "

When you cause an adversary to mark 1 or more Hit Points, you can spend 2 Hope to increase your Evasion by the number of Hit Points they marked. This bonus lasts until after the next attack made against you.

", "chatDisplay": true, "actionType": "action", - "cost": [], + "cost": [ + { + "scalable": false, + "key": "hope", + "value": 2, + "itemId": null, + "step": null, + "consumeOnSuccess": false + } + ], "uses": { "value": null, "max": "", @@ -30,8 +39,15 @@ "amount": null }, "name": "Spend Hope", - "img": "icons/skills/melee/maneuver-daggers-paired-orange.webp", - "range": "" + "img": "icons/skills/melee/maneuver-sword-katana-yellow.webp", + "range": "", + "triggers": [ + { + "trigger": "postDamageReduction", + "triggeringActorType": "other", + "command": "/* Check if sufficient hope */\nif (this.actor.system.resources.hope.value < 2) return;\n\n/* Check if hit point damage was dealt */\nconst hpDamage = damageUpdates.find(u => u.key === CONFIG.DH.GENERAL.healingTypes.hitPoints.id);\nif (hpDamage.value < 0) return;\n\n/* Dialog to give player choice */\nconst confirmed = await foundry.applications.api.DialogV2.confirm({\n window: { title: this.item?.name ?? '' },\n content: game.i18n.format('DAGGERHEART.CONFIG.Triggers.triggerTexts.ferocityContent', { bonus: hpDamage.value }),\n});\n\nif (!confirmed) return;\n\n/* Create the effect */\nthis.actor.createEmbeddedDocuments('ActiveEffect', [{\n name: this.item.name,\n img: 'icons/skills/melee/maneuver-sword-katana-yellow.webp',\n description: game.i18n.format('DAGGERHEART.CONFIG.Triggers.triggerTexts.ferocityEffectDescription', { bonus: hpDamage.value }),\n changes: [{ key: 'system.evasion', mode: 2, value: hpDamage.value }]\n}]);\n\n/* Update hope */\nreturn { updates: [{ \n originActor: this.actor, \n updates: [{\n key: CONFIG.DH.GENERAL.healingTypes.hope.id,\n value: -2,\n total: 2\n }] \n}]}" + } + ] } }, "attribution": { diff --git a/styles/less/sheets/actions/actions.less b/styles/less/sheets/actions/actions.less new file mode 100644 index 00000000..6796006c --- /dev/null +++ b/styles/less/sheets/actions/actions.less @@ -0,0 +1,55 @@ +.application.daggerheart.dh-style.action-config { + .trigger-data { + width: 100%; + display: flex; + justify-content: space-between; + gap: 8px; + + .trigger-data-inner { + flex: 1; + display: flex; + flex-direction: column; + + select { + flex: 1; + } + + .select-section { + flex: 1; + display: flex; + gap: 8px; + } + + .programmer-section { + flex: 3; + display: flex; + flex-direction: column; + + .hint-section { + display: flex; + gap: 4px; + + .hint { + flex: 1; + flex-wrap: wrap; + } + } + } + } + + .expand-trigger { + font-size: 18px; + } + } + + .code-mirror-wrapper { + width: 100%; + height: 0; + min-height: 0; + transition: height 0.1s ease-in-out; + + &.revealed { + height: 300px; + } + } +} diff --git a/styles/less/sheets/index.less b/styles/less/sheets/index.less index 1de1b055..44a6aa4d 100644 --- a/styles/less/sheets/index.less +++ b/styles/less/sheets/index.less @@ -1,3 +1,5 @@ +@import './actions/actions.less'; + @import './actors/actor-sheet-shared.less'; @import './actors/adversary/actions.less'; diff --git a/templates/settings/automation-settings/roll.hbs b/templates/settings/automation-settings/roll.hbs index 5769bf61..dc65f8ae 100644 --- a/templates/settings/automation-settings/roll.hbs +++ b/templates/settings/automation-settings/roll.hbs @@ -19,4 +19,15 @@ {{/each}} + +
+ + {{localize "DAGGERHEART.SETTINGS.Automation.trigger.title"}} + + +
+ {{formGroup settingFields.schema.fields.triggers.fields.enabled value=settingFields.triggers.enabled localize=true}} +

{{localize "DAGGERHEART.SETTINGS.Automation.FIELDS.triggers.enabled.hint"}}

+
+
\ No newline at end of file diff --git a/templates/sheets-settings/action-settings/trigger.hbs b/templates/sheets-settings/action-settings/trigger.hbs new file mode 100644 index 00000000..b048461e --- /dev/null +++ b/templates/sheets-settings/action-settings/trigger.hbs @@ -0,0 +1,37 @@ +
+ + + {{#each @root.triggers as |trigger index|}} +
+ + +
+
+
+ {{formGroup @root.fields.triggers.element.fields.trigger value=trigger.trigger name=(concat "triggers." index ".trigger") blank=false localize=true}} + {{#if trigger.usesActor}}{{formGroup @root.fields.triggers.element.fields.triggeringActorType value=trigger.triggeringActorType name=(concat "triggers." index ".triggeringActorType") blank=false localize=true}}{{/if}} +
+
+
+ {{localize "Context: "}} + {{localize trigger.hint}} +
+
+ {{localize "Return: "}} + {{localize trigger.returns}} +
+
+
+ +
+ +
+ {{formInput @root.fields.triggers.element.fields.command value=trigger.command elementType="code-mirror" name=(concat "triggers." index ".command") aria=(object label=(localize "Test")) }} +
+
+ {{/each}} +
\ No newline at end of file From 8de12551ad819512b93da48b0b322881d1d456e3 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Mon, 12 Jan 2026 00:26:27 +0100 Subject: [PATCH 3/4] [Feature] Scene Environments (#1499) * Initial * Remade to array structure * Fixed context menu for multiples * . * Cleanup * . * Fixed so that users can access sceneEnvironments if they have permissions * Update module/documents/scene.mjs Co-authored-by: Carlos Fernandez * . --------- Co-authored-by: Carlos Fernandez --- daggerheart.mjs | 1 + lang/en.json | 4 +- .../scene/sceneConfigSettings.mjs | 80 +++++++++++++---- module/applications/ui/_module.mjs | 1 + module/applications/ui/sceneNavigation.mjs | 89 +++++++++++++++++++ module/applications/ux/contextMenu.mjs | 4 +- module/data/actor/environment.mjs | 30 ++++++- module/data/scene/scene.mjs | 8 +- module/documents/scene.mjs | 26 ++++++ module/systemRegistration/socket.mjs | 7 +- styles/less/ui/index.less | 2 + styles/less/ui/scene-config/scene-config.less | 59 ++++++++++++ .../ui/scene-navigation/scene-navigation.less | 36 ++++++++ templates/scene/dh-config.hbs | 35 ++++++++ .../ui/sceneNavigation/scene-navigation.hbs | 36 ++++++++ 15 files changed, 395 insertions(+), 23 deletions(-) create mode 100644 module/applications/ui/sceneNavigation.mjs create mode 100644 styles/less/ui/scene-navigation/scene-navigation.less create mode 100644 templates/ui/sceneNavigation/scene-navigation.hbs diff --git a/daggerheart.mjs b/daggerheart.mjs index 0cd82014..861d16ca 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -62,6 +62,7 @@ CONFIG.Token.rulerClass = placeables.DhTokenRuler; CONFIG.Token.hudClass = applications.hud.DHTokenHUD; CONFIG.ui.combat = applications.ui.DhCombatTracker; +CONFIG.ui.nav = applications.ui.DhSceneNavigation; CONFIG.ui.chat = applications.ui.DhChatLog; CONFIG.ui.effectsDisplay = applications.ui.DhEffectsDisplay; CONFIG.ui.hotbar = applications.ui.DhHotbar; diff --git a/lang/en.json b/lang/en.json index 1378daef..c32ffa4e 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2604,7 +2604,9 @@ } }, "disabledText": "Daggerheart Measurements are disabled in System Settings - Variant Rules", - "rangeMeasurement": "Range Measurement" + "rangeMeasurement": "Range Measurement", + "sceneEnvironments": "Scene Environments", + "dragEnvironmentHere": "Drag environments here" } }, "UI": { diff --git a/module/applications/scene/sceneConfigSettings.mjs b/module/applications/scene/sceneConfigSettings.mjs index be8f7b71..1b93aa8c 100644 --- a/module/applications/scene/sceneConfigSettings.mjs +++ b/module/applications/scene/sceneConfigSettings.mjs @@ -1,16 +1,28 @@ +import { RefreshType, socketEvent } from '../../systemRegistration/socket.mjs'; + export default class DhSceneConfigSettings extends foundry.applications.sheets.SceneConfig { - // static DEFAULT_OPTIONS = { - // ...super.DEFAULT_OPTIONS, - // form: { - // handler: this.updateData, - // closeOnSubmit: true - // } - // }; + constructor(options) { + super(options); + + Hooks.on(socketEvent.Refresh, ({ refreshType }) => { + if (refreshType === RefreshType.Scene) { + this.daggerheartFlag = new game.system.api.data.scenes.DHScene(this.document.flags.daggerheart); + this.render(); + } + }); + } + + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + actions: { + ...super.DEFAULT_OPTIONS.actions, + removeSceneEnvironment: DhSceneConfigSettings.#removeSceneEnvironment + } + }; static buildParts() { const { footer, tabs, ...parts } = super.PARTS; const tmpParts = { - // tabs, tabs: { template: 'systems/daggerheart/templates/scene/tabs.hbs' }, ...parts, dh: { template: 'systems/daggerheart/templates/scene/dh-config.hbs' }, @@ -28,27 +40,45 @@ export default class DhSceneConfigSettings extends foundry.applications.sheets.S static TABS = DhSceneConfigSettings.buildTabs(); + async _preRender(context, options) { + await super._preFirstRender(context, options); + this.daggerheartFlag = new game.system.api.data.scenes.DHScene(this.document.flags.daggerheart); + } + _attachPartListeners(partId, htmlElement, options) { super._attachPartListeners(partId, htmlElement, options); + switch (partId) { case 'dh': htmlElement.querySelector('#rangeMeasurementSetting')?.addEventListener('change', async event => { - const flagData = foundry.utils.mergeObject(this.document.flags.daggerheart, { - rangeMeasurement: { setting: event.target.value } - }); - this.document.flags.daggerheart = flagData; + this.daggerheartFlag.updateSource({ rangeMeasurement: { setting: event.target.value } }); this.render(); }); + + const dragArea = htmlElement.querySelector('.scene-environments'); + if (dragArea) dragArea.ondrop = this._onDrop.bind(this); + break; } } + async _onDrop(event) { + const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event); + const item = await foundry.utils.fromUuid(data.uuid); + if (item instanceof game.system.api.documents.DhpActor && item.type === 'environment') { + await this.daggerheartFlag.updateSource({ + sceneEnvironments: [...this.daggerheartFlag.sceneEnvironments, data.uuid] + }); + this.render(); + } + } + /** @inheritDoc */ async _preparePartContext(partId, context, options) { context = await super._preparePartContext(partId, context, options); switch (partId) { case 'dh': - context.data = new game.system.api.data.scenes.DHScene(canvas.scene.flags.daggerheart); + context.data = this.daggerheartFlag; context.variantRules = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules); break; } @@ -56,8 +86,24 @@ export default class DhSceneConfigSettings extends foundry.applications.sheets.S return context; } - // static async updateData(event, _, formData) { - // const data = foundry.utils.expandObject(formData.object); - // this.close(data); - // } + static async #removeSceneEnvironment(_event, button) { + await this.daggerheartFlag.updateSource({ + sceneEnvironments: this.daggerheartFlag.sceneEnvironments.filter( + (_, index) => index !== Number.parseInt(button.dataset.index) + ) + }); + this.render(); + } + + /** @override */ + async _processSubmitData(event, form, submitData, options) { + submitData.flags.daggerheart = this.daggerheartFlag.toObject(); + for (const key of Object.keys(this.document._source.flags.daggerheart?.sceneEnvironments ?? {})) { + if (!submitData.flags.daggerheart.sceneEnvironments[key]) { + submitData.flags.daggerheart.sceneEnvironments[`-=${key}`] = null; + } + } + + super._processSubmitData(event, form, submitData, options); + } } diff --git a/module/applications/ui/_module.mjs b/module/applications/ui/_module.mjs index d5f31906..8c5c020e 100644 --- a/module/applications/ui/_module.mjs +++ b/module/applications/ui/_module.mjs @@ -5,4 +5,5 @@ export { default as DhCombatTracker } from './combatTracker.mjs'; export { default as DhEffectsDisplay } from './effectsDisplay.mjs'; export { default as DhFearTracker } from './fearTracker.mjs'; export { default as DhHotbar } from './hotbar.mjs'; +export { default as DhSceneNavigation } from './sceneNavigation.mjs'; export { ItemBrowser } from './itemBrowser.mjs'; diff --git a/module/applications/ui/sceneNavigation.mjs b/module/applications/ui/sceneNavigation.mjs new file mode 100644 index 00000000..ac16ac99 --- /dev/null +++ b/module/applications/ui/sceneNavigation.mjs @@ -0,0 +1,89 @@ +import { emitAsGM, GMUpdateEvent } from '../../systemRegistration/socket.mjs'; + +export default class DhSceneNavigation extends foundry.applications.ui.SceneNavigation { + /** @inheritdoc */ + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + classes: ['faded-ui', 'flexcol', 'scene-navigation'], + actions: { + openSceneEnvironment: DhSceneNavigation.#openSceneEnvironment + } + }; + + /** @inheritdoc */ + static PARTS = { + scenes: { + root: true, + template: 'systems/daggerheart/templates/ui/sceneNavigation/scene-navigation.hbs' + } + }; + + /** @inheritdoc */ + async _prepareContext(options) { + const context = await super._prepareContext(options); + + const extendScenes = scenes => + scenes.map(x => { + const scene = game.scenes.get(x.id); + if (!scene.flags.daggerheart) return x; + + const daggerheartInfo = new game.system.api.data.scenes.DHScene(scene.flags.daggerheart); + const environments = daggerheartInfo.sceneEnvironments.filter( + x => x && x.testUserPermission(game.user, 'LIMITED') + ); + const hasEnvironments = environments.length > 0; + return { + ...x, + hasEnvironments, + environmentImage: hasEnvironments ? environments[0].img : null, + environments: environments + }; + }); + context.scenes.active = extendScenes(context.scenes.active); + context.scenes.inactive = extendScenes(context.scenes.inactive); + + return context; + } + + static async #openSceneEnvironment(event, button) { + const scene = game.scenes.get(button.dataset.sceneId); + const sceneEnvironments = new game.system.api.data.scenes.DHScene( + scene.flags.daggerheart + ).sceneEnvironments.filter(x => x.testUserPermission(game.user, 'LIMITED')); + + if (sceneEnvironments.length === 1 || event.shiftKey) { + sceneEnvironments[0].sheet.render(true); + } else { + new foundry.applications.ux.ContextMenu.implementation( + button, + '.scene-environment', + sceneEnvironments.map(environment => ({ + name: environment.name, + callback: () => { + if (scene.flags.daggerheart.sceneEnvironments[0] !== environment.uuid) { + const newEnvironments = scene.flags.daggerheart.sceneEnvironments; + const newFirst = newEnvironments.splice( + newEnvironments.findIndex(x => x === environment.uuid) + )[0]; + newEnvironments.unshift(newFirst); + emitAsGM( + GMUpdateEvent.UpdateDocument, + scene.update.bind(scene), + { 'flags.daggerheart.sceneEnvironments': newEnvironments }, + scene.uuid + ); + } + + environment.sheet.render({ force: true }); + } + })), + { + jQuery: false, + fixed: true + } + ); + + CONFIG.ux.ContextMenu.triggerContextMenu(event, '.scene-environment'); + } + } +} diff --git a/module/applications/ux/contextMenu.mjs b/module/applications/ux/contextMenu.mjs index 09454848..081e6ba0 100644 --- a/module/applications/ux/contextMenu.mjs +++ b/module/applications/ux/contextMenu.mjs @@ -96,11 +96,11 @@ export default class DHContextMenu extends foundry.applications.ux.ContextMenu { * Trigger a context menu event in response to a normal click on a additional options button. * @param {PointerEvent} event */ - static triggerContextMenu(event) { + static triggerContextMenu(event, altSelector) { event.preventDefault(); event.stopPropagation(); const { clientX, clientY } = event; - const selector = '[data-item-uuid]'; + const selector = altSelector ?? '[data-item-uuid]'; const target = event.target.closest(selector) ?? event.currentTarget.closest(selector); target?.dispatchEvent( new PointerEvent('contextmenu', { diff --git a/module/data/actor/environment.mjs b/module/data/actor/environment.mjs index 4ed3819e..0aaf8eb0 100644 --- a/module/data/actor/environment.mjs +++ b/module/data/actor/environment.mjs @@ -1,8 +1,11 @@ import BaseDataActor from './base.mjs'; import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs'; import DHEnvironmentSettings from '../../applications/sheets-configs/environment-settings.mjs'; +import { RefreshType, socketEvent } from '../../systemRegistration/socket.mjs'; export default class DhEnvironment extends BaseDataActor { + scenes = new Set(); + /**@override */ static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Environment']; @@ -53,6 +56,31 @@ export default class DhEnvironment extends BaseDataActor { } isItemValid(source) { - return source.type === "feature"; + return source.type === 'feature'; + } + + _onUpdate(changes, options, userId) { + super._onUpdate(changes, options, userId); + for (const scene of this.scenes) { + scene.render(); + } + } + + _onDelete(options, userId) { + super._onDelete(options, userId); + for (const scene of this.scenes) { + if (game.user.isActiveGM) { + const newSceneEnvironments = scene.flags.daggerheart.sceneEnvironments.filter( + x => x !== this.parent.uuid + ); + scene.update({ 'flags.daggerheart.sceneEnvironments': newSceneEnvironments }).then(() => { + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.Scene }); + game.socket.emit(`system.${CONFIG.DH.id}`, { + action: socketEvent.Refresh, + data: { refreshType: RefreshType.TagTeamRoll } + }); + }); + } + } } } diff --git a/module/data/scene/scene.mjs b/module/data/scene/scene.mjs index 7cf74ade..f2a24308 100644 --- a/module/data/scene/scene.mjs +++ b/module/data/scene/scene.mjs @@ -1,3 +1,8 @@ +import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs'; + +/* Foundry does not add any system data for subtyped Scenes. The data model is therefore used by instantiating a new instance of it for sceneConfigSettings.mjs. + Needed dataprep and lifetime hooks are handled in documents/scene. +*/ export default class DHScene extends foundry.abstract.DataModel { static defineSchema() { const fields = foundry.data.fields; @@ -13,7 +18,8 @@ export default class DHScene extends foundry.abstract.DataModel { veryClose: new fields.NumberField({ integer: true, label: 'DAGGERHEART.CONFIG.Range.veryClose.name' }), 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 }) }; } } diff --git a/module/documents/scene.mjs b/module/documents/scene.mjs index c6cdd2c2..7f880b1d 100644 --- a/module/documents/scene.mjs +++ b/module/documents/scene.mjs @@ -37,4 +37,30 @@ export default class DhScene extends Scene { this.#sizeSyncBatch.clear(); this.updateEmbeddedDocuments('Token', entries, { animation: { movementSpeed: 1.5 } }); }, 0); + + prepareBaseData() { + super.prepareBaseData(); + + if (this instanceof game.system.api.documents.DhScene) { + const system = new game.system.api.data.scenes.DHScene(this.flags.daggerheart); + + // Register this scene to all environements + for (const environment of system.sceneEnvironments) { + environment.system.scenes?.add(this); + } + } + } + + _onDelete(options, userId) { + super._onDelete(options, userId); + + if (this instanceof game.system.api.documents.DhScene) { + const system = new game.system.api.data.scenes.DHScene(this.flags.daggerheart); + + // Clear this scene from all environments that aren't deleted + for (const environment of system.sceneEnvironments) { + environment?.system?.scenes?.delete(this); + } + } + } } diff --git a/module/systemRegistration/socket.mjs b/module/systemRegistration/socket.mjs index 046f1b68..82ca2e1c 100644 --- a/module/systemRegistration/socket.mjs +++ b/module/systemRegistration/socket.mjs @@ -37,7 +37,8 @@ export const GMUpdateEvent = { export const RefreshType = { Countdown: 'DhCoundownRefresh', TagTeamRoll: 'DhTagTeamRollRefresh', - EffectsDisplay: 'DhEffectsDisplayRefresh' + EffectsDisplay: 'DhEffectsDisplayRefresh', + Scene: 'DhSceneRefresh' }; export const registerSocketHooks = () => { @@ -92,6 +93,10 @@ export const registerSocketHooks = () => { } } }); + Hooks.on(socketEvent.RefreshDocument, async data => { + const document = await foundry.utils.fromUuid(data.uuid); + document.sheet.render(); + }); }; export const registerUserQueries = () => { diff --git a/styles/less/ui/index.less b/styles/less/ui/index.less index 7f9ada25..25f51d0f 100644 --- a/styles/less/ui/index.less +++ b/styles/less/ui/index.less @@ -33,3 +33,5 @@ @import './scene-config/scene-config.less'; @import './effects-display/sheet.less'; + +@import './scene-navigation/scene-navigation.less'; diff --git a/styles/less/ui/scene-config/scene-config.less b/styles/less/ui/scene-config/scene-config.less index fb36dd33..664e7526 100644 --- a/styles/less/ui/scene-config/scene-config.less +++ b/styles/less/ui/scene-config/scene-config.less @@ -37,4 +37,63 @@ .helper-text { font-style: italic; } + + .scene-environments { + display: flex; + flex-direction: column; + gap: 8px; + + .scene-environment { + display: flex; + align-items: center; + gap: 8px; + + .scene-environment-inner { + display: flex; + align-items: center; + gap: 16px; + flex: 1; + + img { + height: 36px; + } + + h5 { + margin: 0; + } + + .tags { + display: flex; + gap: 4px; + padding-bottom: 0; + + .tag { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 3px 5px; + font-size: var(--font-size-12); + font: @font-body; + + background: light-dark(@dark-15, @beige-15); + border: 1px solid light-dark(@dark, @beige); + border-radius: 3px; + } + + .label { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + font-size: var(--font-size-12); + } + } + } + + .remove-icon { + font-size: 16px; + } + } + } } diff --git a/styles/less/ui/scene-navigation/scene-navigation.less b/styles/less/ui/scene-navigation/scene-navigation.less new file mode 100644 index 00000000..6b97ddec --- /dev/null +++ b/styles/less/ui/scene-navigation/scene-navigation.less @@ -0,0 +1,36 @@ +#ui-left #ui-left-column-2 { + flex: 0 0 230px; + + .scene-navigation { + .scene-wrapper { + display: flex; + gap: 2px; + height: var(--control-size); + width: 100%; + + .scene-environment { + padding: 0; + + img { + border-radius: 4px; + } + } + } + + .scene { + justify-content: center; + align-content: center; + background: var(--control-bg-color); + border: 1px solid var(--control-border-color); + border-radius: 4px; + color: var(--control-icon-color); + pointer-events: all; + transition: + border 0.25s, + color 0.25s; + text-shadow: none; + width: 200px; + max-width: 200px; + } + } +} diff --git a/templates/scene/dh-config.hbs b/templates/scene/dh-config.hbs index 1f7dcd81..017613ee 100644 --- a/templates/scene/dh-config.hbs +++ b/templates/scene/dh-config.hbs @@ -21,4 +21,39 @@ {{localize "DAGGERHEART.SETTINGS.Scene.disabledText"}} {{/if}} + +
+ + {{localize "DAGGERHEART.SETTINGS.Scene.sceneEnvironments"}} + + +
+ {{#each data.sceneEnvironments as |environment index|}} +
+ {{#if environment}} +
+ +
{{environment.name}}
+
+
+ + {{localize (concat 'DAGGERHEART.GENERAL.Tiers.' environment.system.tier)}} + +
+ {{#if environment.system.type}} +
+ + {{localize (concat 'DAGGERHEART.CONFIG.EnvironmentType.' environment.system.type '.label')}} + +
+ {{/if}} +
+
+ {{/if}} + +
+ {{/each}} + {{localize "DAGGERHEART.SETTINGS.Scene.dragEnvironmentHere"}} +
+
\ No newline at end of file diff --git a/templates/ui/sceneNavigation/scene-navigation.hbs b/templates/ui/sceneNavigation/scene-navigation.hbs new file mode 100644 index 00000000..41e9e3e8 --- /dev/null +++ b/templates/ui/sceneNavigation/scene-navigation.hbs @@ -0,0 +1,36 @@ + From 883aaeec021498403cb9fa962325e812db2fc531 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:16:19 +0100 Subject: [PATCH 4/4] [Feature] Armor/Weapon Features In Description (#1521) * ItemFeatures are now prepended to the description * . * Better separation of concerns * . * . --- lang/en.json | 1 - .../sheets/api/application-mixin.mjs | 18 +++++++------ module/applications/sheets/api/base-item.mjs | 8 +----- module/data/item/armor.mjs | 15 +++++++++++ module/data/item/base.mjs | 27 +++++++++++++++++++ module/data/item/weapon.mjs | 15 +++++++++++ module/documents/tooltipManager.mjs | 7 +++-- styles/less/ux/tooltip/tooltip.less | 3 +-- templates/sheets/items/armor/description.hbs | 5 ++++ templates/sheets/items/weapon/description.hbs | 5 ++++ 10 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 templates/sheets/items/armor/description.hbs create mode 100644 templates/sheets/items/weapon/description.hbs diff --git a/lang/en.json b/lang/en.json index c32ffa4e..6060a141 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2081,7 +2081,6 @@ "partyMembers": "Party Members", "projects": "Projects", "types": "Types", - "itemFeatures": "Item Features", "questions": "Questions", "configuration": "Configuration", "base": "Base", diff --git a/module/applications/sheets/api/application-mixin.mjs b/module/applications/sheets/api/application-mixin.mjs index d25a1a4e..903caa2a 100644 --- a/module/applications/sheets/api/application-mixin.mjs +++ b/module/applications/sheets/api/application-mixin.mjs @@ -211,7 +211,7 @@ export default function DHApplicationMixin(Base) { const step = event.key === 'ArrowUp' ? 1 : event.key === 'ArrowDown' ? -1 : 0; if (step !== 0) { handleUpdate(step); - deltaInput.dispatchEvent(new Event("change", { bubbles: true })); + deltaInput.dispatchEvent(new Event('change', { bubbles: true })); } }); @@ -222,7 +222,7 @@ export default function DHApplicationMixin(Base) { if (deltaInput === document.activeElement) { event.preventDefault(); handleUpdate(Math.sign(-1 * event.deltaY)); - deltaInput.dispatchEvent(new Event("change", { bubbles: true })); + deltaInput.dispatchEvent(new Event('change', { bubbles: true })); } }, { passive: false } @@ -236,7 +236,7 @@ export default function DHApplicationMixin(Base) { // Handle contenteditable for (const input of htmlElement.querySelectorAll('[contenteditable][data-property]')) { const property = input.dataset.property; - input.addEventListener("blur", () => { + input.addEventListener('blur', () => { const selection = document.getSelection(); if (input.contains(selection.anchorNode)) { selection.empty(); @@ -244,12 +244,12 @@ export default function DHApplicationMixin(Base) { this.document.update({ [property]: input.textContent }); }); - input.addEventListener("keydown", event => { - if (event.key === "Enter") input.blur(); + input.addEventListener('keydown', event => { + if (event.key === 'Enter') input.blur(); }); // Chrome sometimes add
, which aren't a problem for the value but are for the placeholder - input.addEventListener("input", () => input.querySelectorAll("br").forEach((i) => i.remove())); + input.addEventListener('input', () => input.querySelectorAll('br').forEach(i => i.remove())); } } @@ -585,7 +585,9 @@ export default function DHApplicationMixin(Base) { if (!doc || !descriptionElement) continue; // localize the description (idk if it's still necessary) - const description = game.i18n.localize(doc.system?.description ?? doc.description); + const description = doc.system?.getEnrichedDescription + ? await doc.system.getEnrichedDescription() + : game.i18n.localize(doc.system?.description ?? doc.description); // Enrich the description and attach it; const isAction = doc.documentName === 'Action'; @@ -736,7 +738,7 @@ export default function DHApplicationMixin(Base) { }; if (inVault) data['system.inVault'] = true; if (disabled) data.disabled = true; - if (type === "domainCard" && parent?.system.domains?.length) { + if (type === 'domainCard' && parent?.system.domains?.length) { data.system.domain = parent.system.domains[0]; } diff --git a/module/applications/sheets/api/base-item.mjs b/module/applications/sheets/api/base-item.mjs index 42ed9426..e3568b23 100644 --- a/module/applications/sheets/api/base-item.mjs +++ b/module/applications/sheets/api/base-item.mjs @@ -76,16 +76,10 @@ export default class DHBaseItemSheet extends DHApplicationMixin(ItemSheetV2) { /**@inheritdoc */ async _preparePartContext(partId, context, options) { await super._preparePartContext(partId, context, options); - const { TextEditor } = foundry.applications.ux; switch (partId) { case 'description': - const value = foundry.utils.getProperty(this.document, 'system.description') ?? ''; - context.enrichedDescription = await TextEditor.enrichHTML(value, { - relativeTo: this.item, - rollData: this.item.getRollData(), - secrets: this.item.isOwner - }); + context.enrichedDescription = await this.document.system.getEnrichedDescription(); break; case 'effects': await this._prepareEffectsContext(context, options); diff --git a/module/data/item/armor.mjs b/module/data/item/armor.mjs index e35fae46..3d4a62fa 100644 --- a/module/data/item/armor.mjs +++ b/module/data/item/armor.mjs @@ -54,6 +54,21 @@ export default class DHArmor extends AttachableItem { ); } + /**@inheritdoc */ + async getDescriptionData() { + const baseDescription = this.description; + const allFeatures = CONFIG.DH.ITEM.allArmorFeatures(); + const features = this.armorFeatures.map(x => allFeatures[x.value]); + if (!features.length) return { prefix: null, value: baseDescription, suffix: null }; + + const prefix = await foundry.applications.handlebars.renderTemplate( + 'systems/daggerheart/templates/sheets/items/armor/description.hbs', + { features } + ); + + return { prefix, value: baseDescription, suffix: null }; + } + /**@inheritdoc */ async _preUpdate(changes, options, user) { const allowed = await super._preUpdate(changes, options, user); diff --git a/module/data/item/base.mjs b/module/data/item/base.mjs index 60d29792..415fc8d4 100644 --- a/module/data/item/base.mjs +++ b/module/data/item/base.mjs @@ -124,6 +124,33 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel { return [source, page ? `pg ${page}.` : null].filter(x => x).join('. '); } + /** + * Augments the description for the item with type specific info to display. Implemented in applicable item subtypes. + * @param {object} [options] - Options that modify the styling of the rendered template. { headerStyle: undefined|'none'|'large' } + * @returns {string} + */ + async getDescriptionData(_options) { + return { prefix: null, value: this.description, suffix: null }; + } + + /** + * Gets the enriched and augmented description for the item. + * @param {object} [options] - Options that modify the styling of the rendered template. { headerStyle: undefined|'none'|'large' } + * @returns {string} + */ + async getEnrichedDescription() { + if (!this.metadata.hasDescription) return ''; + + const { prefix, value, suffix } = await this.getDescriptionData(); + const fullDescription = [prefix, value, suffix].filter(p => !!p).join('\n
\n'); + + return await foundry.applications.ux.TextEditor.implementation.enrichHTML(fullDescription, { + relativeTo: this, + rollData: this.getRollData(), + secrets: this.isOwner + }); + } + /** * Obtain a data object used to evaluate any dice rolls associated with this Item Type * @param {object} [options] - Options which modify the getRollData method. diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 295cc0c5..f333e5f3 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -110,6 +110,21 @@ export default class DHWeapon extends AttachableItem { ); } + /**@inheritdoc */ + async getDescriptionData() { + const baseDescription = this.description; + const allFeatures = CONFIG.DH.ITEM.allWeaponFeatures(); + const features = this.weaponFeatures.map(x => allFeatures[x.value]); + if (!features.length) return { prefix: null, value: baseDescription, suffix: null }; + + const prefix = await foundry.applications.handlebars.renderTemplate( + 'systems/daggerheart/templates/sheets/items/weapon/description.hbs', + { features } + ); + + return { prefix, value: baseDescription, suffix: null }; + } + prepareDerivedData() { this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait; } diff --git a/module/documents/tooltipManager.mjs b/module/documents/tooltipManager.mjs index 3ea6703c..dac5aea3 100644 --- a/module/documents/tooltipManager.mjs +++ b/module/documents/tooltipManager.mjs @@ -220,12 +220,15 @@ export default class DhTooltipManager extends foundry.helpers.interaction.Toolti for (const [index, itemValue] of pathValue.entries()) { const itemIsAction = itemValue instanceof game.system.api.models.actions.actionsTypes.base; const value = itemIsAction || !itemValue?.item ? itemValue : itemValue.item; - const enrichedValue = await TextEditor.enrichHTML(value.system?.description ?? value.description); + const enrichedValue = + (await value.system?.getEnrichedDescription?.()) ?? + (await TextEditor.enrichHTML(value.system?.description ?? value.description)); if (itemIsAction) value.enrichedDescription = enrichedValue; else foundry.utils.setProperty(item, `${basePath}.${index}.enrichedDescription`, enrichedValue); } } else { - const enrichedValue = await TextEditor.enrichHTML(pathValue); + const enrichedValue = + (await item.system?.getEnrichedDescription?.()) ?? (await TextEditor.enrichHTML(pathValue)); foundry.utils.setProperty( item, `${data.path ? `${data.path}.` : ''}enriched${data.name.capitalize()}`, diff --git a/styles/less/ux/tooltip/tooltip.less b/styles/less/ux/tooltip/tooltip.less index bfe0c01f..0f632772 100644 --- a/styles/less/ux/tooltip/tooltip.less +++ b/styles/less/ux/tooltip/tooltip.less @@ -11,7 +11,7 @@ aside[role='tooltip']:has(div.daggerheart.dh-style.tooltip.card-style) { width: 18rem; background-image: url('../assets/parchments/dh-parchment-dark.png'); outline: 1px solid light-dark(@dark-80, @beige-80); - box-shadow: 0 0 25px rgba(0, 0, 0, 0.80); + box-shadow: 0 0 25px rgba(0, 0, 0, 0.8); .tooltip-title { font-size: var(--font-size-20); @@ -235,7 +235,6 @@ aside[role='tooltip'].locked-tooltip:has(div.daggerheart.dh-style.tooltip.card-s .theme-light aside[role='tooltip'].locked-tooltip:has(div.daggerheart.dh-style.tooltip) { box-shadow: 0 0 25px @dark-blue-90; outline: 1px solid light-dark(@dark-blue, @golden); - } #tooltip, diff --git a/templates/sheets/items/armor/description.hbs b/templates/sheets/items/armor/description.hbs new file mode 100644 index 00000000..c5a9924e --- /dev/null +++ b/templates/sheets/items/armor/description.hbs @@ -0,0 +1,5 @@ +
+ {{#each features as | feature |}} +
{{localize feature.label}}: {{{localize feature.description}}}
+ {{/each}} +
\ No newline at end of file diff --git a/templates/sheets/items/weapon/description.hbs b/templates/sheets/items/weapon/description.hbs new file mode 100644 index 00000000..c5a9924e --- /dev/null +++ b/templates/sheets/items/weapon/description.hbs @@ -0,0 +1,5 @@ +
+ {{#each features as | feature |}} +
{{localize feature.label}}: {{{localize feature.description}}}
+ {{/each}} +
\ No newline at end of file