From 3efdcb6c9a92b8f5f861be4dc81f7405abc68e2e Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Sun, 19 Jul 2026 05:35:55 -0400 Subject: [PATCH 1/4] Allow deleting countdowns via right click context menu on the main panel (#2090) --- daggerheart.d.ts | 13 +++++ module/applications/ui/countdownEdit.mjs | 1 - module/applications/ui/countdowns.mjs | 66 +++++++++++++----------- module/config/settingsConfig.mjs | 3 ++ module/config/system.mjs | 1 + module/data/_types.d.ts | 7 +++ module/data/countdowns.mjs | 63 +++++++++++++++++++--- 7 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 module/data/_types.d.ts diff --git a/daggerheart.d.ts b/daggerheart.d.ts index 891a3a2a..02abf063 100644 --- a/daggerheart.d.ts +++ b/daggerheart.d.ts @@ -11,6 +11,9 @@ import * as documents from './module/documents/_module.mjs'; import { macros } from './module/_module.mjs'; import * as dice from './module/dice/_module.mjs'; import * as fields from './module/data/fields/_module.mjs'; +import { gameSettings } from './module/config/settingsConfig.mjs'; +import DhCountdowns from './module/data/countdowns.mjs'; +import DhAutomation from './module/data/settings/Automation.mjs'; // Foundry's use of `Object.assign(globalThis) means many globally available objects are not read as such @@ -103,3 +106,13 @@ declare module '@client/packages/system.mjs' { }; } } + +declare module '@client/helpers/client-settings.mjs' { + // Add explicit typed overrides for auto complete. These require /** @type {"string"} on the vars themselves to work */ + export default interface ClientSettings { + get(namespace: 'daggerheart', key: typeof gameSettings.Automation): DhAutomation; + get(namespace: 'daggerheart', key: typeof gameSettings.Homebrew): DhHomebrew; + get(namespace: 'daggerheart', key: typeof gameSettings.Countdowns): DhCountdowns; + get(namespace: 'daggerheart', key: string): unknown; + } +} \ No newline at end of file diff --git a/module/applications/ui/countdownEdit.mjs b/module/applications/ui/countdownEdit.mjs index 1dbc56be..af1af46b 100644 --- a/module/applications/ui/countdownEdit.mjs +++ b/module/applications/ui/countdownEdit.mjs @@ -153,7 +153,6 @@ export default class CountdownEdit extends HandlebarsApplicationMixin(Applicatio action: socketEvent.Refresh, data: { refreshType: RefreshType.Countdown } }); - Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.Countdown }); } static #addCountdown() { diff --git a/module/applications/ui/countdowns.mjs b/module/applications/ui/countdowns.mjs index 5cf79100..996a46ea 100644 --- a/module/applications/ui/countdowns.mjs +++ b/module/applications/ui/countdowns.mjs @@ -12,14 +12,11 @@ const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; export default class DhCountdowns extends HandlebarsApplicationMixin(ApplicationV2) { previousCountdownData = null; - changedCountdownsForAnimation = new Set(); constructor(options = {}) { super(options); - this.previousCountdownData = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns).countdowns; - this.setupHooks(); } /** @inheritDoc */ @@ -80,7 +77,15 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application return frame; } + /** @inheritdoc */ + async _onFirstRender(context, options) { + await super._onFirstRender(context, options); + this._createContextMenu(this._getCountdownContextOptions, '.countdown-container[data-countdown]', { + parentClassHooks: false, fixed: true + }); + } + /** @inheritdoc */ async _onRender(context, options) { await super._onRender(context, options); @@ -95,7 +100,7 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application /* Handle animations to draw attention to countdown values changing */ const typesToAnimate = new Set(); - for (const countdownKey of this.changedCountdownsForAnimation) { + for (const countdownKey of options.animate ?? []) { const shimmerAnimation = [ { backgroundPositionX: '98%' }, { backgroundPositionX: '0%' } @@ -127,8 +132,6 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application const element = this.element.querySelector(`.header-type-toggles .header-type[data-type="${type}"]`); element?.animate(pulseAnimation, pulseTiming); } - - this.changedCountdownsForAnimation.clear(); } /** Returns countdown data filtered by ownership */ @@ -137,17 +140,15 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application const values = Object.entries(setting.countdowns).map(([key, countdown]) => ({ key, countdown, - ownership: DhCountdowns.#getPlayerOwnership(game.user, setting, countdown) + ownership: countdown.getUserLevel(game.user) })); return values.filter(v => v.ownership !== CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE); } _getCountdownData() { - const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); - return this.#getCountdowns().reduce((acc, { key, countdown, ownership }) => { const playersWithAccess = game.users.reduce((acc, user) => { - const ownership = DhCountdowns.#getPlayerOwnership(user, setting, countdown); + const ownership = countdown.getUserLevel(user); if (!user.isGM && ownership && ownership !== CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE) { acc.push(user); } @@ -213,19 +214,6 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application return context; } - static #getPlayerOwnership(user, setting, countdown) { - if (user.isGM) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; - - const playerOwnership = countdown.ownership[user.id]; - return playerOwnership === undefined || playerOwnership === CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT - ? setting.defaultOwnership - : playerOwnership; - } - - cooldownRefresh = ({ refreshType }) => { - if (refreshType === RefreshType.Countdown) this.render(); - }; - static canPerformEdit() { if (game.user.isGM) return true; @@ -323,18 +311,12 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application action: socketEvent.Refresh, data: { refreshType: RefreshType.Countdown } }); - Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.Countdown }); - } - - setupHooks() { - Hooks.on(socketEvent.Refresh, this.cooldownRefresh.bind()); } async close(options) { /* Opt out of Foundry's standard behavior of closing all application windows marked as UI when Escape is pressed */ if (options.closeKey) return; - Hooks.off(socketEvent.Refresh, this.cooldownRefresh); return super.close(options); } @@ -377,4 +359,30 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application refreshType: RefreshType.Countdown }); } + + /** + * @returns {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]} + */ + _getCountdownContextOptions() { + /** @param {HTMLElement} element */ + const getCountdownFromElement = element => { + const id = element.closest('[data-countdown]').dataset.countdown; + if (!id) return null; + const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); + return setting.countdowns[id ?? '']; + } + + return [ + { + label: 'CONTROLS.CommonDelete', + icon: 'fa-solid fa-trash', + visible: element => { + return getCountdownFromElement(element)?.isOwner; + }, + onClick: (_, target) => { + getCountdownFromElement(target)?.delete(); + } + } + ]; + } } diff --git a/module/config/settingsConfig.mjs b/module/config/settingsConfig.mjs index 50841084..7aa1b0f7 100644 --- a/module/config/settingsConfig.mjs +++ b/module/config/settingsConfig.mjs @@ -27,8 +27,10 @@ export const menu = { }; export const gameSettings = { + /** @type {'Automation'} */ Automation: 'Automation', Metagaming: 'Metagaming', + /** @type {'Homebrew'} */ Homebrew: 'Homebrew', appearance: 'Appearance', GlobalOverrides: 'GlobalOverrides', @@ -37,6 +39,7 @@ export const gameSettings = { Fear: 'ResourcesFear' }, LevelTiers: 'LevelTiers', + /** @type {'Countdowns'} */ Countdowns: 'Countdowns', LastMigrationVersion: 'LastMigrationVersion', SpotlightRequestQueue: 'SpotlightRequestQueue', diff --git a/module/config/system.mjs b/module/config/system.mjs index 31dba518..de6630ec 100644 --- a/module/config/system.mjs +++ b/module/config/system.mjs @@ -12,6 +12,7 @@ import * as HOOKS from './hooksConfig.mjs'; import * as TRIGGER from './triggerConfig.mjs'; import * as ITEMBROWSER from './itemBrowserConfig.mjs'; +/** @type {"daggerheart"} */ export const SYSTEM_ID = 'daggerheart'; export const SYSTEM = { diff --git a/module/data/_types.d.ts b/module/data/_types.d.ts new file mode 100644 index 00000000..f0e2ade5 --- /dev/null +++ b/module/data/_types.d.ts @@ -0,0 +1,7 @@ +import { DhCountdown } from './countdowns.mjs' + +declare module './countdowns.mjs' { + export default interface DhCountdowns { + countdowns: Record; + } +} \ No newline at end of file diff --git a/module/data/countdowns.mjs b/module/data/countdowns.mjs index ffe4d26b..5fe01e20 100644 --- a/module/data/countdowns.mjs +++ b/module/data/countdowns.mjs @@ -1,3 +1,4 @@ +import { RefreshType, socketEvent } from '../systemRegistration/socket.mjs'; import FormulaField from './fields/formulaField.mjs'; export default class DhCountdowns extends foundry.abstract.DataModel { @@ -14,19 +15,36 @@ export default class DhCountdowns extends foundry.abstract.DataModel { }; } - handleChange() { + /** @inheritdoc */ + _initialize(options) { + super._initialize(options); + for (const [id, countdown] of Object.entries(this.countdowns)) { + countdown.id = id; + } + } + + async handleChange() { const previousCountdowns = foundry.ui.countdowns.previousCountdownData; const changedCountdowns = Object.entries(this.countdowns).reduce((acc, [key, countdown]) => { - const previousCountdown = previousCountdowns[key]; - if (!previousCountdown || (previousCountdown.progress.current !== countdown.progress.current)) { + const previous = previousCountdowns[key]; + const currentChanged = !previous || (previous.progress.current !== countdown.progress.current); + if (currentChanged && previous?.progress.start === countdown.progress.start) { acc.push(key); } - return acc; }, []); - for (const countdownKey of changedCountdowns) - foundry.ui.countdowns.changedCountdownsForAnimation.add(countdownKey); + // Re-render countdowns applications. When the change is due to an actual update, resync the editor + if (!foundry.utils.equals(previousCountdowns, this.countdowns)) { + await foundry.ui.countdowns.render({ animate: changedCountdowns }); + for (const instance of game.system.api.applications.ui.CountdownEdit.instances()) { + instance.data = this; + await instance.render(); + } + } + + // Inform modules of updates + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.Countdown }); } static migrateData(source) { @@ -160,6 +178,14 @@ export class DhCountdown extends foundry.abstract.DataModel { }, {}); } + /** + * A boolean indicator for whether the current game User has ownership rights for this countdown + * @returns {boolean} + */ + get isOwner() { + return this.getUserLevel(game.user) === CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; + } + /** @inheritDoc */ static migrateData(source) { if (source.progress.max) { @@ -170,4 +196,29 @@ export class DhCountdown extends foundry.abstract.DataModel { return super.migrateData(source); } + + /** + * Get the explicit permission level that a User has over this Document, a value in CONST.DOCUMENT_OWNERSHIP_LEVELS. + * Compendium content ignores the ownership field in favor of User role-based ownership. Otherwise, Documents use + * granular per-User ownership definitions and Embedded Documents defer to their parent ownership. + * + * @param {BaseUser} [user=game.user] The User being tested + * @returns {DocumentOwnershipNumber} A numeric permission level from {@link CONST.DOCUMENT_OWNERSHIP_LEVELS} + */ + getUserLevel(user) { + if (user.isGM) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; + + const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); + const playerOwnership = this.ownership[user.id]; + return playerOwnership === undefined || playerOwnership === CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT + ? setting.defaultOwnership + : playerOwnership; + } + + async delete() { + const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); + const data = foundry.utils.deepClone(setting._source); + delete data.countdowns[this.id]; + await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns, data); + } } From 8d68166e4c643c26bc82882fd50773b4caf19e18 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Sun, 19 Jul 2026 12:42:16 +0200 Subject: [PATCH 2/4] Raised foundry compatability to 14.365 --- system.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system.json b/system.json index 214ab2ee..0aafe3f5 100644 --- a/system.json +++ b/system.json @@ -5,7 +5,7 @@ "version": "2.5.4", "compatibility": { "minimum": "14.364", - "verified": "14.364", + "verified": "14.365", "maximum": "14" }, "url": "https://github.com/Foundryborne/daggerheart", From 4a25ffb456ece26de6d12558fb1dbd29da4df07e Mon Sep 17 00:00:00 2001 From: WBHarry Date: Mon, 20 Jul 2026 11:10:04 +0200 Subject: [PATCH 3/4] Fixed so that compoundCombo denomination increases are used for all subsequent results --- module/dice/die/baseDie.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/module/dice/die/baseDie.mjs b/module/dice/die/baseDie.mjs index 26ed6d7f..30cb5b45 100644 --- a/module/dice/die/baseDie.mjs +++ b/module/dice/die/baseDie.mjs @@ -112,15 +112,19 @@ export default class BaseDie extends foundry.dice.terms.Die { async continueCombo(maxIncreasesDiceSize) { const activeResults = this.results.filter(x => x.active); const lastIndex = activeResults.length - 1; + const lastResult = activeResults[lastIndex]; /* The Combo only continues if the latest roll was higher or equal to the previous */ - if (activeResults[lastIndex].result < activeResults[lastIndex - 1].result) return false; + if (lastResult.result < activeResults[lastIndex - 1].result) return false; - const lastFaces = activeResults[lastIndex].denomination?.slice(1) ?? this.faces; + const lastFaces = lastResult.denomination ? + Number(lastResult.denomination.slice(1)) : this.faces; + const currentDenomination = `d${lastFaces}` const increaseDiceSize = maxIncreasesDiceSize && lastFaces < 12 && - activeResults[lastIndex].result === lastFaces; - const denomination = increaseDiceSize ? adjustDice(this.denomination, false) : this.denomination; + lastResult.result === lastFaces; + + const denomination = increaseDiceSize ? adjustDice(currentDenomination, false) : currentDenomination; const newRoll = await (new Roll(`1${denomination}`)).evaluate(); this.results.push({ result: newRoll.total, denomination: denomination, active: true }); From a5c8e79d722863a4790e15eb6346a26451e6e00d Mon Sep 17 00:00:00 2001 From: WBHarry Date: Mon, 20 Jul 2026 12:26:13 +0200 Subject: [PATCH 4/4] Added reroll handling for compound size changes --- module/dice/die/baseDie.mjs | 43 +++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/module/dice/die/baseDie.mjs b/module/dice/die/baseDie.mjs index 30cb5b45..4c684dd3 100644 --- a/module/dice/die/baseDie.mjs +++ b/module/dice/die/baseDie.mjs @@ -19,7 +19,7 @@ export default class BaseDie extends foundry.dice.terms.Die { this.results.splice(resultIndex, 0, rerolledResult); if (['c', 'cc'].some(x => this.modifiers.includes(x))) { - await this.handleComboDiceReroll(resultIndex); + await this.handleComboDiceReroll(resultIndex, result); } } @@ -66,7 +66,7 @@ export default class BaseDie extends foundry.dice.terms.Die { async rollComboDice(options) { const { maxIncreasesDiceSize, rerollStartIndex } = options; - const initialResultsLength = this.results.length; + const initialResultsLength = this.results.filter(x => x.active).length; const result = await this.continueCombo(maxIncreasesDiceSize); /* The flow of DiceSoNice has no way of knowing that some of the results of a Die should be a different denomination @@ -133,8 +133,9 @@ export default class BaseDie extends foundry.dice.terms.Die { return this.continueCombo(maxIncreasesDiceSize); } - async handleComboDiceReroll(rerolledIndex) { - const resultGroupingIndexes = this.results.map((x, index) => ({ index, active: x.active })) + async handleComboDiceReroll(rerolledIndex, originalResult) { + /* Potentially sliced when correcting compound dice at (1) */ + let resultGroupingIndexes = this.results.map((x, index) => ({ index, active: x.active })) .filter(x => x.active).map(x => x.index); if (resultGroupingIndexes.length <= 1) return; @@ -147,7 +148,29 @@ export default class BaseDie extends foundry.dice.terms.Die { const nextIndex = resultGroupingIndexes[rerollGroupingIndex + 1]; const nextResult = this.results[nextIndex]; - /* Rerolling any of the last two dice might introduce new results */ + const dropDice = preceeding => { + const cutoffIndex = preceeding ? resultGroupingIndexes[rerollGroupingIndex + 1] + 1 : rerolledIndex + 1; + this.results = this.results.slice(0, cutoffIndex); + this.number = this.results.filter(x => x.active).length; + }; + + /* (1) If subsequent size increases from the compound effect are now incorrect, drop subsequent dice */ + const lastFaces = Number((originalResult.denomination ?? this.denomination).slice(1)); + if ( + compoundCombo && + ( + (originalResult.result === lastFaces && + rerolledResult.result !== lastFaces) + || + (originalResult.result !== lastFaces && + rerolledResult.result === lastFaces) + ) + ) { + dropDice(false); + resultGroupingIndexes = resultGroupingIndexes.slice(0, this.number); + } + + /* (2) Rerolling any of the last two dice might introduce new results */ if ( rerollGroupingIndex === resultGroupingIndexes.length - 1 && rerolledResult.result >= previousResult?.result @@ -165,11 +188,13 @@ export default class BaseDie extends foundry.dice.terms.Die { rerollStartIndex: rerollGroupingIndex }); } - /* Rerolling a preceeding dice might invalidate later dice which should then be dropped */ + /* (3) Rerolling a subsequent dice might invalidate later dice which should then be dropped */ else if (rerolledResult.result < previousResult?.result){ - const cutoffIndex = rerollGroupingIndex === 0 ? resultGroupingIndexes[1] + 1 : rerolledIndex + 1; - this.results = this.results.slice(0, cutoffIndex); - this.number = this.results.filter(x => x.active).length; + dropDice(false); + } + /* (4) Rerolling a preceeding dice might invalidate later dice which should then be dropped */ + else if (rerolledResult.result >= nextResult?.result) { + dropDice(true); } const fakeRollFaces =