From 98cf6fa6de1d2bb61f0a9b9c4a4d2df385120b70 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:13:26 +0100 Subject: [PATCH 01/95] [Feature] Character Creation Confirmations (#1533) * Added confirmation on ignoring character setup. Added reset option to character sheet. * Removed the system setting for playerCanEdit. It's always available now. --- lang/en.json | 11 ++-- .../applications/sheets/actors/character.mjs | 51 ++++++++++++++++--- module/data/settings/Automation.mjs | 5 -- .../settings/automation-settings/general.hbs | 1 - templates/sheets/actors/character/header.hbs | 6 +-- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/lang/en.json b/lang/en.json index 8e64ab7d..870e1b2b 100755 --- a/lang/en.json +++ b/lang/en.json @@ -237,10 +237,13 @@ "confirmText": "Would you like to level up your companion {name} by {levelChange} levels at this time? (You can do it manually later)" }, "viewLevelups": "View Levelups", + "resetCharacter": "Reset Character", "viewParty": "View Party", "InvalidOldCharacterImportTitle": "Old Character Import", "InvalidOldCharacterImportText": "Character data exported prior to system version 1.1 will not generate a complete character. Do you wish to continue?", - "cancelBeastform": "Cancel Beastform" + "cancelBeastform": "Cancel Beastform", + "resetCharacterConfirmationTitle": "Reset Character", + "resetCharacterConfirmationContent": "You are reseting all character data except name and portrait. Are you sure?" }, "Companion": { "FIELDS": { @@ -314,6 +317,8 @@ "selectPrimaryWeapon": "Select Primary Weapon", "selectSecondaryWeapon": "Select Secondary Weapon", "selectSubclass": "Select Subclass", + "setupSkipTitle": "Skipping Character Setup", + "setupSkipContent": "You are skipping the Character Setup by adding this manually. The character setup is the blinking arrows in the top-right. Are you sure you want to continue?", "startingItems": "Starting Items", "story": "Story", "storyExplanation": "Select which background and connection prompts you want to copy into your character's background.", @@ -2450,10 +2455,6 @@ "label": "Show Resource Change Scrolltexts", "hint": "When a character is damaged, uses armor etc, a scrolling text will briefly appear by the token to signify this." }, - "playerCanEditSheet": { - "label": "Players Can Manually Edit Character Settings", - "hint": "Players are allowed to access the manual Character Settings and change their statistics beyond the rules." - }, "roll": { "roll": { "label": "Roll", diff --git a/module/applications/sheets/actors/character.mjs b/module/applications/sheets/actors/character.mjs index e11fee05..d691c129 100644 --- a/module/applications/sheets/actors/character.mjs +++ b/module/applications/sheets/actors/character.mjs @@ -27,6 +27,7 @@ export default class CharacterSheet extends DHBaseActorSheet { makeDeathMove: CharacterSheet.#makeDeathMove, levelManagement: CharacterSheet.#levelManagement, viewLevelups: CharacterSheet.#viewLevelups, + resetCharacter: CharacterSheet.#resetCharacter, toggleEquipItem: CharacterSheet.#toggleEquipItem, toggleResourceDice: CharacterSheet.#toggleResourceDice, handleResourceDice: CharacterSheet.#handleResourceDice, @@ -42,6 +43,11 @@ export default class CharacterSheet extends DHBaseActorSheet { icon: 'fa-solid fa-angles-up', label: 'DAGGERHEART.ACTORS.Character.viewLevelups', action: 'viewLevelups' + }, + { + icon: 'fa-solid fa-arrow-rotate-left', + label: 'DAGGERHEART.ACTORS.Character.resetCharacter', + action: 'resetCharacter' } ] }, @@ -220,13 +226,6 @@ export default class CharacterSheet extends DHBaseActorSheet { async _preparePartContext(partId, context, options) { context = await super._preparePartContext(partId, context, options); switch (partId) { - case 'header': - const { playerCanEditSheet, levelupAuto } = game.settings.get( - CONFIG.DH.id, - CONFIG.DH.SETTINGS.gameSettings.Automation - ); - context.showSettings = game.user.isGM || !levelupAuto || (levelupAuto && playerCanEditSheet); - break; case 'loadout': await this._prepareLoadoutContext(context, options); break; @@ -666,6 +665,32 @@ export default class CharacterSheet extends DHBaseActorSheet { new LevelupViewMode(this.document).render({ force: true }); } + /** + * Resets the character data and removes all embedded documents. + */ + static async #resetCharacter() { + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { + title: game.i18n.localize('DAGGERHEART.ACTORS.Character.resetCharacterConfirmationTitle') + }, + content: game.i18n.localize('DAGGERHEART.ACTORS.Character.resetCharacterConfirmationContent') + }); + + if (!confirmed) return; + + await this.document.update({ + '==system': {} + }); + await this.document.deleteEmbeddedDocuments( + 'Item', + this.document.items.map(x => x.id) + ); + await this.document.deleteEmbeddedDocuments( + 'ActiveEffect', + this.document.effects.map(x => x.id) + ); + } + /** * Opens the Death Move interface for the character. * @type {ApplicationClickAction} @@ -956,6 +981,18 @@ export default class CharacterSheet extends DHBaseActorSheet { } async _onDropItem(event, item) { + const setupCriticalItemTypes = ['class', 'subclass', 'ancestry', 'community']; + if (this.document.system.needsCharacterSetup && setupCriticalItemTypes.includes(item.type)) { + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { + title: game.i18n.localize('DAGGERHEART.APPLICATIONS.CharacterCreation.setupSkipTitle') + }, + content: game.i18n.localize('DAGGERHEART.APPLICATIONS.CharacterCreation.setupSkipContent') + }); + + if (!confirmed) return; + } + if (this.document.uuid === item.parent?.uuid) { return super._onDropItem(event, item); } diff --git a/module/data/settings/Automation.mjs b/module/data/settings/Automation.mjs index 3376b153..bff0bae9 100644 --- a/module/data/settings/Automation.mjs +++ b/module/data/settings/Automation.mjs @@ -55,11 +55,6 @@ export default class DhAutomation extends foundry.abstract.DataModel { initial: true, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.resourceScrollTexts.label' }), - playerCanEditSheet: new fields.BooleanField({ - required: true, - initial: false, - label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.playerCanEditSheet.label' - }), defeated: new fields.SchemaField({ enabled: new fields.BooleanField({ required: true, diff --git a/templates/settings/automation-settings/general.hbs b/templates/settings/automation-settings/general.hbs index d49ef9b8..bd91b2b1 100644 --- a/templates/settings/automation-settings/general.hbs +++ b/templates/settings/automation-settings/general.hbs @@ -18,7 +18,6 @@ {{formGroup settingFields.schema.fields.hordeDamage value=settingFields._source.hordeDamage localize=true}} {{formGroup settingFields.schema.fields.effects.fields.rangeDependent value=settingFields._source.effects.rangeDependent localize=true}} {{formGroup settingFields.schema.fields.levelupAuto value=settingFields._source.levelupAuto localize=true}} - {{formGroup settingFields.schema.fields.playerCanEditSheet value=settingFields._source.playerCanEditSheet localize=true}} {{formGroup settingFields.schema.fields.damageReductionRulesDefault value=settingFields._source.damageReductionRulesDefault localize=true}} {{formGroup settingFields.schema.fields.resourceScrollTexts value=settingFields._source.resourceScrollTexts localize=true}} diff --git a/templates/sheets/actors/character/header.hbs b/templates/sheets/actors/character/header.hbs index 1459e10b..87319dbb 100644 --- a/templates/sheets/actors/character/header.hbs +++ b/templates/sheets/actors/character/header.hbs @@ -123,9 +123,7 @@ {{/each}} - {{#> 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' showSettings=showSettings }} - {{#if ../showSettings}} - - {{/if}} + {{#> 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' }} + {{/'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs'}} \ No newline at end of file From 4ce8fbb84c7f79892aa1c8fc39de40aa537f5590 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Sat, 17 Jan 2026 01:42:40 +0100 Subject: [PATCH 02/95] Raised version --- system.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system.json b/system.json index 8b6081a4..2c68f785 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.5.3", + "version": "1.5.4", "compatibility": { "minimum": "13.346", "verified": "13.351", From 9d75157e1711fa921fef0c2e2dcf8fcc0e39380c Mon Sep 17 00:00:00 2001 From: Chris Ryan <73275196+chrisryan10@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:11:50 +1000 Subject: [PATCH 03/95] [Feature] Death moves and Fate rolls (#1463) * Update the death move descriptions * Renamed to DhDeathMove * Partial Fate Roll creation and Fate Roll Enricher (/fr) * Hide stuff not required for fate roll * Hide formula display; code removal; start to add Fear die as a choice for Fate roll * Fix chat message display; start moving towards supporting Hope and Fear for Fate roll * /fr now supports type=X, where X is Hope or Fear, if not supplied, defaults to Hope * Fixed DSN rolling; removed console messages; chat message clean up * Add localisation entry * Trying to sort out the button for the fate roll * Style the fate message based on Hope/Fear colors. * Partial improvement on the fate template buttons - chat display is correct, but the roll dialog is wrong * Fixed enricher button; localization fixes; debug cleanup * Error checking for the fate type parsing in all potential problem locations * Added localization for the fate type parsing error * Start on Avoid Death death move * debug stuff * More death moves setup/testing * Avoid fate scars update in place, with scars migrating to an integer value. * Remove some debug code; add Blaze Of Glory shell * Start on Guaranteed Critical for Blaze of Glory * Partial implementation of Blaze of Glory * Dice/critical checks/tests * Moved detection of guaranteed critical to before the roll dialog is created, so it can be skipped; removed debug code * Remove debug * Update Blaze of Glory effect description * Risk It All - critical roll - clear all stress and HP * Auto remove all marked stress and HP for Risk It All, if Hope value rolled covers it. * Display the Death Move description in chat expanded if the appropriate config setting is on * Made the Blaze of Glory ActiveEffect image use configured version * Update the current Hope value if the scar value change affects it * Scars management in the Character details editor * Separate less file for the Death Moves instead of reusing Downtime * Added result messages to the Death Move chat output and removed debug statements * Some localization, style and smaller changes * Fixed RiskItAll resource handling method * Risk It All success chat message start * [Add] Hope/Scar Interplay (#1531) * Migrated character.maxHope to homebrew settings * Added a visual for scars * . * . * Pass the hope value in the button data; skeleton risk it all dialog to fill out. * Start on risk it dialog * More dialog stuff * Remove non-existent field * Dialog templating and logic * . * Ensure effect is Applied to Actor (#1547) Co-authored-by: Chris Ryan * [Fix] 1548 - Standalone Item Add Actions (#1549) * Fixed so that items not on an actor don't error out on creating actions * Fixed deletion of items error * Raised version * Fix the sliders to do the correct maximums * Pass the actor id through the button; fix /dr and /fr flavor text * Remove debug message --------- Co-authored-by: Chris Ryan Co-authored-by: WBHarry Co-authored-by: WBHarry <89362246+WBHarry@users.noreply.github.com> --- daggerheart.mjs | 40 +++- lang/en.json | 35 +++- module/applications/dialogs/_module.mjs | 1 + module/applications/dialogs/d20RollDialog.mjs | 2 +- module/applications/dialogs/deathMove.mjs | 148 +++++++++++++- .../applications/dialogs/riskItAllDialog.mjs | 94 +++++++++ .../applications/sheets/actors/character.mjs | 9 +- module/applications/ui/chatLog.mjs | 17 +- module/data/action/baseAction.mjs | 6 +- module/data/actor/character.mjs | 44 ++++- module/data/chat-message/_modules.mjs | 1 + module/data/settings/Homebrew.mjs | 7 + module/dice/_module.mjs | 1 + module/dice/dualityRoll.mjs | 47 +++-- module/dice/fateRoll.mjs | 85 +++++++++ module/documents/actor.mjs | 14 +- module/documents/chatMessage.mjs | 9 + module/enrichers/DualityRollEnricher.mjs | 8 +- module/enrichers/FateRollEnricher.mjs | 80 ++++++++ module/enrichers/_module.mjs | 11 +- module/helpers/utils.mjs | 22 ++- .../death-move/death-move-container.less | 111 +++++------ styles/less/dialog/index.less | 4 +- styles/less/dialog/risk-it-all/sheet.less | 60 ++++++ styles/less/global/enrichment.less | 1 + .../less/sheets/actors/character/header.less | 5 + styles/less/ui/chat/chat.less | 26 ++- styles/less/ui/chat/deathmoves.less | 152 +++++++++++++++ styles/less/ui/index.less | 1 + system.json | 1 + templates/dialogs/dice-roll/rollSelection.hbs | 180 +++++++++++------- templates/dialogs/riskItAllDialog.hbs | 39 ++++ .../settings/homebrew-settings/settings.hbs | 1 + .../action-settings/trigger.hbs | 2 +- .../character-settings/details.hbs | 2 +- templates/sheets/actors/character/header.hbs | 5 + templates/ui/chat/deathMove.hbs | 27 ++- templates/ui/chat/parts/roll-part.hbs | 126 +++++++----- 38 files changed, 1166 insertions(+), 258 deletions(-) create mode 100644 module/applications/dialogs/riskItAllDialog.mjs create mode 100644 module/dice/fateRoll.mjs create mode 100644 module/enrichers/FateRollEnricher.mjs create mode 100644 styles/less/dialog/risk-it-all/sheet.less create mode 100644 styles/less/ui/chat/deathmoves.less create mode 100644 templates/dialogs/riskItAllDialog.hbs diff --git a/daggerheart.mjs b/daggerheart.mjs index 3abcd210..23f153dd 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -8,8 +8,9 @@ import * as fields from './module/data/fields/_module.mjs'; import RegisterHandlebarsHelpers from './module/helpers/handlebarsHelper.mjs'; import { enricherConfig, enricherRenderSetup } from './module/enrichers/_module.mjs'; import { getCommandTarget, rollCommandToJSON } from './module/helpers/utils.mjs'; -import { BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll } from './module/dice/_module.mjs'; +import { BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll, FateRoll } from './module/dice/_module.mjs'; import { enrichedDualityRoll } from './module/enrichers/DualityRollEnricher.mjs'; +import { enrichedFateRoll, getFateTypeData } from './module/enrichers/FateRollEnricher.mjs'; import { handlebarsRegistration, runMigrations, @@ -24,12 +25,13 @@ import TokenManager from './module/documents/tokenManager.mjs'; CONFIG.DH = SYSTEM; CONFIG.TextEditor.enrichers.push(...enricherConfig); -CONFIG.Dice.rolls = [BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll]; +CONFIG.Dice.rolls = [BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll, FateRoll]; CONFIG.Dice.daggerheart = { DHRoll: DHRoll, DualityRoll: DualityRoll, D20Roll: D20Roll, - DamageRoll: DamageRoll + DamageRoll: DamageRoll, + FateRoll: FateRoll }; CONFIG.Actor.documentClass = documents.DhpActor; @@ -298,8 +300,8 @@ Hooks.on('chatMessage', (_, message) => { const difficulty = rollCommand.difficulty; const target = getCommandTarget({ allowNull: true }); - const title = traitValue - ? game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', { + const title = flavor ?? + traitValue ? game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', { ability: game.i18n.localize(SYSTEM.ACTOR.abilities[traitValue].label) }) : game.i18n.localize('DAGGERHEART.GENERAL.duality'); @@ -316,6 +318,34 @@ Hooks.on('chatMessage', (_, message) => { }); return false; } + + if (message.startsWith('/fr')) { + const result = + message.trim().toLowerCase() === '/fr' ? { result: {} } : rollCommandToJSON(message.replace(/\/fr\s?/, '')); + + if (!result) { + ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateParsing')); + return false; + } + + const { result: rollCommand, flavor } = result; + const fateTypeData = getFateTypeData(rollCommand?.type); + + if (!fateTypeData) + return ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing')); + + const { value: fateType, label: fateTypeLabel } = fateTypeData; + const target = getCommandTarget({ allowNull: true }); + const title = flavor ?? game.i18n.localize('DAGGERHEART.GENERAL.fateRoll'); + + enrichedFateRoll({ + target, + title, + label: fateTypeLabel, + fateType + }); + return false; + } }); const updateActorsRangeDependentEffects = async token => { diff --git a/lang/en.json b/lang/en.json index 870e1b2b..69965b9e 100755 --- a/lang/en.json +++ b/lang/en.json @@ -619,6 +619,13 @@ "title": "{name} Resource", "rerollDice": "Reroll Dice" }, + "RiskItAllDialog": { + "title": "{name} - Risk It All", + "subtitle": "Clear Stress and Hit Points", + "remainingTitle": "Remaining Points", + "clearResource": "Clear {resource}", + "finalTitle": "Final Character Resources" + }, "TagTeamSelect": { "title": "Tag Team Roll", "leaderTitle": "Initiating Character", @@ -1017,15 +1024,15 @@ "DeathMoves": { "avoidDeath": { "name": "Avoid Death", - "description": "You drop unconscious temporarily and work with the GM to describe how the situation gets much worse because of it. Then roll your Fear die; if its value is equal to or under your Level, take a Scar." + "description": "Your character avoids death and faces the consequences. They temporarily drop unconscious, and then you work with the GM to describe how the situation worsens. While unconscious, your character can't move or act, and they can't be targeted by an attack. They return to consciousness when an ally clears 1 or more of their marked Hit Points or when the party finishes a long rest. After your character falls unconscious, roll your Hope Die. If its value is equal to or less than your character's level, they gain a scar: permanently cross out a Hope slot and work with the GM to determine its lasting narrative impact and how, if possible, it can be restored. If you ever cross out your last Hope slot, your character's journey ends." }, "riskItAll": { "name": "Risk It All", - "description": "Roll your Duality Dice. If Hope is higher, you stay on your feet and clear an amount of Hit Points and/or Stress equal to the value of the Hope die (divide the Hope die value up between these however you’d prefer). If your Fear die is higher, you cross through the veil of death. If the Duality Dice are tied, you stay on your feet and clear all Hit Points and Stress." + "description": "Roll your Duality Dice. If the Hope Die is higher, your character stays on their feet and clears a number of Hit Points or Stress equal to the value of the Hope Die (you can divide the Hope Die value between Hit Points and Stress however you'd prefer). If the Fear Die is higher, your character crosses through the veil of death. If the Duality Dice show matching results, your character stays up and clears all Hit Points and Stress." }, "blazeOfGlory": { "name": "Blaze Of Glory", - "description": "With Blaze of Glory, the player is accepting death for the character. Take one action (at GM discretion), which becomes an automatic critical success, then cross through the veil of death." + "description": "Your character embraces death and goes out in a blaze of glory. Take one final action. It automatically critically succeeds (with GM approval), and then you cross through the veil of death. NOTE: A Blaze of Glory effect has been added to your character. Any Duality Roll will automatically be a critical." } }, "DomainCardTypes": { @@ -2066,6 +2073,7 @@ "description": "Description", "main": "Data", "information": "Information", + "itemFeatures": "Item Features", "notes": "Notes", "inventory": "Inventory", "loadout": "Loadout", @@ -2141,6 +2149,7 @@ "dropActorsHere": "Drop Actors here", "dropFeaturesHere": "Drop Features here", "duality": "Duality", + "dualityDice": "Duality Dice", "dualityRoll": "Duality Roll", "enabled": "Enabled", "evasion": "Evasion", @@ -2150,11 +2159,14 @@ "plural": "Experiences" }, "failure": "Failure", + "fate": "Fate", + "fateRoll": "Fate Roll", "fear": "Fear", "features": "Features", "formula": "Formula", "general": "General", "gm": "GM", + "guaranteedCriticalSuccess": "Guaranteed Critical Success", "healing": "Healing", "healingRoll": "Healing Roll", "hit": { @@ -2214,6 +2226,7 @@ "rollWith": "{roll} Roll", "save": "Save", "scalable": "Scalable", + "scars": "Scars", "situationalBonus": "Situational Bonus", "spent": "Spent", "step": "Step", @@ -2510,6 +2523,7 @@ "resetMovesText": "Are you sure you want to reset?", "FIELDS": { "maxFear": { "label": "Max Fear" }, + "maxHope": { "label": "Max Hope" }, "traitArray": { "label": "Initial Trait Modifiers" }, "maxLoadout": { "label": "Max Cards in Loadout", @@ -2657,7 +2671,16 @@ "currentTarget": "Current" }, "deathMove": { - "title": "Death Move" + "title": "Death Move", + "gainScar": "You gained a scar.", + "avoidScar": "You have avoided a new scar.", + "journeysEnd": "You have {scars} Scars and have crossed out your last Hope slot. Your character's journey ends.", + "riskItAllCritical": "Critical Rolled, clearing all marked Stress and Hit Points.", + "riskItAllFailure": "The fear die rolled higher. You have crossed through the veil of death.", + "blazeOfGlory": "Blaze of Glory Effect Added!", + "riskItAllDialogButton": "Clear Stress And Hit Points.", + "riskItAllSuccessWithEnoughHope": "The Hope value is more than the marked Stress and Hit Points. Both are cleared fully.", + "riskItAllSuccess": "The hope die rolled higher, clear up to {hope} Stress And Hit Points." }, "dicePool": { "title": "Dice Pool" @@ -2779,7 +2802,9 @@ "noAssignedPlayerCharacter": "You have no assigned character.", "noSelectedToken": "You have no selected token", "onlyUseableByPC": "This can only be used with a PC token", - "dualityParsing": "Duality roll not properly formated", + "dualityParsing": "Duality roll not properly formatted", + "fateParsing": "Fate roll not properly formatted", + "fateTypeParsing": "Fate roll not properly formatted, bad fate type. Valid types are 'Hope' and 'Fear'", "attributeFaulty": "The supplied Attribute doesn't exist", "domainCardWrongDomain": "You don't have access to that Domain", "domainCardToHighLevel": "The Domain Card is too high level to be selected", diff --git a/module/applications/dialogs/_module.mjs b/module/applications/dialogs/_module.mjs index 92038c41..d43045e6 100644 --- a/module/applications/dialogs/_module.mjs +++ b/module/applications/dialogs/_module.mjs @@ -14,3 +14,4 @@ export { default as ResourceDiceDialog } from './resourceDiceDialog.mjs'; export { default as ActionSelectionDialog } from './actionSelectionDialog.mjs'; export { default as GroupRollDialog } from './group-roll-dialog.mjs'; export { default as TagTeamDialog } from './tagTeamDialog.mjs'; +export { default as RiskItAllDialog } from './riskItAllDialog.mjs'; diff --git a/module/applications/dialogs/d20RollDialog.mjs b/module/applications/dialogs/d20RollDialog.mjs index 441842dc..6f320152 100644 --- a/module/applications/dialogs/d20RollDialog.mjs +++ b/module/applications/dialogs/d20RollDialog.mjs @@ -123,7 +123,7 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio context.formula = this.roll.constructFormula(this.config); if (this.actor?.system?.traits) context.abilities = this.getTraitModifiers(); - context.showReaction = !this.config.roll?.type || context.rollType === 'DualityRoll'; + context.showReaction = !this.config.skips?.reaction && context.rollType === 'DualityRoll'; context.reactionOverride = this.reactionOverride; } diff --git a/module/applications/dialogs/deathMove.mjs b/module/applications/dialogs/deathMove.mjs index d0686d2b..01df6057 100644 --- a/module/applications/dialogs/deathMove.mjs +++ b/module/applications/dialogs/deathMove.mjs @@ -1,11 +1,16 @@ -const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +import { enrichedFateRoll } from '../../enrichers/FateRollEnricher.mjs'; +import { enrichedDualityRoll } from '../../enrichers/DualityRollEnricher.mjs'; -export default class DhpDeathMove extends HandlebarsApplicationMixin(ApplicationV2) { +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV2) { constructor(actor) { super({}); this.actor = actor; this.selectedMove = null; + this.showRiskItAllButton = false; + this.riskItAllButtonLabel = ''; + this.riskItAllHope = 0; } get title() { @@ -38,6 +43,107 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application return context; } + async handleAvoidDeath() { + const target = this.actor.uuid; + const config = await enrichedFateRoll({ + target, + title: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.avoidDeath.name'), + label: `${game.i18n.localize('DAGGERHEART.GENERAL.hope')} ${game.i18n.localize('DAGGERHEART.GENERAL.fateRoll')}`, + fateType: 'Hope' + }); + + if (!config.roll.fate) return; + + if (config.roll.fate.value <= this.actor.system.levelData.level.current) { + // apply scarring - for now directly apply - later add a button. + const newScarAmount = this.actor.system.scars + 1; + + await this.actor.update({ + system: { + scars: newScarAmount + } + }); + + if (newScarAmount >= this.actor.system.resources.hope.max) { + return game.i18n.format('DAGGERHEART.UI.Chat.deathMove.journeysEnd', { scars: newScarAmount }); + } + + return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.gainScar'); + } + + return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar'); + } + + async handleRiskItAll() { + const config = await enrichedDualityRoll({ + reaction: true, + traitValue: null, + target: this.actor, + difficulty: null, + title: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.riskItAll.name'), + label: game.i18n.localize('DAGGERHEART.GENERAL.dualityDice'), + actionType: null, + advantage: null, + customConfig: { skips: { resources: true, reaction: true } } + }); + + if (!config.roll.result) return; + + const clearAllStressAndHitpointsUpdates = [ + { key: 'hitPoints', clear: true }, + { key: 'stress', clear: true } + ]; + + let chatMessage = ''; + if (config.roll.isCritical) { + config.resourceUpdates.addResources(clearAllStressAndHitpointsUpdates); + chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllCritical'); + } + + if (config.roll.result.duality == 1) { + if ( + config.roll.hope.value >= + this.actor.system.resources.hitPoints.value + this.actor.system.resources.stress.value + ) { + config.resourceUpdates.addResources(clearAllStressAndHitpointsUpdates); + chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllSuccessWithEnoughHope'); + } else { + chatMessage = game.i18n.format('DAGGERHEART.UI.Chat.deathMove.riskItAllSuccess', { + hope: config.roll.hope.value + }); + this.showRiskItAllButton = true; + this.riskItAllHope = config.roll.hope.value; + this.riskItAllButtonLabel = game.i18n.format('DAGGERHEART.UI.Chat.deathMove.riskItAllDialogButton'); + } + } + + if (config.roll.result.duality == -1) { + chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllFailure'); + } + + await config.resourceUpdates.updateResources(); + return chatMessage; + } + + async handleBlazeOfGlory() { + this.actor.createEmbeddedDocuments('ActiveEffect', [ + { + name: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.name'), + description: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.description'), + img: CONFIG.DH.GENERAL.deathMoves.blazeOfGlory.img, + changes: [ + { + key: 'system.rules.roll.guaranteedCritical', + mode: 2, + value: 'true' + } + ] + } + ]); + + return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.blazeOfGlory'); + } + static selectMove(_, button) { const move = button.dataset.move; this.selectedMove = CONFIG.DH.GENERAL.deathMoves[move]; @@ -46,23 +152,49 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application } static async takeMove() { + this.close(); + + let result = ''; + + if (CONFIG.DH.GENERAL.deathMoves.blazeOfGlory === this.selectedMove) { + result = await this.handleBlazeOfGlory(); + } + + if (CONFIG.DH.GENERAL.deathMoves.avoidDeath === this.selectedMove) { + result = await this.handleAvoidDeath(); + } + + if (CONFIG.DH.GENERAL.deathMoves.riskItAll === this.selectedMove) { + result = await this.handleRiskItAll(); + } + + if (!result) return; + + const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance) + .expandRollMessage?.desc; const cls = getDocumentClass('ChatMessage'); + const msg = { user: game.user.id, content: await foundry.applications.handlebars.renderTemplate( 'systems/daggerheart/templates/ui/chat/deathMove.hbs', { player: this.actor.name, - actor: { name: this.actor.name, img: this.actor.img }, + actor: this.actor, + actorId: this.actor._id, author: game.users.get(game.user.id), title: game.i18n.localize(this.selectedMove.name), img: this.selectedMove.img, - description: game.i18n.localize(this.selectedMove.description) + description: game.i18n.localize(this.selectedMove.description), + result: result, + open: autoExpandDescription ? 'open' : '', + chevron: autoExpandDescription ? 'fa-chevron-up' : 'fa-chevron-down', + showRiskItAllButton: this.showRiskItAllButton, + riskItAllButtonLabel: this.riskItAllButtonLabel, + riskItAllHope: this.riskItAllHope } ), - title: game.i18n.localize( - 'DAGGERHEART.UI.Chat.deathMove.title' - ), + title: game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.title'), speaker: cls.getSpeaker(), flags: { daggerheart: { @@ -72,7 +204,5 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application }; cls.create(msg); - - this.close(); } } diff --git a/module/applications/dialogs/riskItAllDialog.mjs b/module/applications/dialogs/riskItAllDialog.mjs new file mode 100644 index 00000000..10fa1bb4 --- /dev/null +++ b/module/applications/dialogs/riskItAllDialog.mjs @@ -0,0 +1,94 @@ +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +export default class RiskItAllDialog extends HandlebarsApplicationMixin(ApplicationV2) { + constructor(actor, resourceValue) { + super({}); + + this.actor = actor; + this.resourceValue = resourceValue; + this.choices = { + hitPoints: 0, + stress: 0 + }; + } + + get title() { + return game.i18n.format('DAGGERHEART.APPLICATIONS.RiskItAllDialog.title', { name: this.actor.name }); + } + + static DEFAULT_OPTIONS = { + classes: ['daggerheart', 'dh-style', 'dialog', 'views', 'risk-it-all'], + position: { width: 280, height: 'auto' }, + window: { icon: 'fa-solid fa-dice fa-xl' }, + actions: { + finish: RiskItAllDialog.#finish + } + }; + + static PARTS = { + application: { + id: 'risk-it-all', + template: 'systems/daggerheart/templates/dialogs/riskItAllDialog.hbs' + } + }; + + _attachPartListeners(partId, htmlElement, options) { + super._attachPartListeners(partId, htmlElement, options); + + for (const input of htmlElement.querySelectorAll('.resource-container input')) + input.addEventListener('change', this.updateChoice.bind(this)); + } + + async _prepareContext(_options) { + const context = await super._prepareContext(_options); + context.resourceValue = this.resourceValue; + context.maxHitPointsValue = Math.min(this.resourceValue, this.actor.system.resources.hitPoints.max); + context.maxStressValue = Math.min(this.resourceValue, this.actor.system.resources.stress.max); + context.remainingResource = this.resourceValue - this.choices.hitPoints - this.choices.stress; + context.unfinished = context.remainingResource !== 0; + + context.choices = this.choices; + context.final = { + hitPoints: { + value: this.actor.system.resources.hitPoints.value - this.choices.hitPoints, + max: this.actor.system.resources.hitPoints.max + }, + stress: { + value: this.actor.system.resources.stress.value - this.choices.stress, + max: this.actor.system.resources.stress.max + } + }; + + context; + + return context; + } + + updateChoice(event) { + let value = Number.parseInt(event.target.value); + const choiceKey = event.target.dataset.choice; + const actorValue = this.actor.system.resources[choiceKey].value; + const remaining = this.resourceValue - this.choices.hitPoints - this.choices.stress; + const changeAmount = value - this.choices[choiceKey]; + + /* If trying to increase beyond remaining resource points, just increase to max available */ + if (remaining - changeAmount < 0) value = this.choices[choiceKey] + remaining; + else if (actorValue - value < 0) value = actorValue; + + this.choices[choiceKey] = value; + this.render(); + } + + static async #finish() { + const resourceUpdate = Object.keys(this.choices).reduce((acc, resourceKey) => { + const value = this.actor.system.resources[resourceKey].value - this.choices[resourceKey]; + acc[resourceKey] = { value }; + return acc; + }, {}); + + await this.actor.update({ + 'system.resources': resourceUpdate + }); + + this.close(); + } +} diff --git a/module/applications/sheets/actors/character.mjs b/module/applications/sheets/actors/character.mjs index d691c129..5c6bac3a 100644 --- a/module/applications/sheets/actors/character.mjs +++ b/module/applications/sheets/actors/character.mjs @@ -1,5 +1,5 @@ import DHBaseActorSheet from '../api/base-actor.mjs'; -import DhpDeathMove from '../../dialogs/deathMove.mjs'; +import DhDeathMove from '../../dialogs/deathMove.mjs'; import { abilities } from '../../../config/actorConfig.mjs'; import { CharacterLevelup, LevelupViewMode } from '../../levelup/_module.mjs'; import DhCharacterCreation from '../../characterCreation/characterCreation.mjs'; @@ -696,7 +696,7 @@ export default class CharacterSheet extends DHBaseActorSheet { * @type {ApplicationClickAction} */ static async #makeDeathMove() { - await new DhpDeathMove(this.document).render({ force: true }); + await new DhDeathMove(this.document).render({ force: true }); } /** @@ -753,9 +753,8 @@ export default class CharacterSheet extends DHBaseActorSheet { if (!result) return; /* This could be avoided by baking config.costs into config.resourceUpdates. Didn't feel like messing with it at the time */ - const costResources = result.costs - .filter(x => x.enabled) - .map(cost => ({ ...cost, value: -cost.value, total: -cost.total })); + const costResources = result.costs?.filter(x => x.enabled) + .map(cost => ({ ...cost, value: -cost.value, total: -cost.total })) || {}; config.resourceUpdates.addResources(costResources); await config.resourceUpdates.updateResources(); } diff --git a/module/applications/ui/chatLog.mjs b/module/applications/ui/chatLog.mjs index c4a313fa..2b489f58 100644 --- a/module/applications/ui/chatLog.mjs +++ b/module/applications/ui/chatLog.mjs @@ -81,6 +81,9 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo html.querySelectorAll('.group-roll-header-expand-section').forEach(element => element.addEventListener('click', this.groupRollExpandSection) ); + html.querySelectorAll('.risk-it-all-button').forEach(element => + element.addEventListener('click', event => this.riskItAllClearStressAndHitPoints(event, data)) + ); }; setupHooks() { @@ -94,15 +97,17 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo /** Ensure the chat theme inherits the interface theme */ _replaceHTML(result, content, options) { - const themedElement = result.log?.querySelector(".chat-log"); - themedElement?.classList.remove("themed", "theme-light", "theme-dark"); + const themedElement = result.log?.querySelector('.chat-log'); + themedElement?.classList.remove('themed', 'theme-light', 'theme-dark'); super._replaceHTML(result, content, options); } /** Remove chat log theme from notifications area */ async _onFirstRender(result, content) { await super._onFirstRender(result, content); - document.querySelector("#chat-notifications .chat-log")?.classList.remove("themed", "theme-light", "theme-dark") + document + .querySelector('#chat-notifications .chat-log') + ?.classList.remove('themed', 'theme-light', 'theme-dark'); } async onRollSimple(event, message) { @@ -383,4 +388,10 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo }); event.target.closest('.group-roll-section').querySelector('.group-roll-content').classList.toggle('closed'); } + + async riskItAllClearStressAndHitPoints(event, data) { + const resourceValue = event.target.dataset.resourceValue; + const actor = game.actors.get(event.target.dataset.actorId); + new game.system.api.applications.dialogs.RiskItAllDialog(actor, resourceValue).render({ force: true }); + } } diff --git a/module/data/action/baseAction.mjs b/module/data/action/baseAction.mjs index b5f95aff..4e699f79 100644 --- a/module/data/action/baseAction.mjs +++ b/module/data/action/baseAction.mjs @@ -376,14 +376,14 @@ export class ResourceUpdateMap extends Map { if (!resource.key) continue; const existing = this.get(resource.key); - if (existing) { + if (!existing || resource.clear) { + this.set(resource.key, resource); + } else if (!existing?.clear) { this.set(resource.key, { ...existing, value: existing.value + (resource.value ?? 0), total: existing.total + (resource.total ?? 0) }); - } else { - this.set(resource.key, resource); } } } diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index f6ab7e3a..a7f99ca8 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -35,7 +35,14 @@ export default class DhCharacter extends BaseDataActor { 'DAGGERHEART.ACTORS.Character.maxHPBonus' ), stress: resourceField(6, 0, 'DAGGERHEART.GENERAL.stress', true), - hope: resourceField(6, 2, 'DAGGERHEART.GENERAL.hope') + hope: new fields.SchemaField({ + value: new fields.NumberField({ + initial: 2, + min: 0, + integer: true, + label: 'DAGGERHEART.GENERAL.hope' + }) + }) }), traits: new fields.SchemaField({ agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'), @@ -78,12 +85,7 @@ export default class DhCharacter extends BaseDataActor { bags: new fields.NumberField({ initial: 0, integer: true }), chests: new fields.NumberField({ initial: 0, integer: true }) }), - scars: new fields.TypedObjectField( - new fields.SchemaField({ - name: new fields.StringField({}), - description: new fields.StringField() - }) - ), + scars: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.scars' }), biography: new fields.SchemaField({ background: new fields.HTMLField(), connections: new fields.HTMLField(), @@ -301,6 +303,9 @@ export default class DhCharacter extends BaseDataActor { runeWard: new fields.BooleanField({ initial: false }), burden: new fields.SchemaField({ ignore: new fields.BooleanField() + }), + roll: new fields.SchemaField({ + guaranteedCritical: new fields.BooleanField() }) }) }; @@ -642,7 +647,9 @@ export default class DhCharacter extends BaseDataActor { ? armor.system.baseThresholds.severe + this.levelData.level.current : this.levelData.level.current * 2 }; - this.resources.hope.max -= Object.keys(this.scars).length; + + const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope; + this.resources.hope.max = globalHopeMax - this.scars; this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0; } @@ -699,6 +706,20 @@ export default class DhCharacter extends BaseDataActor { changes.system.experiences[experience].core = true; } } + + /* Scars can alter the amount of current hope */ + if (changes.system?.scars) { + const diff = this.system.scars - changes.system.scars; + const newHopeMax = this.system.resources.hope.max + diff; + const newHopeValue = Math.min(newHopeMax, this.system.resources.hope.value); + if (newHopeValue != this.system.resources.hope.value) { + if (!changes.system.resources) changes.system.resources = { hope: { value: 0 } }; + changes.system.resources.hope = { + ...changes.system.resources.hope, + value: changes.system.resources.hope.value + newHopeValue + }; + } + } } async _preDelete() { @@ -714,4 +735,11 @@ export default class DhCharacter extends BaseDataActor { t => !!t ); } + + static migrateData(source) { + if (typeof source.scars === 'object') source.scars = 0; + if (source.resources?.hope?.max) source.scars = Math.max(6 - source.resources.hope.max, 0); + + return super.migrateData(source); + } } diff --git a/module/data/chat-message/_modules.mjs b/module/data/chat-message/_modules.mjs index ec095aac..c671de31 100644 --- a/module/data/chat-message/_modules.mjs +++ b/module/data/chat-message/_modules.mjs @@ -8,6 +8,7 @@ export const config = { adversaryRoll: DHActorRoll, damageRoll: DHActorRoll, dualityRoll: DHActorRoll, + fateRoll: DHActorRoll, groupRoll: DHGroupRoll, systemMessage: DHSystemMessage }; diff --git a/module/data/settings/Homebrew.mjs b/module/data/settings/Homebrew.mjs index 7572c9ea..0138713c 100644 --- a/module/data/settings/Homebrew.mjs +++ b/module/data/settings/Homebrew.mjs @@ -23,6 +23,13 @@ export default class DhHomebrew extends foundry.abstract.DataModel { initial: 12, label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxFear.label' }), + maxHope: new fields.NumberField({ + required: true, + integer: true, + min: 0, + initial: 6, + label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label' + }), maxLoadout: new fields.NumberField({ required: true, integer: true, diff --git a/module/dice/_module.mjs b/module/dice/_module.mjs index e6755a74..b9339d87 100644 --- a/module/dice/_module.mjs +++ b/module/dice/_module.mjs @@ -3,3 +3,4 @@ export { default as D20Roll } from './d20Roll.mjs'; export { default as DamageRoll } from './damageRoll.mjs'; export { default as DHRoll } from './dhRoll.mjs'; export { default as DualityRoll } from './dualityRoll.mjs'; +export { default as FateRoll } from './fateRoll.mjs'; diff --git a/module/dice/dualityRoll.mjs b/module/dice/dualityRoll.mjs index aaca7400..0edbe5ad 100644 --- a/module/dice/dualityRoll.mjs +++ b/module/dice/dualityRoll.mjs @@ -12,6 +12,7 @@ export default class DualityRoll extends D20Roll { constructor(formula, data = {}, options = {}) { super(formula, data, options); this.rallyChoices = this.setRallyChoices(); + this.guaranteedCritical = options.guaranteedCritical; } static messageType = 'dualityRoll'; @@ -25,29 +26,23 @@ export default class DualityRoll extends D20Roll { } get dHope() { - // if ( !(this.terms[0] instanceof foundry.dice.terms.Die) ) return; if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); return this.dice[0]; - // return this.#hopeDice; } set dHope(faces) { if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); - this.terms[0].faces = this.getFaces(faces); - // this.#hopeDice = `d${face}`; + this.dice[0].faces = this.getFaces(faces); } get dFear() { - // if ( !(this.terms[1] instanceof foundry.dice.terms.Die) ) return; if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice(); return this.dice[1]; - // return this.#fearDice; } set dFear(faces) { if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice(); this.dice[1].faces = this.getFaces(faces); - // this.#fearDice = `d${face}`; } get dAdvantage() { @@ -90,26 +85,29 @@ export default class DualityRoll extends D20Roll { } get isCritical() { + if (this.guaranteedCritical) return true; if (!this.dHope._evaluated || !this.dFear._evaluated) return; return this.dHope.total === this.dFear.total; } get withHope() { - if (!this._evaluated) return; + if (!this._evaluated || this.guaranteedCritical) return; return this.dHope.total > this.dFear.total; } get withFear() { - if (!this._evaluated) return; + if (!this._evaluated || this.guaranteedCritical) return; return this.dHope.total < this.dFear.total; } get totalLabel() { - const label = this.withHope - ? 'DAGGERHEART.GENERAL.hope' - : this.withFear - ? 'DAGGERHEART.GENERAL.fear' - : 'DAGGERHEART.GENERAL.criticalSuccess'; + const label = this.guaranteedCritical + ? 'DAGGERHEART.GENERAL.guaranteedCriticalSuccess' + : this.isCritical + ? 'DAGGERHEART.GENERAL.criticalSuccess' + : this.withHope + ? 'DAGGERHEART.GENERAL.hope' + : 'DAGGERHEART.GENERAL.fear'; return game.i18n.localize(label); } @@ -178,6 +176,21 @@ export default class DualityRoll extends D20Roll { return modifiers; } + static async buildConfigure(config = {}, message = {}) { + config.dialog ??= {}; + config.guaranteedCritical = config.data?.parent?.appliedEffects.reduce((a, c) => { + const change = c.changes.find(ch => ch.key === 'system.rules.roll.guaranteedCritical'); + if (change) a = true; + return a; + }, false); + + if (config.guaranteedCritical) { + config.dialog.configure = false; + } + + return super.buildConfigure(config, message); + } + getActionChangeKeys() { const changeKeys = new Set([`system.bonuses.roll.${this.options.actionType}`]); @@ -223,7 +236,7 @@ export default class DualityRoll extends D20Roll { data.hope = { dice: roll.dHope.denomination, - value: roll.dHope.total, + value: this.guaranteedCritical ? 0 : roll.dHope.total, rerolled: { any: roll.dHope.results.some(x => x.rerolled), rerolls: roll.dHope.results.filter(x => x.rerolled) @@ -231,7 +244,7 @@ export default class DualityRoll extends D20Roll { }; data.fear = { dice: roll.dFear.denomination, - value: roll.dFear.total, + value: this.guaranteedCritical ? 0 : roll.dFear.total, rerolled: { any: roll.dFear.results.some(x => x.rerolled), rerolls: roll.dFear.results.filter(x => x.rerolled) @@ -243,7 +256,7 @@ export default class DualityRoll extends D20Roll { }; data.result = { duality: roll.withHope ? 1 : roll.withFear ? -1 : 0, - total: roll.dHope.total + roll.dFear.total, + total: this.guaranteedCritical ? 0 : roll.dHope.total + roll.dFear.total, label: roll.totalLabel }; diff --git a/module/dice/fateRoll.mjs b/module/dice/fateRoll.mjs new file mode 100644 index 00000000..418c8465 --- /dev/null +++ b/module/dice/fateRoll.mjs @@ -0,0 +1,85 @@ +import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs'; +import D20Roll from './d20Roll.mjs'; +import { setDiceSoNiceForHopeFateRoll, setDiceSoNiceForFearFateRoll } from '../helpers/utils.mjs'; + +export default class FateRoll extends D20Roll { + constructor(formula, data = {}, options = {}) { + super(formula, data, options); + } + + static messageType = 'fateRoll'; + + static DefaultDialog = D20RollDialog; + + get title() { + return game.i18n.localize(`DAGGERHEART.GENERAL.fateRoll`); + } + + get dHope() { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + return this.dice[0]; + } + + set dHope(faces) { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + this.dice[0].faces = this.getFaces(faces); + } + + get dFear() { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + return this.dice[0]; + } + + set dFear(faces) { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + this.dice[0].faces = this.getFaces(faces); + } + + get isCritical() { + return false; + } + + get fateDie() { + return this.data.fateType; + } + + static getHooks(hooks) { + return [...(hooks ?? []), 'Fate']; + } + + /** @inheritDoc */ + static fromData(data) { + data.terms[0].class = foundry.dice.terms.Die.name; + return super.fromData(data); + } + + createBaseDice() { + if (this.dice[0] instanceof foundry.dice.terms.Die) { + this.terms = [this.terms[0]]; + return; + } + this.terms[0] = new foundry.dice.terms.Die({ faces: 12 }); + } + + static async buildEvaluate(roll, config = {}, message = {}) { + await super.buildEvaluate(roll, config, message); + + if (roll.fateDie === 'Hope') { + await setDiceSoNiceForHopeFateRoll(roll, config.roll.fate.dice); + } else { + await setDiceSoNiceForFearFateRoll(roll, config.roll.fate.dice); + } + } + + static postEvaluate(roll, config = {}) { + const data = super.postEvaluate(roll, config); + + data.fate = { + dice: roll.fateDie === 'Hope' ? roll.dHope.denomination : roll.dFear.denomination, + value: roll.fateDie === 'Hope' ? roll.dHope.total : roll.dFear.total, + fateDie: roll.fateDie + }; + + return data; + } +} diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index 27c310ae..cec1a24d 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -764,16 +764,24 @@ export default class DhpActor extends Actor { }; } } else { + const valueFunc = (base, resource, baseMax) => { + if (resource.clear) return baseMax && base.inverted ? baseMax : 0; + + return (base.value ?? base) + resource.value; + }; switch (r.key) { case 'fear': ui.resources.updateFear( - game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear) + r.value + valueFunc( + game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear), + r + ) ); break; case 'armor': if (this.system.armor?.system?.marks) { updates.armor.resources['system.marks.value'] = Math.max( - Math.min(this.system.armor.system.marks.value + r.value, this.system.armorScore), + Math.min(valueFunc(this.system.armor.system.marks, r), this.system.armorScore), 0 ); } @@ -782,7 +790,7 @@ export default class DhpActor extends Actor { if (this.system.resources?.[r.key]) { updates.actor.resources[`system.resources.${r.key}.value`] = Math.max( Math.min( - this.system.resources[r.key].value + r.value, + valueFunc(this.system.resources[r.key], r, this.system.resources[r.key].max), this.system.resources[r.key].max ), 0 diff --git a/module/documents/chatMessage.mjs b/module/documents/chatMessage.mjs index 2f23cc1a..e03c3cf0 100644 --- a/module/documents/chatMessage.mjs +++ b/module/documents/chatMessage.mjs @@ -87,6 +87,15 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage { break; } } + if (this.type === 'fateRoll') { + html.classList.add('fate'); + if (this.system.roll?.fate.fateDie == 'Hope') { + html.classList.add('hope'); + } + if (this.system.roll?.fate.fateDie == 'Fear') { + html.classList.add('fear'); + } + } const autoExpandRoll = game.settings.get( CONFIG.DH.id, diff --git a/module/enrichers/DualityRollEnricher.mjs b/module/enrichers/DualityRollEnricher.mjs index 536847f7..f6f022f9 100644 --- a/module/enrichers/DualityRollEnricher.mjs +++ b/module/enrichers/DualityRollEnricher.mjs @@ -2,7 +2,7 @@ import { abilities } from '../config/actorConfig.mjs'; import { getCommandTarget, rollCommandToJSON } from '../helpers/utils.mjs'; export default function DhDualityRollEnricher(match, _options) { - const roll = rollCommandToJSON(match[1], match[0]); + const roll = rollCommandToJSON(match[0]); if (!roll) return match[0]; return getDualityMessage(roll.result, roll.flavor); @@ -80,7 +80,7 @@ export const renderDualityButton = async event => { }; export const enrichedDualityRoll = async ( - { reaction, traitValue, target, difficulty, title, label, advantage }, + { reaction, traitValue, target, difficulty, title, label, advantage, customConfig }, event ) => { const config = { @@ -94,7 +94,8 @@ export const enrichedDualityRoll = async ( type: reaction ? 'reaction' : null }, type: 'trait', - hasRoll: true + hasRoll: true, + ...(customConfig ?? {}) }; if (target) { @@ -105,4 +106,5 @@ export const enrichedDualityRoll = async ( config.source = { actor: null }; await CONFIG.Dice.daggerheart.DualityRoll.build(config); } + return config; }; diff --git a/module/enrichers/FateRollEnricher.mjs b/module/enrichers/FateRollEnricher.mjs new file mode 100644 index 00000000..c82bbcb2 --- /dev/null +++ b/module/enrichers/FateRollEnricher.mjs @@ -0,0 +1,80 @@ +import { getCommandTarget, rollCommandToJSON } from '../helpers/utils.mjs'; + +export default function DhFateRollEnricher(match, _options) { + const roll = rollCommandToJSON(match[0]); + if (!roll) return match[0]; + + return getFateMessage(roll.result, roll?.flavor); +} + +export function getFateTypeData(fateTypeValue) { + const value = fateTypeValue ? fateTypeValue.capitalize() : 'Hope'; + const lowercased = fateTypeValue?.toLowerCase?.() ?? 'hope'; + switch (lowercased) { + case 'hope': + case 'fear': + return { value, label: game.i18n.localize(`DAGGERHEART.GENERAL.${lowercased}`) }; + default: + return null; + } +} + +function getFateMessage(roll, flavor) { + const fateTypeData = getFateTypeData(roll?.type); + + if (!fateTypeData) + return ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing')); + + const { value: fateType, label: fateTypeLabel } = fateTypeData; + const title = flavor ?? game.i18n.localize('DAGGERHEART.GENERAL.fateRoll'); + + const fateElement = document.createElement('span'); + fateElement.innerHTML = ` + + `; + + return fateElement; +} + +export const renderFateButton = async event => { + const button = event.currentTarget, + target = getCommandTarget({ allowNull: true }); + + const fateTypeData = getFateTypeData(button.dataset?.fatetype); + + if (!fateTypeData) ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing')); + const { value: fateType, label: fateTypeLabel } = fateTypeData; + + await enrichedFateRoll( + { + target, + title: button.dataset.title, + label: button.dataset.label, + fateType: fateType + }, + event + ); +}; + +export const enrichedFateRoll = async ({ target, title, label, fateType }, event) => { + const config = { + event: event ?? {}, + title: title, + headerTitle: label, + roll: {}, + hasRoll: true, + fateType: fateType, + skips: { reaction: true } + }; + + config.data = { experiences: {}, traits: {}, fateType: fateType }; + config.source = { actor: target?.uuid }; + await CONFIG.Dice.daggerheart.FateRoll.build(config); + return config; +}; diff --git a/module/enrichers/_module.mjs b/module/enrichers/_module.mjs index 0dcd870e..b80f166c 100644 --- a/module/enrichers/_module.mjs +++ b/module/enrichers/_module.mjs @@ -1,10 +1,11 @@ import { default as DhDamageEnricher, renderDamageButton } from './DamageEnricher.mjs'; import { default as DhDualityRollEnricher, renderDualityButton } from './DualityRollEnricher.mjs'; +import { default as DhFateRollEnricher, renderFateButton } from './FateRollEnricher.mjs'; import { default as DhEffectEnricher } from './EffectEnricher.mjs'; import { default as DhTemplateEnricher, renderMeasuredTemplate } from './TemplateEnricher.mjs'; import { default as DhLookupEnricher } from './LookupEnricher.mjs'; -export { DhDamageEnricher, DhDualityRollEnricher, DhEffectEnricher, DhTemplateEnricher }; +export { DhDamageEnricher, DhDualityRollEnricher, DhEffectEnricher, DhTemplateEnricher, DhFateRollEnricher }; export const enricherConfig = [ { @@ -15,6 +16,10 @@ export const enricherConfig = [ pattern: /\[\[\/dr\s?(.*?)\]\]({[^}]*})?/g, enricher: DhDualityRollEnricher }, + { + pattern: /\[\[\/fr\s?(.*?)\]\]({[^}]*})?/g, + enricher: DhFateRollEnricher + }, { pattern: /@Effect\[([^\[\]]*)\]({[^}]*})?/g, enricher: DhEffectEnricher @@ -38,6 +43,10 @@ export const enricherRenderSetup = element => { .querySelectorAll('.duality-roll-button') .forEach(element => element.addEventListener('click', renderDualityButton)); + element + .querySelectorAll('.fate-roll-button') + .forEach(element => element.addEventListener('click', renderFateButton)); + element .querySelectorAll('.measured-template-button') .forEach(element => element.addEventListener('click', renderMeasuredTemplate)); diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs index a28725b1..1cce581a 100644 --- a/module/helpers/utils.mjs +++ b/module/helpers/utils.mjs @@ -1,14 +1,14 @@ -import { diceTypes, getDiceSoNicePresets, range } from '../config/generalConfig.mjs'; +import { diceTypes, getDiceSoNicePresets, getDiceSoNicePreset, range } from '../config/generalConfig.mjs'; import Tagify from '@yaireo/tagify'; export const capitalize = string => { return string.charAt(0).toUpperCase() + string.slice(1); }; -export function rollCommandToJSON(text, raw) { +export function rollCommandToJSON(text) { if (!text) return {}; - const flavorMatch = raw?.match(/{(.*)}$/); + const flavorMatch = text?.match(/{(.*)}$/); const flavor = flavorMatch ? flavorMatch[1] : null; // Match key="quoted string" OR key=unquotedValue @@ -31,7 +31,7 @@ export function rollCommandToJSON(text, raw) { } result[key] = value; } - return Object.keys(result).length > 0 ? { result, flavor } : null; + return { result, flavor }; } export const getCommandTarget = (options = {}) => { @@ -69,6 +69,20 @@ export const setDiceSoNiceForDualityRoll = async (rollResult, advantageState, ho } }; +export const setDiceSoNiceForHopeFateRoll = async (rollResult, hopeFaces) => { + if (!game.modules.get('dice-so-nice')?.active) return; + const { diceSoNice } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance); + const diceSoNicePresets = await getDiceSoNicePreset(diceSoNice.hope, hopeFaces); + rollResult.dice[0].options = diceSoNicePresets; +}; + +export const setDiceSoNiceForFearFateRoll = async (rollResult, fearFaces) => { + if (!game.modules.get('dice-so-nice')?.active) return; + const { diceSoNice } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance); + const diceSoNicePresets = await getDiceSoNicePreset(diceSoNice.fear, fearFaces); + rollResult.dice[0].options = diceSoNicePresets; +}; + export const chunkify = (array, chunkSize, mappingFunc) => { var chunkifiedArray = []; for (let i = 0; i < array.length; i += chunkSize) { diff --git a/styles/less/dialog/death-move/death-move-container.less b/styles/less/dialog/death-move/death-move-container.less index e5803d95..903704e3 100644 --- a/styles/less/dialog/death-move/death-move-container.less +++ b/styles/less/dialog/death-move/death-move-container.less @@ -1,55 +1,56 @@ -@import '../../utils/spacing.less'; -@import '../../utils/colors.less'; -@import '../../utils/fonts.less'; - -.daggerheart.dh-style.dialog.death-move { - .death-move-container { - display: flex; - flex-direction: column; - gap: 5px; - - .moves-list { - .move-item { - display: flex; - align-items: center; - gap: 5px; - - &:hover { - background-color: light-dark(@soft-shadow, @soft-white-shadow); - cursor: pointer; - } - padding: 5px; - border-radius: 5px; - transition: background-color 0.3s ease-in-out; - - .label { - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - flex: 1; - i { - text-align: center; - width: 30px; - } - } - - input[type='radio'] { - margin-left: auto; - } - } - } - } - - footer { - margin-top: 8px; - display: flex; - gap: 8px; - - button { - flex: 1; - height: 40px; - font-weight: 600; - } - } -} +@import '../../utils/spacing.less'; +@import '../../utils/colors.less'; +@import '../../utils/fonts.less'; + +.daggerheart.dh-style.dialog.death-move { + .death-move-container { + display: flex; + flex-direction: column; + gap: 5px; + + .moves-list { + .move-item { + display: flex; + align-items: center; + gap: 5px; + padding: 5px; + border-radius: 5px; + transition: background-color 0.3s ease-in-out; + height: 37px; + + &:hover { + background-color: light-dark(@soft-shadow, @soft-white-shadow); + cursor: pointer; + } + + .label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + flex: 1; + i { + text-align: center; + width: 30px; + } + } + + input[type='radio'] { + margin-left: auto; + } + } + } + } + + footer { + margin-top: 8px; + display: flex; + gap: 8px; + + button { + flex: 1; + height: 40px; + font-weight: 600; + } + } +} diff --git a/styles/less/dialog/index.less b/styles/less/dialog/index.less index b5ed8764..01a3f954 100644 --- a/styles/less/dialog/index.less +++ b/styles/less/dialog/index.less @@ -38,4 +38,6 @@ @import './item-transfer/sheet.less'; -@import './settings/change-currency-icon.less'; \ No newline at end of file +@import './settings/change-currency-icon.less'; + +@import './risk-it-all/sheet.less'; diff --git a/styles/less/dialog/risk-it-all/sheet.less b/styles/less/dialog/risk-it-all/sheet.less new file mode 100644 index 00000000..db34a5c1 --- /dev/null +++ b/styles/less/dialog/risk-it-all/sheet.less @@ -0,0 +1,60 @@ +.daggerheart.dialog.dh-style.views.risk-it-all { + .risk-it-all-container { + display: flex; + align-items: center; + flex-direction: column; + gap: 8px; + text-align: center; + + header { + font-weight: bold; + font-size: var(--font-size-20); + } + + .section-label { + font-size: var(--font-size-18); + text-decoration: underline; + } + + .remaining-section { + display: flex; + flex-direction: column; + gap: 2px; + } + + .resource-section { + width: 100%; + display: flex; + gap: 8px; + } + + .final-section { + width: 100%; + display: flex; + flex-direction: column; + gap: 2px; + + .final-section-values-container { + width: 100%; + display: flex; + align-items: center; + justify-content: space-evenly; + + .final-section-value-container { + display: flex; + flex-direction: column; + gap: 2px; + } + } + } + } + + footer { + width: 100%; + display: flex; + + button { + flex: 1; + } + } +} diff --git a/styles/less/global/enrichment.less b/styles/less/global/enrichment.less index 2ad3975a..8256d60a 100644 --- a/styles/less/global/enrichment.less +++ b/styles/less/global/enrichment.less @@ -1,5 +1,6 @@ .measured-template-button, .enriched-damage-button, +.fate-roll-button, .duality-roll-button { display: inline; diff --git a/styles/less/sheets/actors/character/header.less b/styles/less/sheets/actors/character/header.less index 593f1b73..5e8ef002 100644 --- a/styles/less/sheets/actors/character/header.less +++ b/styles/less/sheets/actors/character/header.less @@ -195,6 +195,11 @@ .hope-value { display: flex; cursor: pointer; + + &.scar { + cursor: initial; + opacity: 0.6; + } } } } diff --git a/styles/less/ui/chat/chat.less b/styles/less/ui/chat/chat.less index 57e9fd57..1b1e3c1c 100644 --- a/styles/less/ui/chat/chat.less +++ b/styles/less/ui/chat/chat.less @@ -14,6 +14,7 @@ color: @dark; } + &.fate, &.duality { background-image: url(../assets/parchments/dh-parchment-dark.png); @@ -66,6 +67,7 @@ } } + &.fate, &.critical { --text-color: @chat-purple; --bg-color: @chat-purple-40; @@ -80,7 +82,7 @@ } } - &:not(.duality) { + &:not(.duality .fate) { .font-20 { color: @dark; } @@ -173,6 +175,28 @@ } } + &.fate { + + &.hope { + --text-color: @golden; + --bg-color: @golden-40; + .message-header, + .message-content { + background-color: @golden-bg; + } + } + + &.fear { + --text-color: @chat-blue; + --bg-color: @chat-blue-40; + .message-header, + .message-content { + background-color: @chat-blue-bg; + } + } + + } + &.duality { &.hope { --text-color: @golden; diff --git a/styles/less/ui/chat/deathmoves.less b/styles/less/ui/chat/deathmoves.less new file mode 100644 index 00000000..175b8753 --- /dev/null +++ b/styles/less/ui/chat/deathmoves.less @@ -0,0 +1,152 @@ +@import '../../utils/colors.less'; +@import '../../utils/fonts.less'; +@import '../../utils/spacing.less'; + +#interface.theme-light { + .daggerheart.chat.death-moves { + .death-moves-list .death-move { + &:hover { + background: @dark-blue-10; + } + + .death-label { + border-bottom: 1px solid @dark-blue; + + .header-label .title { + color: @dark-blue; + } + .header-label .label { + color: @dark; + } + } + + .fa-chevron-down { + color: @dark-blue; + } + } + + .description { + color: @dark; + } + + .result { + color: @dark; + } + + .risk-it-all-button { + color: @dark; + } + } +} + +.daggerheart.chat { + &.death-moves { + display: flex; + flex-direction: column; + align-items: center; + + details[open] { + .fa-chevron-down { + transform: rotate(180deg); + transition: all 0.3s ease; + } + } + + .death-moves-list { + display: flex; + flex-direction: column; + gap: 5px; + width: 100%; + + .fa-chevron-down { + transition: all 0.3s ease; + margin-left: auto; + } + + .death-move { + width: 100%; + + .death-label { + display: flex; + align-items: center; + gap: 5px; + border-bottom: 1px solid @golden; + margin: 0 8px; + padding-bottom: 5px; + width: -webkit-fill-available; + + &:hover { + background: light-dark(@dark-blue-10, @golden-10); + cursor: pointer; + transition: all 0.3s ease; + } + + .death-image { + width: 40px; + height: 40px; + border-radius: 3px; + } + + .header-label { + padding: 8px; + .title { + font-size: var(--font-size-16); + color: @golden; + font-weight: 700; + } + .label { + font-size: var(--font-size-12); + color: @beige; + margin: 0; + } + } + } + + .description { + padding: 8px; + } + } + + .action-use-button-parent { + width: 100%; + + .action-use-target { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + width: 100%; + padding: 4px 8px 10px 40px; + font-size: var(--font-size-12); + + label { + font-weight: bold; + } + + select { + flex: 1; + } + } + } + + .action-use-button { + width: -webkit-fill-available; + margin: 0 8px; + font-weight: 600; + height: 40px; + } + } + + .result { + padding: 8px; + font-weight: bold; + } + + .risk-it-all-button { + width: -webkit-fill-available; + margin: 0 8px; + font-weight: 600; + height: 40px; + } + } +} diff --git a/styles/less/ui/index.less b/styles/less/ui/index.less index 25f51d0f..065e43c5 100644 --- a/styles/less/ui/index.less +++ b/styles/less/ui/index.less @@ -6,6 +6,7 @@ @import './chat/effect-summary.less'; @import './chat/group-roll.less'; @import './chat/refresh-message.less'; +@import './chat/deathmoves.less'; @import './chat/sheet.less'; @import './combat-sidebar/combat-sidebar.less'; diff --git a/system.json b/system.json index 2c68f785..8e4d19d9 100644 --- a/system.json +++ b/system.json @@ -285,6 +285,7 @@ }, "ChatMessage": { "dualityRoll": {}, + "fateRoll": {}, "adversaryRoll": {}, "damageRoll": {}, "abilityUse": {}, diff --git a/templates/dialogs/dice-roll/rollSelection.hbs b/templates/dialogs/dice-roll/rollSelection.hbs index e60f4683..5851a33d 100644 --- a/templates/dialogs/dice-roll/rollSelection.hbs +++ b/templates/dialogs/dice-roll/rollSelection.hbs @@ -68,95 +68,127 @@ {{/if}} {{/if}} {{/if}} + {{#if (eq @root.rollType 'FateRoll')}} + {{#if (eq @root.roll.fateDie 'Hope')}} + +
+ +
+ {{localize "DAGGERHEART.GENERAL.hope"}} + +
+
+ {{/if}} + + {{#if (eq @root.roll.fateDie 'Fear')}} +
+ +
+ {{localize "DAGGERHEART.GENERAL.fear"}} + +
+
+ {{/if}} + + {{/if}} - {{#if hasSelectedEffects}} -
- {{localize "DAGGERHEART.GENERAL.Effect.plural"}} + {{#if (ne @root.rollType 'FateRoll')}} + {{#if hasSelectedEffects}} +
+ {{localize "DAGGERHEART.GENERAL.Effect.plural"}} - {{#each selectedEffects as |effect id|}} -
- - {{effect.name}} -
- {{/each}} -
- {{/if}} - - {{#if experiences.length}} -
- {{localize "DAGGERHEART.GENERAL.experience.plural"}} - {{#each experiences}} - {{#if name}} -
- - {{name}} +{{value}} + {{#each selectedEffects as |effect id|}} +
+ + {{effect.name}}
- {{/if}} - {{/each}} -
- {{/if}} + {{/each}} +
+ {{/if}} -
- {{localize "DAGGERHEART.GENERAL.Modifier.plural"}} -
- - -
- {{#unless (eq @root.rollType 'D20Roll')}} + {{#if experiences.length}} +
+ {{localize "DAGGERHEART.GENERAL.experience.plural"}} + {{#each experiences}} + {{#if name}} +
+ + {{name}} +{{value}} +
+ {{/if}} + {{/each}} +
+ {{/if}} +
+ {{#if @root.advantage}} + {{localize "DAGGERHEART.GENERAL.Modifier.plural"}}
- - + +
- {{#if abilities}} - {{localize "DAGGERHEART.GENERAL.traitModifier"}} - + {{#times 10}} + + {{/times}} + + + + {{#if abilities}} + {{localize "DAGGERHEART.GENERAL.traitModifier"}} + + {{/if}} + {{/unless}} + {{/if}} + {{#if @root.rallyDie.length}} + {{localize "DAGGERHEART.CLASS.Feature.rallyDice"}} + {{/if}} - {{/unless}} - {{#if @root.rallyDie.length}} - {{localize "DAGGERHEART.CLASS.Feature.rallyDice"}} - - {{/if}} - {{#if (eq @root.rollType 'DualityRoll')}}{{localize "DAGGERHEART.GENERAL.situationalBonus"}}{{/if}} - -
+ {{#if (eq @root.rollType 'DualityRoll')}}{{localize "DAGGERHEART.GENERAL.situationalBonus"}}{{/if}} + +
+ {{/if}} {{/unless}} {{#if (or costs uses)}} {{> 'systems/daggerheart/templates/dialogs/dice-roll/costSelection.hbs'}} {{/if}} - {{localize "DAGGERHEART.GENERAL.formula"}}: {{@root.formula}} + {{#if (ne @root.rollType 'FateRoll')}} + {{localize "DAGGERHEART.GENERAL.formula"}}: {{@root.formula}} + {{/if}}
+
+
+ + +
+ + +
+ +
+
+ + {{this.final.hitPoints.value}}/{{this.final.hitPoints.max}} +
+
+ + {{this.final.stress.value}}/{{this.final.stress.max}} +
+
+
+ +
+ +
+ + \ No newline at end of file diff --git a/templates/settings/homebrew-settings/settings.hbs b/templates/settings/homebrew-settings/settings.hbs index cdcbd461..4b6e7d85 100644 --- a/templates/settings/homebrew-settings/settings.hbs +++ b/templates/settings/homebrew-settings/settings.hbs @@ -8,6 +8,7 @@

{{localize 'DAGGERHEART.SETTINGS.Menu.homebrew.name'}}

{{formGroup settingFields.schema.fields.maxFear value=settingFields._source.maxFear localize=true}} + {{formGroup settingFields.schema.fields.maxHope value=settingFields._source.maxHope localize=true}} {{formGroup settingFields.schema.fields.maxDomains value=settingFields._source.maxDomains localize=true}} {{formGroup settingFields.schema.fields.maxLoadout value=settingFields._source.maxLoadout localize=true}}
diff --git a/templates/sheets-settings/action-settings/trigger.hbs b/templates/sheets-settings/action-settings/trigger.hbs index b048461e..9ef97733 100644 --- a/templates/sheets-settings/action-settings/trigger.hbs +++ b/templates/sheets-settings/action-settings/trigger.hbs @@ -30,7 +30,7 @@
- {{formInput @root.fields.triggers.element.fields.command value=trigger.command elementType="code-mirror" name=(concat "triggers." index ".command") aria=(object label=(localize "Test")) }} + {{formInput @root.fields.triggers.element.fields.command value=trigger.command elementType="code-mirror" name=(concat "triggers." index ".command") }}
{{/each}} diff --git a/templates/sheets-settings/character-settings/details.hbs b/templates/sheets-settings/character-settings/details.hbs index 42e17a9b..3f9247e0 100644 --- a/templates/sheets-settings/character-settings/details.hbs +++ b/templates/sheets-settings/character-settings/details.hbs @@ -31,7 +31,7 @@ {{formGroup systemFields.resources.fields.stress.fields.max value=document._source.system.resources.stress.max localize=true}} {{formGroup systemFields.resources.fields.hope.fields.value value=document._source.system.resources.hope.value localize=true}} - {{formGroup systemFields.resources.fields.hope.fields.max value=document._source.system.resources.hope.max localize=true}} + {{formGroup systemFields.scars value=document._source.system.scars localize=true}} {{formGroup systemFields.proficiency value=document._source.system.proficiency localize=true}} diff --git a/templates/sheets/actors/character/header.hbs b/templates/sheets/actors/character/header.hbs index 87319dbb..d2c01f3c 100644 --- a/templates/sheets/actors/character/header.hbs +++ b/templates/sheets/actors/character/header.hbs @@ -76,6 +76,11 @@ {{/if}} {{/times}} + {{#times document.system.scars}} + + + + {{/times}} {{#if document.system.class.value}}
diff --git a/templates/ui/chat/deathMove.hbs b/templates/ui/chat/deathMove.hbs index 9940376e..7c677fe3 100644 --- a/templates/ui/chat/deathMove.hbs +++ b/templates/ui/chat/deathMove.hbs @@ -1,17 +1,32 @@ -
-
    -
    - - +
    +
      +
      + +

      {{this.title}}

      {{localize 'DAGGERHEART.UI.Chat.deathMove.title'}}
      - +
      {{{this.description}}}
    +
    + {{{this.result}}} +
    + {{#if this.showRiskItAllButton}} +
    + +
    + {{/if}} + +
    +
    \ No newline at end of file diff --git a/templates/ui/chat/parts/roll-part.hbs b/templates/ui/chat/parts/roll-part.hbs index 8e88015f..ea962f9e 100644 --- a/templates/ui/chat/parts/roll-part.hbs +++ b/templates/ui/chat/parts/roll-part.hbs @@ -29,64 +29,86 @@
    - {{#if roll.hope}} -
    - -
    - {{#if roll.hope.rerolled.any}}{{/if}} - {{roll.hope.value}} -
    -
    -
    - -
    - {{#if roll.fear.rerolled.any}}{{/if}} - {{roll.fear.value}} -
    -
    - {{#if roll.advantage.type}} -
    - {{#if (eq roll.advantage.type 1)}} - -
    {{roll.advantage.value}}
    - {{else}} - -
    {{roll.advantage.value}}
    - {{/if}} -
    - {{/if}} - {{#if roll.rally.dice}} -
    - -
    {{roll.rally.value}}
    -
    - {{/if}} - {{#each roll.extra}} - {{#each results}} - {{#unless discarded}} -
    - -
    {{result}}
    -
    - {{/unless}} - {{/each}} - {{/each}} - {{else}} - {{#each roll.dice}} - {{#each results}} -
    -
    - {{result}} -
    + {{#if roll.fate}} + {{#if (eq roll.fate.fateDie "Hope")}} +
    + +
    + {{roll.fate.value}}
    +
    + {{/if}} + {{#if (eq roll.fate.fateDie "Fear")}} +
    + +
    + {{roll.fate.value}} +
    +
    + {{/if}} + {{else}} + {{#if roll.hope}} +
    + +
    + {{#if roll.hope.rerolled.any}}{{/if}} + {{roll.hope.value}} +
    +
    +
    + +
    + {{#if roll.fear.rerolled.any}}{{/if}} + {{roll.fear.value}} +
    +
    + {{#if roll.advantage.type}} +
    + {{#if (eq roll.advantage.type 1)}} + +
    {{roll.advantage.value}}
    + {{else}} + +
    {{roll.advantage.value}}
    + {{/if}} +
    + {{/if}} + {{#if roll.rally.dice}} +
    + +
    {{roll.rally.value}}
    +
    + {{/if}} + {{#each roll.extra}} + {{#each results}} + {{#unless discarded}} +
    + +
    {{result}}
    +
    + {{/unless}} + {{/each}} {{/each}} - {{/each}} + {{else}} + {{#each roll.dice}} + {{#each results}} +
    +
    + {{result}} +
    +
    + {{/each}} + {{/each}} + {{/if}} {{/if}}
    -
    {{roll.formula}}
    + {{#if roll.fate}} + {{else}} +
    {{roll.formula}}
    + {{/if}}
{{/unless}} -
\ No newline at end of file + From 77bac647a86f886e811bd71126cc51289be75d33 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:31:54 +0100 Subject: [PATCH 04/95] Fixed typo in levelupViewMode domain card option (#1558) --- module/applications/levelup/levelupViewMode.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/module/applications/levelup/levelupViewMode.mjs b/module/applications/levelup/levelupViewMode.mjs index b3d7c30f..afd7dbc4 100644 --- a/module/applications/levelup/levelupViewMode.mjs +++ b/module/applications/levelup/levelupViewMode.mjs @@ -70,7 +70,10 @@ export default class DhlevelUpViewMode extends HandlebarsApplicationMixin(Applic return checkbox; }); - let label = game.i18n.localize(option.label); + let label = + optionKey === 'domainCard' + ? game.i18n.format(option.label, { maxLevel: tier.levels.end }) + : game.i18n.localize(option.label); return { label: label, checkboxGroups: chunkify(checkboxes, option.minCost, chunkedBoxes => { From cc998bffa7ca04bdf203f6969ebde17cf8c751cb Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:03:52 +0100 Subject: [PATCH 05/95] [Feature] DeathMove Condition Improvement (#1562) * Added DeathMove condition and automated changing to the correct condition depending on the result of death moves * . * Update module/data/settings/Automation.mjs Co-authored-by: Chris Ryan <73275196+chrisryan10@users.noreply.github.com> * Update lang/en.json Co-authored-by: Chris Ryan <73275196+chrisryan10@users.noreply.github.com> * Fixed DefeatedCondition localizations --------- Co-authored-by: Chris Ryan <73275196+chrisryan10@users.noreply.github.com> --- lang/en.json | 10 +++++++++- module/applications/dialogs/deathMove.mjs | 11 +++++++---- module/config/generalConfig.mjs | 6 +++++- module/data/actor/character.mjs | 13 ++++++++++++- module/data/settings/Automation.mjs | 16 +++++++++++----- module/documents/actor.mjs | 16 ++++++++++++++-- templates/settings/automation-settings/rules.hbs | 3 ++- 7 files changed, 60 insertions(+), 15 deletions(-) diff --git a/lang/en.json b/lang/en.json index 69965b9e..dda78410 100755 --- a/lang/en.json +++ b/lang/en.json @@ -973,6 +973,10 @@ "outsideRange": "Outside Range" }, "Condition": { + "deathMove": { + "name": "Death Move", + "description": "The character is about to make a Death Move" + }, "dead": { "name": "Dead", "description": "The character is dead" @@ -2435,7 +2439,11 @@ "overlay": { "label": "Overlay Effect" }, "characterDefault": { "label": "Character Default Defeated Status" }, "adversaryDefault": { "label": "Adversary Default Defeated Status" }, - "companionDefault": { "label": "Companion Default Defeated Status" } + "companionDefault": { "label": "Companion Default Defeated Status" }, + "deathMove": { "label": "Death Move" }, + "dead": { "label": "Dead" }, + "defeated": { "label": "Defeated" }, + "unconscious": { "label": "Unconscious" } }, "hopeFear": { "label": "Hope & Fear", diff --git a/module/applications/dialogs/deathMove.mjs b/module/applications/dialogs/deathMove.mjs index 01df6057..d1b9379b 100644 --- a/module/applications/dialogs/deathMove.mjs +++ b/module/applications/dialogs/deathMove.mjs @@ -54,10 +54,9 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV if (!config.roll.fate) return; + let returnMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar'); if (config.roll.fate.value <= this.actor.system.levelData.level.current) { - // apply scarring - for now directly apply - later add a button. const newScarAmount = this.actor.system.scars + 1; - await this.actor.update({ system: { scars: newScarAmount @@ -65,13 +64,15 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV }); if (newScarAmount >= this.actor.system.resources.hope.max) { + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.dead.id); return game.i18n.format('DAGGERHEART.UI.Chat.deathMove.journeysEnd', { scars: newScarAmount }); } - return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.gainScar'); + returnMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.gainScar'); } - return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar'); + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.unconscious.id); + return returnMessage; } async handleRiskItAll() { @@ -118,6 +119,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV } if (config.roll.result.duality == -1) { + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.dead.id); chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllFailure'); } @@ -141,6 +143,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV } ]); + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.dead.id); return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.blazeOfGlory'); } diff --git a/module/config/generalConfig.mjs b/module/config/generalConfig.mjs index 37894644..be1dfce1 100644 --- a/module/config/generalConfig.mjs +++ b/module/config/generalConfig.mjs @@ -171,7 +171,7 @@ export const defeatedConditions = () => { acc[key] = { ...choice, img: defeated[`${choice.id}Icon`], - description: `DAGGERHEART.CONFIG.Condition.${choice.id}.description` + description: game.i18n.localize(`DAGGERHEART.CONFIG.Condition.${choice.id}.description`) }; return acc; @@ -179,6 +179,10 @@ export const defeatedConditions = () => { }; export const defeatedConditionChoices = { + deathMove: { + id: 'deathMove', + name: 'DAGGERHEART.CONFIG.Condition.deathMove.name' + }, defeated: { id: 'defeated', name: 'DAGGERHEART.CONFIG.Condition.defeated.name' diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index a7f99ca8..12396384 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -549,7 +549,18 @@ export default class DhCharacter extends BaseDataActor { } get deathMoveViable() { - return this.resources.hitPoints.max > 0 && this.resources.hitPoints.value >= this.resources.hitPoints.max; + const { characterDefault } = game.settings.get( + CONFIG.DH.id, + CONFIG.DH.SETTINGS.gameSettings.Automation + ).defeated; + const deathMoveOutcomeStatuses = Object.keys(CONFIG.DH.GENERAL.defeatedConditionChoices).filter( + key => key !== characterDefault + ); + const deathMoveNotResolved = this.parent.statuses.every(status => !deathMoveOutcomeStatuses.includes(status)); + + const allHitPointsMarked = + this.resources.hitPoints.max > 0 && this.resources.hitPoints.value >= this.resources.hitPoints.max; + return deathMoveNotResolved && allHitPointsMarked; } get armorApplicableDamageTypes() { diff --git a/module/data/settings/Automation.mjs b/module/data/settings/Automation.mjs index bff0bae9..436f0eb7 100644 --- a/module/data/settings/Automation.mjs +++ b/module/data/settings/Automation.mjs @@ -58,7 +58,7 @@ export default class DhAutomation extends foundry.abstract.DataModel { defeated: new fields.SchemaField({ enabled: new fields.BooleanField({ required: true, - initial: false, + initial: true, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.enabled.label' }), overlay: new fields.BooleanField({ @@ -69,7 +69,7 @@ export default class DhAutomation extends foundry.abstract.DataModel { characterDefault: new fields.StringField({ required: true, choices: CONFIG.DH.GENERAL.defeatedConditionChoices, - initial: CONFIG.DH.GENERAL.defeatedConditionChoices.unconscious.id, + initial: CONFIG.DH.GENERAL.defeatedConditionChoices.deathMove.id, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.characterDefault.label' }), adversaryDefault: new fields.StringField({ @@ -84,23 +84,29 @@ export default class DhAutomation extends foundry.abstract.DataModel { initial: CONFIG.DH.GENERAL.defeatedConditionChoices.defeated.id, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.companionDefault.label' }), + deathMoveIcon: new fields.FilePathField({ + initial: 'icons/magic/life/heart-cross-purple-orange.webp', + categories: ['IMAGE'], + base64: false, + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.deathMove.label' + }), deadIcon: new fields.FilePathField({ initial: 'icons/magic/death/grave-tombstone-glow-teal.webp', categories: ['IMAGE'], base64: false, - label: 'Dead' + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.dead.label' }), defeatedIcon: new fields.FilePathField({ initial: 'icons/magic/control/fear-fright-mask-orange.webp', categories: ['IMAGE'], base64: false, - label: 'Defeated' + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.defeated.label' }), unconsciousIcon: new fields.FilePathField({ initial: 'icons/magic/control/sleep-bubble-purple.webp', categories: ['IMAGE'], base64: false, - label: 'Unconcious' + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.unconscious.label' }) }), roll: new fields.SchemaField({ diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index cec1a24d..d76d7447 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -849,8 +849,8 @@ export default class DhpActor extends Actor { async toggleDefeated(defeatedState) { const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).defeated; - const { unconscious, defeated, dead } = CONFIG.DH.GENERAL.conditions(); - const defeatedConditions = new Set([unconscious.id, defeated.id, dead.id]); + const { deathMove, unconscious, defeated, dead } = CONFIG.DH.GENERAL.conditions(); + const defeatedConditions = new Set([deathMove.id, unconscious.id, defeated.id, dead.id]); if (!defeatedState) { for (let defeatedId of defeatedConditions) { await this.toggleStatusEffect(defeatedId, { overlay: settings.overlay, active: defeatedState }); @@ -864,6 +864,18 @@ export default class DhpActor extends Actor { } } + async setDeathMoveDefeated(defeatedIconId) { + const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).defeated; + const actorDefault = settings[`${this.type}Default`]; + if (!settings.enabled || !settings.enabled || !actorDefault || actorDefault === defeatedIconId) return; + + for (let defeatedId of Object.keys(CONFIG.DH.GENERAL.defeatedConditionChoices)) { + await this.toggleStatusEffect(defeatedId, { overlay: settings.overlay, active: false }); + } + + if (defeatedIconId) await this.toggleStatusEffect(defeatedIconId, { overlay: settings.overlay, active: true }); + } + queueScrollText(scrollingTextData) { this.#scrollTextQueue.push(...scrollingTextData.map(data => () => createScrollText(this, data))); if (!this.#scrollTextInterval) { diff --git a/templates/settings/automation-settings/rules.hbs b/templates/settings/automation-settings/rules.hbs index a12c2999..24f0b262 100644 --- a/templates/settings/automation-settings/rules.hbs +++ b/templates/settings/automation-settings/rules.hbs @@ -9,11 +9,12 @@ {{formGroup settingFields.schema.fields.defeated.fields.enabled value=settingFields._source.defeated.enabled localize=true}} - {{formGroup settingFields.schema.fields.defeated.fields.overlay value=settingFields._source.defeated.overlay localize=true}} + {{formGroup settingFields.schema.fields.defeated.fields.overlay value=settingFields._source.defeated.overlay localize=true}} {{formGroup settingFields.schema.fields.defeated.fields.characterDefault value=settingFields._source.defeated.characterDefault labelAttr="name" localize=true}} {{formGroup settingFields.schema.fields.defeated.fields.adversaryDefault value=settingFields._source.defeated.adversaryDefault labelAttr="name" localize=true}} {{formGroup settingFields.schema.fields.defeated.fields.companionDefault value=settingFields._source.defeated.companionDefault labelAttr="name" localize=true}} + {{formGroup settingFields.schema.fields.defeated.fields.deathMoveIcon value=settingFields._source.defeated.deathMoveIcon localize=true}} {{formGroup settingFields.schema.fields.defeated.fields.deadIcon value=settingFields._source.defeated.deadIcon localize=true}} {{formGroup settingFields.schema.fields.defeated.fields.defeatedIcon value=settingFields._source.defeated.defeatedIcon localize=true}} {{formGroup settingFields.schema.fields.defeated.fields.unconsciousIcon value=settingFields._source.defeated.unconsciousIcon localize=true}} From 3725fc29ef8c8d23f364c03d6d070a4e13415614 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 21 Jan 2026 02:52:07 +0100 Subject: [PATCH 06/95] [Feature] Seaborne Improvement (#1553) * Added a max to KnowTheTide. Added a onFear trigger to increase the resource * . * Added a notification message when KnowTheTide gains a token --- lang/en.json | 3 +- module/data/registeredTriggers.mjs | 1 + ...eature_Know_the_Tide_07x6Qe6qMzDw2xN4.json | 42 +++++++++++++++++-- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lang/en.json b/lang/en.json index dda78410..37cd627e 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2876,7 +2876,8 @@ "documentIsMissing": "The {documentType} is missing from the world.", "tokenActorMissing": "{name} is missing an Actor", "tokenActorsMissing": "[{names}] missing Actors", - "domainTouchRequirement": "This domain card requires {nr} {domain} cards in the loadout to be used" + "domainTouchRequirement": "This domain card requires {nr} {domain} cards in the loadout to be used", + "knowTheTide": "Know The Tide gained a token" }, "Sidebar": { "actorDirectory": { diff --git a/module/data/registeredTriggers.mjs b/module/data/registeredTriggers.mjs index 8a100585..3fd9f82c 100644 --- a/module/data/registeredTriggers.mjs +++ b/module/data/registeredTriggers.mjs @@ -20,6 +20,7 @@ export default class RegisteredTriggers extends Map { } registerItemTriggers(item, registerOverride) { + if (!item.actor || !item._stats.createdTime) return; for (const action of item.system.actions ?? []) { if (!action.actor) continue; diff --git a/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json b/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json index 069fe6ba..41f11a74 100644 --- a/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json +++ b/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json @@ -9,13 +9,47 @@ "resource": { "type": "simple", "value": 0, - "max": "", - "icon": "", - "recovery": null, + "max": "@system.levelData.level.current", + "icon": "fa-solid fa-water", + "recovery": "session", "diceStates": {}, "dieFaces": "d4" }, - "actions": {}, + "actions": { + "tFlus34KotJjHfTe": { + "type": "effect", + "_id": "tFlus34KotJjHfTe", + "systemPath": "actions", + "baseAction": false, + "description": "", + "chatDisplay": true, + "originItem": { + "type": "itemCollection" + }, + "actionType": "action", + "triggers": [ + { + "trigger": "fearRoll", + "triggeringActorType": "self", + "command": "const { max, value } = this.item.system.resource;\nconst maxValue = actor.system.levelData.level.current;\nconst afterUpdate = value+1;\nif (afterUpdate > maxValue) return;\n\nui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.knowTheTide'));\nreturn { updates: [{\n key: 'resource',\n itemId: this.item.id,\n target: this.item,\n value: 1,\n}]};" + } + ], + "cost": [], + "uses": { + "value": null, + "max": "", + "recovery": null, + "consumeOnSuccess": false + }, + "effects": [], + "target": { + "type": "any", + "amount": null + }, + "name": "Know The Tide", + "range": "" + } + }, "originItemType": null, "subType": null, "originId": null, From 6e5d7fb34c85ff77449e60f39343126d0a49289c Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 21 Jan 2026 02:52:42 +0100 Subject: [PATCH 07/95] Fixed so that saving throw damage mitigation works again (#1555) --- module/data/action/baseAction.mjs | 1 + module/documents/actor.mjs | 2 +- module/documents/chatMessage.mjs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/module/data/action/baseAction.mjs b/module/data/action/baseAction.mjs index b5f95aff..c403d4a9 100644 --- a/module/data/action/baseAction.mjs +++ b/module/data/action/baseAction.mjs @@ -241,6 +241,7 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel hasHealing: this.hasHealing, hasEffect: this.hasEffect, hasSave: this.hasSave, + onSave: this.save?.damageMod, isDirect: !!this.damage?.direct, selectedRollMode: game.settings.get('core', 'rollMode'), data: this.getRollData(), diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index 27c310ae..6d943c54 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -607,7 +607,7 @@ export default class DhpActor extends Actor { if (!updates.length) return; const hpDamage = updates.find(u => u.key === CONFIG.DH.GENERAL.healingTypes.hitPoints.id); - if (hpDamage) { + if (hpDamage?.value) { hpDamage.value = this.convertDamageToThreshold(hpDamage.value); if ( this.type === 'character' && diff --git a/module/documents/chatMessage.mjs b/module/documents/chatMessage.mjs index 2f23cc1a..4c6b0b93 100644 --- a/module/documents/chatMessage.mjs +++ b/module/documents/chatMessage.mjs @@ -179,7 +179,7 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage { config = foundry.utils.deepClone(this.system); config.event = event; - if (this.system.onSave) { + if (config.hasSave) { const pendingingSaves = targets.filter(t => t.saved.success === null); if (pendingingSaves.length) { const confirm = await foundry.applications.api.DialogV2.confirm({ From f659d08d58c236256f9d8d9ae48005e80594b7ff Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 21 Jan 2026 02:53:07 +0100 Subject: [PATCH 08/95] . (#1563) --- module/applications/ui/combatTracker.mjs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/module/applications/ui/combatTracker.mjs b/module/applications/ui/combatTracker.mjs index 288ba8ad..fc47f085 100644 --- a/module/applications/ui/combatTracker.mjs +++ b/module/applications/ui/combatTracker.mjs @@ -42,8 +42,8 @@ export default class DhCombatTracker extends foundry.applications.sidebar.tabs.C this.combats .find(x => x.active) ?.system?.extendedBattleToggles?.reduce((acc, toggle) => (acc ?? 0) + toggle.category, null) ?? null; - const maxBP = CONFIG.DH.ENCOUNTER.BaseBPPerEncounter(context.characters.length) + modifierBP; - const currentBP = AdversaryBPPerEncounter(context.adversaries, context.characters); + const maxBP = CONFIG.DH.ENCOUNTER.BaseBPPerEncounter(context.allCharacters.length) + modifierBP; + const currentBP = AdversaryBPPerEncounter(context.adversaries, context.allCharacters); Object.assign(context, { fear: game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear), @@ -73,9 +73,8 @@ export default class DhCombatTracker extends foundry.applications.sidebar.tabs.C Object.assign(context, { actionTokens: game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).actionTokens, adversaries, - characters: characters - ?.filter(x => !x.isNPC) - .filter(x => !spotlightQueueEnabled || x.system.spotlight.requestOrderIndex == 0), + allCharacters: characters, + characters: characters.filter(x => !spotlightQueueEnabled || x.system.spotlight.requestOrderIndex == 0), spotlightRequests }); } From 2aba7cf9213e41e970c31e4b3c0305c3217b0d17 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 21 Jan 2026 02:56:47 +0100 Subject: [PATCH 09/95] Changed to use a dialog to choose which parts are kept when reseting (#1557) --- lang/en.json | 7 ++ module/applications/dialogs/_module.mjs | 1 + .../dialogs/characterResetDialog.mjs | 105 ++++++++++++++++++ .../applications/sheets/actors/character.mjs | 26 +---- styles/less/dialog/character-reset/sheet.less | 27 +++++ styles/less/dialog/index.less | 2 + templates/dialogs/characterReset.hbs | 33 ++++++ 7 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 module/applications/dialogs/characterResetDialog.mjs create mode 100644 styles/less/dialog/character-reset/sheet.less create mode 100644 templates/dialogs/characterReset.hbs diff --git a/lang/en.json b/lang/en.json index 37cd627e..720a08c5 100755 --- a/lang/en.json +++ b/lang/en.json @@ -330,6 +330,12 @@ "title": "{actor} - Character Setup", "traitIncreases": "Trait Increases" }, + "CharacterReset": { + "title": "Reset Character", + "alwaysDeleteSection": "Deleted Data", + "optionalDeleteSection": "Optional Data", + "headerTitle": "Select which data you'd like to keep" + }, "CombatTracker": { "combatStarted": "Active", "giveSpotlight": "Give The Spotlight", @@ -2214,6 +2220,7 @@ "single": "Player", "plurial": "Players" }, + "portrait": "Portrait", "proficiency": "Proficiency", "quantity": "Quantity", "range": "Range", diff --git a/module/applications/dialogs/_module.mjs b/module/applications/dialogs/_module.mjs index d43045e6..4eda8579 100644 --- a/module/applications/dialogs/_module.mjs +++ b/module/applications/dialogs/_module.mjs @@ -1,5 +1,6 @@ export { default as AttributionDialog } from './attributionDialog.mjs'; export { default as BeastformDialog } from './beastformDialog.mjs'; +export { default as CharacterResetDialog } from './characterResetDialog.mjs'; export { default as d20RollDialog } from './d20RollDialog.mjs'; export { default as DamageDialog } from './damageDialog.mjs'; export { default as DamageReductionDialog } from './damageReductionDialog.mjs'; diff --git a/module/applications/dialogs/characterResetDialog.mjs b/module/applications/dialogs/characterResetDialog.mjs new file mode 100644 index 00000000..1f3f3d5a --- /dev/null +++ b/module/applications/dialogs/characterResetDialog.mjs @@ -0,0 +1,105 @@ +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +export default class CharacterResetDialog extends HandlebarsApplicationMixin(ApplicationV2) { + constructor(actor, options = {}) { + super(options); + + this.actor = actor; + this.data = { + delete: { + class: { keep: false, label: 'TYPES.Item.class' }, + subclass: { keep: false, label: 'TYPES.Item.subclass' }, + ancestry: { keep: false, label: 'TYPES.Item.ancestry' }, + community: { keep: false, label: 'TYPES.Item.community' } + }, + optional: { + portrait: { keep: true, label: 'DAGGERHEART.GENERAL.portrait' }, + name: { keep: true, label: 'Name' }, + biography: { keep: true, label: 'DAGGERHEART.GENERAL.Tabs.biography' }, + inventory: { keep: false, label: 'DAGGERHEART.GENERAL.inventory' } + } + }; + } + + static DEFAULT_OPTIONS = { + tag: 'form', + classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'character-reset'], + window: { + icon: 'fa-solid fa-arrow-rotate-left', + title: 'DAGGERHEART.APPLICATIONS.CharacterReset.title' + }, + actions: { + finishSelection: this.#finishSelection + }, + form: { + handler: this.updateData, + submitOnChange: true, + submitOnClose: false + } + }; + + /** @override */ + static PARTS = { + resourceDice: { + id: 'resourceDice', + template: 'systems/daggerheart/templates/dialogs/characterReset.hbs' + } + }; + + async _prepareContext(_options) { + const context = await super._prepareContext(_options); + context.data = this.data; + + return context; + } + + static async updateData(event, _, formData) { + const { data } = foundry.utils.expandObject(formData.object); + + this.data = foundry.utils.mergeObject(this.data, data); + this.render(); + } + + static getUpdateData() { + const update = {}; + if (!this.data.optional.portrait) update.if(!this.data.optional.biography); + + if (!this.data.optional.inventory) return update; + } + + static async #finishSelection() { + const update = {}; + if (!this.data.optional.name.keep) { + const defaultName = game.system.api.documents.DhpActor.defaultName({ type: 'character' }); + foundry.utils.setProperty(update, 'name', defaultName); + foundry.utils.setProperty(update, 'prototypeToken.name', defaultName); + } + + if (!this.data.optional.portrait.keep) { + foundry.utils.setProperty(update, 'img', this.actor.schema.fields.img.initial(this.actor)); + foundry.utils.setProperty(update, 'prototypeToken.==texture', {}); + foundry.utils.setProperty(update, 'prototypeToken.==ring', {}); + } + + if (this.data.optional.biography.keep) + foundry.utils.setProperty(update, 'system.biography', this.actor.system.biography); + + if (this.data.optional.inventory.keep) foundry.utils.setProperty(update, 'system.gold', this.actor.system.gold); + + const { system, ...rest } = update; + await this.actor.update({ + ...rest, + '==system': system ?? {} + }); + + const inventoryItemTypes = ['weapon', 'armor', 'consumable', 'loot']; + await this.actor.deleteEmbeddedDocuments( + 'Item', + this.actor.items + .filter(x => !inventoryItemTypes.includes(x.type) || !this.data.optional.inventory.keep) + .map(x => x.id) + ); + + this.close(); + } +} diff --git a/module/applications/sheets/actors/character.mjs b/module/applications/sheets/actors/character.mjs index 5c6bac3a..4ecaeb06 100644 --- a/module/applications/sheets/actors/character.mjs +++ b/module/applications/sheets/actors/character.mjs @@ -669,26 +669,7 @@ export default class CharacterSheet extends DHBaseActorSheet { * Resets the character data and removes all embedded documents. */ static async #resetCharacter() { - const confirmed = await foundry.applications.api.DialogV2.confirm({ - window: { - title: game.i18n.localize('DAGGERHEART.ACTORS.Character.resetCharacterConfirmationTitle') - }, - content: game.i18n.localize('DAGGERHEART.ACTORS.Character.resetCharacterConfirmationContent') - }); - - if (!confirmed) return; - - await this.document.update({ - '==system': {} - }); - await this.document.deleteEmbeddedDocuments( - 'Item', - this.document.items.map(x => x.id) - ); - await this.document.deleteEmbeddedDocuments( - 'ActiveEffect', - this.document.effects.map(x => x.id) - ); + new game.system.api.applications.dialogs.CharacterResetDialog(this.document).render({ force: true }); } /** @@ -753,8 +734,9 @@ export default class CharacterSheet extends DHBaseActorSheet { if (!result) return; /* This could be avoided by baking config.costs into config.resourceUpdates. Didn't feel like messing with it at the time */ - const costResources = result.costs?.filter(x => x.enabled) - .map(cost => ({ ...cost, value: -cost.value, total: -cost.total })) || {}; + const costResources = + result.costs?.filter(x => x.enabled).map(cost => ({ ...cost, value: -cost.value, total: -cost.total })) || + {}; config.resourceUpdates.addResources(costResources); await config.resourceUpdates.updateResources(); } diff --git a/styles/less/dialog/character-reset/sheet.less b/styles/less/dialog/character-reset/sheet.less new file mode 100644 index 00000000..44312a3e --- /dev/null +++ b/styles/less/dialog/character-reset/sheet.less @@ -0,0 +1,27 @@ +.daggerheart.dh-style.dialog.views.character-reset { + .character-reset-container { + display: flex; + flex-direction: column; + gap: 8px; + + legend { + padding: 0 4px; + } + + .character-reset-header { + font-size: var(--font-size-18); + text-align: center; + } + + .reset-data-container { + display: grid; + grid-template-columns: 3fr 2fr; + align-items: center; + gap: 4px; + + label { + font-weight: bold; + } + } + } +} diff --git a/styles/less/dialog/index.less b/styles/less/dialog/index.less index 01a3f954..733cdd1c 100644 --- a/styles/less/dialog/index.less +++ b/styles/less/dialog/index.less @@ -41,3 +41,5 @@ @import './settings/change-currency-icon.less'; @import './risk-it-all/sheet.less'; + +@import './character-reset/sheet.less'; diff --git a/templates/dialogs/characterReset.hbs b/templates/dialogs/characterReset.hbs new file mode 100644 index 00000000..298826e5 --- /dev/null +++ b/templates/dialogs/characterReset.hbs @@ -0,0 +1,33 @@ +
+
+
{{localize "DAGGERHEART.APPLICATIONS.CharacterReset.headerTitle"}}
+ +
+ {{localize "DAGGERHEART.APPLICATIONS.CharacterReset.alwaysDeleteSection"}} + +
+ {{#each this.data.delete as | data key|}} +
+ + +
+ {{/each}} +
+
+ +
+ {{localize "DAGGERHEART.APPLICATIONS.CharacterReset.optionalDeleteSection"}} + +
+ {{#each this.data.optional as | data key|}} +
+ + +
+ {{/each}} +
+
+ + +
+
\ No newline at end of file From 38fb00bd1058c24af88475157ade74996f0ddfef Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:47:03 +0100 Subject: [PATCH 10/95] Fixed DowntimeMoves and ItemFeatures reset functions (#1568) --- lang/en.json | 1 + .../settings/homebrewSettings.mjs | 32 +++++++++++++++++-- .../settings/homebrew-settings/downtime.hbs | 4 +-- .../homebrew-settings/itemFeatures.hbs | 4 +-- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lang/en.json b/lang/en.json index 8e64ab7d..18048d9d 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2506,6 +2506,7 @@ "itemFeatures": "Item Features", "nrChoices": "# Moves Per Rest", "resetMovesTitle": "Reset {type} Downtime Moves", + "resetItemFeaturesTitle": "Reset {type}", "resetMovesText": "Are you sure you want to reset?", "FIELDS": { "maxFear": { "label": "Max Fear" }, diff --git a/module/applications/settings/homebrewSettings.mjs b/module/applications/settings/homebrewSettings.mjs index 3c4486c1..6e2e665d 100644 --- a/module/applications/settings/homebrewSettings.mjs +++ b/module/applications/settings/homebrewSettings.mjs @@ -36,7 +36,8 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli addItem: this.addItem, editItem: this.editItem, removeItem: this.removeItem, - resetMoves: this.resetMoves, + resetDowntimeMoves: this.resetDowntimeMoves, + resetItemFeatures: this.resetItemFeatures, addDomain: this.addDomain, toggleSelectedDomain: this.toggleSelectedDomain, deleteDomain: this.deleteDomain, @@ -232,7 +233,7 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli this.render(); } - static async resetMoves(_, target) { + static async resetDowntimeMoves(_, target) { const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: game.i18n.format('DAGGERHEART.SETTINGS.Homebrew.resetMovesTitle', { @@ -266,7 +267,7 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli ...move, name: game.i18n.localize(move.name), description: game.i18n.localize(move.description), - actions: move.actions.reduce((acc, key) => { + actions: Object.keys(move.actions).reduce((acc, key) => { const action = move.actions[key]; acc[key] = { ...action, @@ -293,6 +294,31 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli this.render(); } + static async resetItemFeatures(_, target) { + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { + title: game.i18n.format('DAGGERHEART.SETTINGS.Homebrew.resetItemFeaturesTitle', { + type: game.i18n.localize(`DAGGERHEART.GENERAL.${target.dataset.type}`) + }) + }, + content: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.resetMovesText') + }); + + if (!confirmed) return; + + await this.settings.updateSource({ + [`itemFeatures.${target.dataset.type}`]: Object.keys( + this.settings.itemFeatures[target.dataset.type] + ).reduce((acc, key) => { + acc[`-=${key}`] = null; + + return acc; + }, {}) + }); + + this.render(); + } + static async addDomain(event) { event.preventDefault(); const content = new foundry.data.fields.StringField({ diff --git a/templates/settings/homebrew-settings/downtime.hbs b/templates/settings/homebrew-settings/downtime.hbs index 8612f3d5..25f4c95d 100644 --- a/templates/settings/homebrew-settings/downtime.hbs +++ b/templates/settings/homebrew-settings/downtime.hbs @@ -10,7 +10,7 @@ {{localize "DAGGERHEART.APPLICATIONS.Downtime.longRest.title"}} - +
@@ -31,7 +31,7 @@ {{localize "DAGGERHEART.APPLICATIONS.Downtime.shortRest.title"}} - +
diff --git a/templates/settings/homebrew-settings/itemFeatures.hbs b/templates/settings/homebrew-settings/itemFeatures.hbs index 22c23af6..df3419fa 100644 --- a/templates/settings/homebrew-settings/itemFeatures.hbs +++ b/templates/settings/homebrew-settings/itemFeatures.hbs @@ -8,7 +8,7 @@ {{localize "DAGGERHEART.GENERAL.weaponFeatures"}} - +
@@ -22,7 +22,7 @@ {{localize "DAGGERHEART.GENERAL.armorFeatures"}} - +
From c90875fa7cb14553fd223362433daf186af3c11c Mon Sep 17 00:00:00 2001 From: WBHarry Date: Wed, 21 Jan 2026 14:05:39 +0100 Subject: [PATCH 11/95] Changed ResetDialog to have all optional sections initially kept --- module/applications/dialogs/characterResetDialog.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/applications/dialogs/characterResetDialog.mjs b/module/applications/dialogs/characterResetDialog.mjs index 1f3f3d5a..0836af9c 100644 --- a/module/applications/dialogs/characterResetDialog.mjs +++ b/module/applications/dialogs/characterResetDialog.mjs @@ -16,7 +16,7 @@ export default class CharacterResetDialog extends HandlebarsApplicationMixin(App portrait: { keep: true, label: 'DAGGERHEART.GENERAL.portrait' }, name: { keep: true, label: 'Name' }, biography: { keep: true, label: 'DAGGERHEART.GENERAL.Tabs.biography' }, - inventory: { keep: false, label: 'DAGGERHEART.GENERAL.inventory' } + inventory: { keep: true, label: 'DAGGERHEART.GENERAL.inventory' } } }; } From 1f7d4d6f1e864fe1fd38aa59601a52f0e28c6553 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:52:22 +0100 Subject: [PATCH 12/95] Fixed an error where a player having their token initially selected caused an error in effectsDisplay.mjs (#1569) --- module/applications/ui/effectsDisplay.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/module/applications/ui/effectsDisplay.mjs b/module/applications/ui/effectsDisplay.mjs index 0875e783..8c0c939c 100644 --- a/module/applications/ui/effectsDisplay.mjs +++ b/module/applications/ui/effectsDisplay.mjs @@ -76,6 +76,8 @@ export default class DhEffectsDisplay extends HandlebarsApplicationMixin(Applica }; toggleHidden(token, focused) { + if (!this.element) return; + const effects = DhEffectsDisplay.getTokenEffects(focused ? token : null); this.element.hidden = effects.length === 0; From fa3b3fa0a81401ce6e8022582d7bb096004d4806 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:21:42 +0100 Subject: [PATCH 13/95] [Fix] Damage Rerolls (#1566) * Fixed so that damage rerolls work again * Set default data for a roll instead and fix title (#1570) * Set default data for a roll instead and fix title * Ensure same options object is used --------- Co-authored-by: Carlos Fernandez --- lang/en.json | 1 + module/dice/dhRoll.mjs | 2 +- system.json | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lang/en.json b/lang/en.json index 18048d9d..33f8c514 100755 --- a/lang/en.json +++ b/lang/en.json @@ -607,6 +607,7 @@ }, "RerollDialog": { "title": "Reroll", + "damageTitle": "Reroll Damage", "deselectDiceNotification": "Deselect one of the selected dice first", "acceptCurrentRolls": "Accept Current Rolls" }, diff --git a/module/dice/dhRoll.mjs b/module/dice/dhRoll.mjs index a5ac5091..1977c7ea 100644 --- a/module/dice/dhRoll.mjs +++ b/module/dice/dhRoll.mjs @@ -3,7 +3,7 @@ import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs'; export default class DHRoll extends Roll { baseTerms = []; constructor(formula, data = {}, options = {}) { - super(formula, data, options); + super(formula, data, foundry.utils.mergeObject(options, { roll: [] }, { overwrite: false })); options.bonusEffects = this.bonusEffectBuilder(); if (!this.data || !Object.keys(this.data).length) this.data = options.data; } diff --git a/system.json b/system.json index 2c68f785..6521c6d6 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.5.4", + "version": "1.5.5", "compatibility": { "minimum": "13.346", "verified": "13.351", From 21ef288283bef655918412b25fe4bf692e1e97d1 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:51:14 +0100 Subject: [PATCH 14/95] Fixed when users drag in compendium environments to the sceneEnvironments (#1573) --- .../applications/scene/sceneConfigSettings.mjs | 13 ++++++++++++- module/systemRegistration/migrations.mjs | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/module/applications/scene/sceneConfigSettings.mjs b/module/applications/scene/sceneConfigSettings.mjs index 8a58db5c..98e18f09 100644 --- a/module/applications/scene/sceneConfigSettings.mjs +++ b/module/applications/scene/sceneConfigSettings.mjs @@ -65,8 +65,15 @@ export default class DhSceneConfigSettings extends foundry.applications.sheets.S 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') { + let sceneUuid = data.uuid; + if (item.pack) { + const inWorldActor = await game.system.api.documents.DhpActor.create([item.toObject()]); + if (!inWorldActor.length) return; + sceneUuid = inWorldActor[0].uuid; + } + await this.daggerheartFlag.updateSource({ - sceneEnvironments: [...this.daggerheartFlag.sceneEnvironments, data.uuid] + sceneEnvironments: [...this.daggerheartFlag.sceneEnvironments, sceneUuid] }); this.render({ internalRefresh: true }); } @@ -97,6 +104,10 @@ export default class DhSceneConfigSettings extends foundry.applications.sheets.S /** @override */ async _processSubmitData(event, form, submitData, options) { submitData.flags.daggerheart = this.daggerheartFlag.toObject(); + submitData.flags.daggerheart.sceneEnvironments = submitData.flags.daggerheart.sceneEnvironments.filter(x => + foundry.utils.fromUuidSync(x) + ); + for (const key of Object.keys(this.document._source.flags.daggerheart?.sceneEnvironments ?? {})) { if (!submitData.flags.daggerheart.sceneEnvironments[key]) { submitData.flags.daggerheart.sceneEnvironments[`-=${key}`] = null; diff --git a/module/systemRegistration/migrations.mjs b/module/systemRegistration/migrations.mjs index b3116459..086647bf 100644 --- a/module/systemRegistration/migrations.mjs +++ b/module/systemRegistration/migrations.mjs @@ -210,6 +210,22 @@ export async function runMigrations() { lastMigrationVersion = '1.2.7'; } + + if (foundry.utils.isNewerVersion('1.5.5', lastMigrationVersion)) { + for (const scene of game.scenes) { + if (!scene.flags.daggerheart) continue; + const systemData = new game.system.api.data.scenes.DHScene(scene.flags.daggerheart); + const sceneEnvironments = systemData.sceneEnvironments; + + const newEnvironments = sceneEnvironments.filter(x => !x?.pack); + if (newEnvironments.length !== sceneEnvironments.length) + await scene.update({ 'flags.daggerheart.sceneEnvironments': newEnvironments }); + } + + ui.nav.render(true); + + lastMigrationVersion = '1.5.5'; + } //#endregion await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.LastMigrationVersion, lastMigrationVersion); From cbd268ea1f51ab59a1cede31fb28c6f91d3c112c Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:10:30 +0100 Subject: [PATCH 15/95] [Feature] DR Command Resources (#1572) * Dr chatcommand and buttons now grant resources via automation by default. Optionally turned off via parameter noResources=true * . --- daggerheart.mjs | 4 +++- module/dice/dualityRoll.mjs | 2 +- module/enrichers/DualityRollEnricher.mjs | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/daggerheart.mjs b/daggerheart.mjs index 3abcd210..c3a5c348 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -296,6 +296,7 @@ Hooks.on('chatMessage', (_, message) => { ? CONFIG.DH.ACTIONS.advantageState.disadvantage.value : undefined; const difficulty = rollCommand.difficulty; + const grantResources = Boolean(rollCommand.grantResources); const target = getCommandTarget({ allowNull: true }); const title = traitValue @@ -312,7 +313,8 @@ Hooks.on('chatMessage', (_, message) => { title, label: game.i18n.localize('DAGGERHEART.GENERAL.dualityRoll'), actionType: null, - advantage + advantage, + grantResources }); return false; } diff --git a/module/dice/dualityRoll.mjs b/module/dice/dualityRoll.mjs index aaca7400..30b569ac 100644 --- a/module/dice/dualityRoll.mjs +++ b/module/dice/dualityRoll.mjs @@ -261,7 +261,7 @@ export default class DualityRoll extends D20Roll { } static async handleTriggers(roll, config) { - if (!config.source?.actor) return; + if (!config.source?.actor || config.skips?.triggers) return; const updates = []; const dualityUpdates = await game.system.registeredTriggers.runTrigger( diff --git a/module/enrichers/DualityRollEnricher.mjs b/module/enrichers/DualityRollEnricher.mjs index 536847f7..67728a37 100644 --- a/module/enrichers/DualityRollEnricher.mjs +++ b/module/enrichers/DualityRollEnricher.mjs @@ -47,6 +47,7 @@ function getDualityMessage(roll, flavor) { ${roll?.trait && abilities[roll.trait] ? `data-trait="${roll.trait}"` : ''} ${roll?.advantage ? 'data-advantage="true"' : ''} ${roll?.disadvantage ? 'data-disadvantage="true"' : ''} + ${roll?.grantResources ? 'data-grant-resources="true"' : ''} > ${roll?.reaction ? '' : ''} ${label} @@ -63,7 +64,8 @@ export const renderDualityButton = async event => { traitValue = button.dataset.trait?.toLowerCase(), target = getCommandTarget({ allowNull: true }), difficulty = button.dataset.difficulty, - advantage = button.dataset.advantage ? Number(button.dataset.advantage) : undefined; + advantage = button.dataset.advantage ? Number(button.dataset.advantage) : undefined, + grantResources = Boolean(button.dataset?.grantResources); await enrichedDualityRoll( { @@ -73,14 +75,15 @@ export const renderDualityButton = async event => { difficulty, title: button.dataset.title, label: button.dataset.label, - advantage + advantage, + grantResources }, event ); }; export const enrichedDualityRoll = async ( - { reaction, traitValue, target, difficulty, title, label, advantage }, + { reaction, traitValue, target, difficulty, title, label, advantage, grantResources }, event ) => { const config = { @@ -93,12 +96,17 @@ export const enrichedDualityRoll = async ( advantage, type: reaction ? 'reaction' : null }, + skips: { + resources: !grantResources, + triggers: !grantResources + }, type: 'trait', hasRoll: true }; if (target) { - await target.diceRoll(config); + const result = await target.diceRoll(config); + result.resourceUpdates.updateResources(); } else { // For no target, call DualityRoll directly with basic data config.data = { experiences: {}, traits: {}, rules: {} }; From bdb89973248dc0001814a3e393697c850c528446 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:15:07 +0100 Subject: [PATCH 16/95] [Feature] 1543 - SceneEnvironment Trigger Registration (#1564) * Added trigger Registration/Unregistration for scene environments * Fixed so that saving throw damage mitigation works again (#1555) * . (#1563) * Fixed DowntimeMoves and ItemFeatures reset functions (#1568) * Fixed an error where a player having their token initially selected caused an error in effectsDisplay.mjs (#1569) * [Fix] Damage Rerolls (#1566) * Fixed so that damage rerolls work again * Set default data for a roll instead and fix title (#1570) * Set default data for a roll instead and fix title * Ensure same options object is used --------- Co-authored-by: Carlos Fernandez * Fixed when users drag in compendium environments to the sceneEnvironments (#1573) * . --------- Co-authored-by: Carlos Fernandez --- module/data/registeredTriggers.mjs | 34 ++++++++++++++++++++---------- module/documents/scene.mjs | 21 ++++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/module/data/registeredTriggers.mjs b/module/data/registeredTriggers.mjs index 3fd9f82c..ee4f3b49 100644 --- a/module/data/registeredTriggers.mjs +++ b/module/data/registeredTriggers.mjs @@ -72,10 +72,21 @@ export default class RegisteredTriggers extends Map { } } + unregisterSceneEnvironmentTriggers(flagSystemData) { + const sceneData = new game.system.api.data.scenes.DHScene(flagSystemData); + for (const environment of sceneData.sceneEnvironments) { + if (environment.pack) continue; + this.unregisterItemTriggers(environment.system.features); + } + } + unregisterSceneTriggers(scene) { + this.unregisterSceneEnvironmentTriggers(scene.flags.daggerheart); + for (const triggerKey of Object.keys(CONFIG.DH.TRIGGER.triggers)) { const existingTrigger = this.get(triggerKey); if (!existingTrigger) continue; + const filtered = new Map(); for (const [uuid, data] of existingTrigger.entries()) { if (!uuid.startsWith(scene.uuid)) filtered.set(uuid, data); @@ -84,14 +95,17 @@ export default class RegisteredTriggers extends Map { } } + registerSceneEnvironmentTriggers(flagSystemData) { + const sceneData = new game.system.api.data.scenes.DHScene(flagSystemData); + for (const environment of sceneData.sceneEnvironments) { + for (const feature of environment.system.features) { + if (feature) this.registerItemTriggers(feature, true); + } + } + } + registerSceneTriggers(scene) { - /* TODO: Finish sceneEnvironment registration and unreg */ - // const systemData = new game.system.api.data.scenes.DHScene(scene.flags.daggerheart); - // for (const environment of systemData.sceneEnvironments) { - // for (const feature of environment.system.features) { - // if(feature) this.registerItemTriggers(feature, true); - // } - // } + this.registerSceneEnvironmentTriggers(scene.flags.daggerheart); for (const actor of scene.tokens.filter(x => x.actor).map(x => x.actor)) { if (actor.prototypeToken.actorLink) continue; @@ -108,13 +122,11 @@ export default class RegisteredTriggers extends Map { if (!triggerSettings.enabled) return updates; const dualityTrigger = this.get(trigger); - if (dualityTrigger) { - const tokenBoundActors = ['adversary', 'environment']; - const triggerActors = ['character', ...tokenBoundActors]; + if (dualityTrigger?.size) { + const triggerActors = ['character', 'adversary', 'environment']; for (let [itemUuid, { actor: actorUuid, triggeringActorType, commands }] of dualityTrigger.entries()) { const actor = await foundry.utils.fromUuid(actorUuid); if (!actor || !triggerActors.includes(actor.type)) continue; - if (tokenBoundActors.includes(actor.type) && !actor.getActiveTokens().length) continue; const triggerData = CONFIG.DH.TRIGGER.triggers[trigger]; if (triggerData.usesActor && triggeringActorType !== 'any') { diff --git a/module/documents/scene.mjs b/module/documents/scene.mjs index 7f880b1d..9e2a3f5b 100644 --- a/module/documents/scene.mjs +++ b/module/documents/scene.mjs @@ -51,6 +51,27 @@ export default class DhScene extends Scene { } } + async _preUpdate(changes, options, user) { + const allowed = await super._preUpdate(changes, options, user); + if (allowed === false) return false; + + if (changes.flags?.daggerheart) { + if (this._source.flags.daggerheart) { + const unregisterTriggerData = this._source.flags.daggerheart.sceneEnvironments.reduce( + (acc, env) => { + if (!changes.flags.daggerheart.sceneEnvironments.includes(env)) acc.sceneEnvironments.push(env); + + return acc; + }, + { ...this._source.flags.daggerheart, sceneEnvironments: [] } + ); + game.system.registeredTriggers.unregisterSceneEnvironmentTriggers(unregisterTriggerData); + } + + game.system.registeredTriggers.registerSceneEnvironmentTriggers(changes.flags.daggerheart); + } + } + _onDelete(options, userId) { super._onDelete(options, userId); From cb998860d91cad3fa4d295677b78606ca156116c Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:19:16 +0100 Subject: [PATCH 17/95] Spellcastmodifiers were not being sorted correctly for use (#1578) --- module/data/actor/character.mjs | 2 +- templates/sheets/actors/character/sidebar.hbs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index 12396384..47660da4 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -368,7 +368,7 @@ export default class DhCharacter extends BaseDataActor { const modifiers = subClasses ?.map(sc => ({ ...this.traits[sc.system.spellcastingTrait], key: sc.system.spellcastingTrait })) .filter(x => x); - return modifiers.sort((a, b) => a.value - b.value)[0]; + return modifiers.sort((a, b) => (b.value ?? 0) - (a.value ?? 0))[0]; } get spellcastModifier() { diff --git a/templates/sheets/actors/character/sidebar.hbs b/templates/sheets/actors/character/sidebar.hbs index 0db2bf42..24e68e02 100644 --- a/templates/sheets/actors/character/sidebar.hbs +++ b/templates/sheets/actors/character/sidebar.hbs @@ -1,12 +1,12 @@
+
+ {{/each}} +
+
\ No newline at end of file diff --git a/templates/ui/itemBrowser/sidebar.hbs b/templates/ui/itemBrowser/sidebar.hbs index 28a34a22..a829f8c5 100644 --- a/templates/ui/itemBrowser/sidebar.hbs +++ b/templates/ui/itemBrowser/sidebar.hbs @@ -1,7 +1,8 @@
+ {{#if isGM}}{{/if}}
{{#each compendiums}} -
{{label}}
+
{{label}}
{{#if folders.length}}
From a65514b1c1856de0cfdf97e7d71a1f33564033fc Mon Sep 17 00:00:00 2001 From: WBHarry Date: Mon, 9 Feb 2026 14:25:56 +0100 Subject: [PATCH 63/95] Improved Downtime Prepare translation --- lang/en.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lang/en.json b/lang/en.json index b1883221..618beefc 100755 --- a/lang/en.json +++ b/lang/en.json @@ -457,12 +457,12 @@ "name": "Clear Stress" }, "prepare": { - "description": "Describe how you are preparing for the next day's adventure, then gain a Hope. If you choose to Prepare with one or more members of your party, you may each take two Hope.", + "description": "Describe how you are preparing for the next day's adventure, then gain a Hope.", "name": "Prepare" }, "prepareWithFriends": { - "description": "Describe how you are preparing for the next day's adventure, then gain a Hope. If you choose to Prepare with one or more members of your party, you may each take two Hope.", - "name": "Prepare (with Friends)" + "description": "You prepare with one or more members of your party, and you each gain 2 Hope.", + "name": "Prepare (together)" }, "repairArmor": { "description": "Describe how you spend time repairing your armor and clear all of its Armor Slots. You may also do this to an ally's armor instead.", @@ -494,11 +494,11 @@ }, "prepare": { "name": "Prepare", - "description": "Describe how you prepare yourself for the path ahead, then gain a Hope. If you choose to Prepare with one or more members of your party, you each gain 2 Hope." + "description": "Describe how you prepare yourself for the path ahead, then gain a Hope." }, "prepareWithFriends": { - "name": "Prepare (with Friends)", - "description": "Describe how you prepare yourself for the path ahead, then gain a Hope. If you choose to Prepare with one or more members of your party, you each gain 2 Hope." + "name": "Prepare (together)", + "description": "You prepare with one or more members of your party, and you each gain 2 Hope." } }, "refreshable": { From 17ec77a3490cdad17abb86e09dbf17368f5cb5b9 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Wed, 11 Feb 2026 00:31:45 +0100 Subject: [PATCH 64/95] Fixed not being able to open the tokenConfig of actor-less tokens --- module/documents/token.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/documents/token.mjs b/module/documents/token.mjs index 6fd931d6..4ee7ce05 100644 --- a/module/documents/token.mjs +++ b/module/documents/token.mjs @@ -6,7 +6,7 @@ export default class DHToken extends CONFIG.Token.documentClass { const valueGroup = game.i18n.localize('TOKEN.BarValues'); const actorModel = typeKey ? game.system.api.data.actors[`Dh${typeKey.capitalize()}`] : null; const getLabel = path => { - const label = actorModel.schema.getField(path)?.label; + const label = actorModel?.schema.getField(path)?.label; return label ? game.i18n.localize(label) : path; }; From fa1933986867f6d531b639d5f026567f6217c0d4 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Wed, 11 Feb 2026 23:34:10 +0100 Subject: [PATCH 65/95] Fixed better sceneNavigation compatability --- .../ui/scene-navigation/scene-navigation.less | 55 ++++++++++--------- .../ui/sceneNavigation/scene-navigation.hbs | 30 +++++----- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/styles/less/ui/scene-navigation/scene-navigation.less b/styles/less/ui/scene-navigation/scene-navigation.less index 6b97ddec..38768658 100644 --- a/styles/less/ui/scene-navigation/scene-navigation.less +++ b/styles/less/ui/scene-navigation/scene-navigation.less @@ -1,36 +1,39 @@ #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-wrapper { + display: flex; + gap: 2px; + height: var(--control-size); + width: 100%; - .scene-environment { - padding: 0; + > ul { + margin: 0; + padding: 0; + } - img { - border-radius: 4px; - } + .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; - } + .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/ui/sceneNavigation/scene-navigation.hbs b/templates/ui/sceneNavigation/scene-navigation.hbs index 41e9e3e8..0bd59465 100644 --- a/templates/ui/sceneNavigation/scene-navigation.hbs +++ b/templates/ui/sceneNavigation/scene-navigation.hbs @@ -7,17 +7,19 @@ {{#each scenes.active as |scene|}}
  • -
    - {{scene.name}} - {{#if scene.users}} -
      - {{#each scene.users as |user|}} -
    • {{user.letter}}
    • - {{/each}} -
    - {{/if}} -
    +
      +
    • + {{scene.name}} + {{#if scene.users}} +
        + {{#each scene.users as |user|}} +
      • {{user.letter}}
      • + {{/each}} +
      + {{/if}} +
    • +
    {{#if scene.hasEnvironments}} {{/if}} @@ -27,9 +29,11 @@ {{#each scenes.inactive as |scene|}}
  • -
    +
      +
    • {{scene.name}} -
    +
  • +
  • {{/each}}
    From 95d4003045e4451181831c93ecdcdcce8140aee7 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:56:35 +0100 Subject: [PATCH 66/95] Fixed so that messages auto expand the description (#1650) --- module/applications/dialogs/deathMove.mjs | 1 - module/applications/dialogs/downtime.mjs | 6 +++++- module/data/fields/actionField.mjs | 5 ++++- system.json | 2 +- templates/ui/chat/action.hbs | 2 +- templates/ui/chat/deathMove.hbs | 2 +- templates/ui/chat/downtime.hbs | 2 +- 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/module/applications/dialogs/deathMove.mjs b/module/applications/dialogs/deathMove.mjs index a9141158..69ff758e 100644 --- a/module/applications/dialogs/deathMove.mjs +++ b/module/applications/dialogs/deathMove.mjs @@ -200,7 +200,6 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV description: game.i18n.localize(this.selectedMove.description), result: result, open: autoExpandDescription ? 'open' : '', - chevron: autoExpandDescription ? 'fa-chevron-up' : 'fa-chevron-down', showRiskItAllButton: this.showRiskItAllButton, riskItAllButtonLabel: this.riskItAllButtonLabel, riskItAllHope: this.riskItAllHope diff --git a/module/applications/dialogs/downtime.mjs b/module/applications/dialogs/downtime.mjs index 9a9a9ddb..4c01c2a9 100644 --- a/module/applications/dialogs/downtime.mjs +++ b/module/applications/dialogs/downtime.mjs @@ -196,6 +196,9 @@ export default class DhpDowntime extends HandlebarsApplicationMixin(ApplicationV .filter(x => x.testUserPermission(game.user, 'LIMITED')) .filter(x => x.uuid !== this.actor.uuid); + const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance) + .expandRollMessage?.desc; + const cls = getDocumentClass('ChatMessage'); const msg = { user: game.user.id, @@ -216,7 +219,8 @@ export default class DhpDowntime extends HandlebarsApplicationMixin(ApplicationV actor: { name: this.actor.name, img: this.actor.img }, moves: moves, characters: characters, - selfId: this.actor.uuid + selfId: this.actor.uuid, + open: autoExpandDescription ? 'open' : '' } ), flags: { diff --git a/module/data/fields/actionField.mjs b/module/data/fields/actionField.mjs index 4cadeac4..89c3c287 100644 --- a/module/data/fields/actionField.mjs +++ b/module/data/fields/actionField.mjs @@ -262,6 +262,9 @@ export function ActionMixin(Base) { } async toChat(origin) { + const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance) + .expandRollMessage?.desc; + const cls = getDocumentClass('ChatMessage'); const systemData = { title: game.i18n.localize('DAGGERHEART.CONFIG.FeatureForm.action'), @@ -290,7 +293,7 @@ export function ActionMixin(Base) { system: systemData, content: await foundry.applications.handlebars.renderTemplate( 'systems/daggerheart/templates/ui/chat/action.hbs', - systemData + { ...systemData, open: autoExpandDescription ? 'open' : '' } ), flags: { daggerheart: { diff --git a/system.json b/system.json index b753b540..96636bab 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.7.0", + "version": "1.7.1", "compatibility": { "minimum": "13.346", "verified": "13.351", diff --git a/templates/ui/chat/action.hbs b/templates/ui/chat/action.hbs index 65bb0762..2854795c 100644 --- a/templates/ui/chat/action.hbs +++ b/templates/ui/chat/action.hbs @@ -1,5 +1,5 @@
    -
    +
    diff --git a/templates/ui/chat/deathMove.hbs b/templates/ui/chat/deathMove.hbs index 7c677fe3..4df53404 100644 --- a/templates/ui/chat/deathMove.hbs +++ b/templates/ui/chat/deathMove.hbs @@ -7,7 +7,7 @@

    {{this.title}}

    {{localize 'DAGGERHEART.UI.Chat.deathMove.title'}}
    - +
    {{{this.description}}} diff --git a/templates/ui/chat/downtime.hbs b/templates/ui/chat/downtime.hbs index 373724dc..d7152955 100644 --- a/templates/ui/chat/downtime.hbs +++ b/templates/ui/chat/downtime.hbs @@ -1,7 +1,7 @@
      {{#each moves as | move index |}} -
      +
      From 6cbe7708801c0795d567fb1a544d19d0702cb95e Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:59:27 +0100 Subject: [PATCH 67/95] [Fix] ActiveEffectConfig Missing Resistances (#1653) * Fixed so that ActiveEffectConfig uses missing hints and has resistance in the autocomplete list * Raised version --- daggerheart.mjs | 11 +++++---- lang/en.json | 2 +- .../sheets-configs/activeEffectConfig.mjs | 23 +++++++++++-------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/daggerheart.mjs b/daggerheart.mjs index 49ed7049..e418401a 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -243,14 +243,17 @@ Hooks.on('setup', () => { })) ]; - const actorCommon = { - bar: ['resources.stress'], - value: [] - }; const damageThresholds = ['damageThresholds.major', 'damageThresholds.severe']; const traits = Object.keys(game.system.api.data.actors.DhCharacter.schema.fields.traits.fields).map( trait => `traits.${trait}.value` ); + const resistance = Object.values(game.system.api.data.actors.DhCharacter.schema.fields.resistance.fields).flatMap( + type => Object.keys(type.fields).map(x => `resistance.${type.name}.${x}`) + ); + const actorCommon = { + bar: ['resources.stress'], + value: [...resistance] + }; CONFIG.Actor.trackableAttributes = { character: { bar: [...actorCommon.bar, 'resources.hitPoints', 'resources.hope'], diff --git a/lang/en.json b/lang/en.json index 618beefc..19317228 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2111,7 +2111,7 @@ "thresholdImmunities": { "minor": { "label": "Threshold Immunities: Minor", - "hint": "Automatically ignores minor damage" + "hint": "Automatically ignores minor damage when set to 1" } } }, diff --git a/module/applications/sheets-configs/activeEffectConfig.mjs b/module/applications/sheets-configs/activeEffectConfig.mjs index 8abc0b79..28db0efe 100644 --- a/module/applications/sheets-configs/activeEffectConfig.mjs +++ b/module/applications/sheets-configs/activeEffectConfig.mjs @@ -30,22 +30,27 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac const group = game.i18n.localize(model.metadata.label); const attributes = CONFIG.Token.documentClass.getTrackedAttributes(model.metadata.type); - const getLabel = path => { - const label = model.schema.getField(path)?.label; - return label ? game.i18n.localize(label) : path; + const getTranslations = path => { + if (path === 'resources.hope.max') + return { + label: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label'), + hint: '' + }; + + const field = model.schema.getField(path); + return { + label: field ? game.i18n.localize(field.label) : path, + hint: field ? game.i18n.localize(field.hint) : '' + }; }; const bars = attributes.bar.flatMap(x => { const joined = `${x.join('.')}.max`; - const label = - joined === 'resources.hope.max' - ? 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label' - : getLabel(joined); - return { value: joined, label, group }; + return { value: joined, ...getTranslations(joined), group }; }); const values = attributes.value.flatMap(x => { const joined = x.join('.'); - return { value: joined, label: getLabel(joined), group }; + return { value: joined, ...getTranslations(joined), group }; }); const bonuses = getAllLeaves(model.schema.fields.bonuses, group); From 12bcd6e34efea076fa17df3d850a907bdf665f9b Mon Sep 17 00:00:00 2001 From: WBHarry Date: Thu, 12 Feb 2026 18:55:22 +0100 Subject: [PATCH 68/95] Fixed ScenEnvironment menu removing sceneEnvironments when used --- module/applications/ui/sceneNavigation.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/applications/ui/sceneNavigation.mjs b/module/applications/ui/sceneNavigation.mjs index 0a3e08a5..67bfe0b4 100644 --- a/module/applications/ui/sceneNavigation.mjs +++ b/module/applications/ui/sceneNavigation.mjs @@ -63,7 +63,8 @@ export default class DhSceneNavigation extends foundry.applications.ui.SceneNavi if (scene.flags.daggerheart.sceneEnvironments[0] !== environment.uuid) { const newEnvironments = scene.flags.daggerheart.sceneEnvironments; const newFirst = newEnvironments.splice( - newEnvironments.findIndex(x => x === environment.uuid) + newEnvironments.findIndex(x => x === environment.uuid), + 1 )[0]; newEnvironments.unshift(newFirst); emitAsGM( From 60cd28ae82cac7da29179881fb5bc7376fffed94 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Thu, 12 Feb 2026 19:03:21 +0100 Subject: [PATCH 69/95] Fixed the fear tracker showing up while supposed to be hidden --- module/applications/ui/fearTracker.mjs | 4 +--- module/systemRegistration/settings.mjs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/module/applications/ui/fearTracker.mjs b/module/applications/ui/fearTracker.mjs index e9c816db..82dda215 100644 --- a/module/applications/ui/fearTracker.mjs +++ b/module/applications/ui/fearTracker.mjs @@ -34,8 +34,6 @@ export default class FearTracker extends HandlebarsApplicationMixin(ApplicationV position: { width: 222, height: 222 - // top: "200px", - // left: "120px" } }; @@ -66,7 +64,7 @@ export default class FearTracker extends HandlebarsApplicationMixin(ApplicationV max = this.maxFear, percent = (current / max) * 100, isGM = game.user.isGM; - // Return the data for rendering + return { display, current, max, percent, isGM }; } diff --git a/module/systemRegistration/settings.mjs b/module/systemRegistration/settings.mjs index 49361877..c4acf7ed 100644 --- a/module/systemRegistration/settings.mjs +++ b/module/systemRegistration/settings.mjs @@ -126,7 +126,7 @@ const registerNonConfigSettings = () => { type: Number, default: 0, onChange: () => { - if (ui.resources) ui.resources.render({ force: true }); + if (ui.resources) ui.resources.render(); ui.combat.render({ force: true }); } }); From e0b3d33f80fdec0a3f0fb30870094f5e32702f62 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Thu, 12 Feb 2026 19:27:03 +0100 Subject: [PATCH 70/95] Added the ability to exclude world compendiums in the Compendium Browser Settings --- lang/en.json | 3 ++- .../dialogs/CompendiumBrowserSettings.mjs | 13 ++++++++++--- module/data/compendiumBrowserSettings.mjs | 3 ++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lang/en.json b/lang/en.json index 19317228..4355140e 100755 --- a/lang/en.json +++ b/lang/en.json @@ -355,7 +355,8 @@ "CompendiumBrowserSettings": { "title": "Enable Compendiums", "enableSource": "Enable Source", - "disableSource": "Disable Source" + "disableSource": "Disable Source", + "worldCompendiums": "World Compendiums" }, "ContextMenu": { "disableEffect": "Disable Effect", diff --git a/module/applications/dialogs/CompendiumBrowserSettings.mjs b/module/applications/dialogs/CompendiumBrowserSettings.mjs index 42d0e256..bef54a6f 100644 --- a/module/applications/dialogs/CompendiumBrowserSettings.mjs +++ b/module/applications/dialogs/CompendiumBrowserSettings.mjs @@ -50,13 +50,20 @@ export default class CompendiumBrowserSettings extends HandlebarsApplicationMixi const excludedSourceData = this.browserSettings.excludedSources; const excludedPackData = this.browserSettings.excludedPacks; context.typePackCollections = game.packs.reduce((acc, pack) => { - const { type, label, packageType, packageName, id } = pack.metadata; - if (packageType === 'world' || !CompendiumBrowserSettings.#browserPackTypes.includes(type)) return acc; + const { type, label, packageType, packageName: basePackageName, id } = pack.metadata; + if (!CompendiumBrowserSettings.#browserPackTypes.includes(type)) return acc; + const isWorldPack = packageType === 'world'; + const packageName = isWorldPack ? 'world' : basePackageName; const sourceChecked = !excludedSourceData[packageName] || !excludedSourceData[packageName].excludedDocumentTypes.includes(type); - const sourceLabel = game.modules.get(packageName)?.title ?? game.system.title; + + const sourceLabel = + game.modules.get(packageName)?.title ?? + (isWorldPack + ? game.i18n.localize('DAGGERHEART.APPLICATIONS.CompendiumBrowserSettings.worldCompendiums') + : game.system.title); if (!acc[type]) acc[type] = { label: game.i18n.localize(`DOCUMENT.${type}s`), sources: {} }; if (!acc[type].sources[packageName]) acc[type].sources[packageName] = { label: sourceLabel, checked: sourceChecked, packs: [] }; diff --git a/module/data/compendiumBrowserSettings.mjs b/module/data/compendiumBrowserSettings.mjs index 9e8025dd..ea71c439 100644 --- a/module/data/compendiumBrowserSettings.mjs +++ b/module/data/compendiumBrowserSettings.mjs @@ -24,7 +24,8 @@ export default class CompendiumBrowserSettings extends foundry.abstract.DataMode const pack = game.packs.get(item.pack); if (!pack) return false; - const excludedSourceData = this.excludedSources[pack.metadata.packageName]; + const packageName = pack.metadata.packageType === 'world' ? 'world' : pack.metadata.packageName; + const excludedSourceData = this.excludedSources[packageName]; if (excludedSourceData && excludedSourceData.excludedDocumentTypes.includes(pack.metadata.type)) return true; const excludedPackData = this.excludedPacks[item.pack]; From 70226303164785012ba80364355608a2edb9e4b0 Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Thu, 12 Feb 2026 16:27:37 -0500 Subject: [PATCH 71/95] [PR][Feature] Add support for changing the tier of an adversary (#1503) * Add support for changing the tier of an adversary * Move scaling data to actorConfig * Use a new algorithm using the median average deviation * Fine tune damage conversion for actions * Use standard deviation instead and change dialog type * Use daggerheart style for dialog * Formatting * Improve handling of minions and hordes * Changed to using lookup for Group Attack damage * Added lookup for Horde feature * Remove spaces in damage formulas --------- Co-authored-by: WBHarry --- lang/en.json | 4 +- .../sidebar/tabs/actorDirectory.mjs | 50 ++++ module/config/actorConfig.mjs | 272 ++++++++++++++++++ module/data/action/attackAction.mjs | 14 + module/data/actor/adversary.mjs | 209 ++++++++++++++ module/helpers/utils.mjs | 46 +++ ..._Apprentice_Assassin_vNIbYQ4YSzNf0WPE.json | 2 +- ...sary_Archer_Squadron_0ts6CGd93lLqGZI5.json | 4 +- .../adversary_Conscript_99TqczuQipBmaB8i.json | 2 +- ...ersary_Cult_Initiate_zx99sOGTXicP4SSD.json | 2 +- ...y_Demonic_Hound_Pack_NoRZ1PqB8N5wcIw0.json | 4 +- ...ersary_Electric_Eels_TLzY1nDw0Bu9Ud40.json | 4 +- ...sary_Elemental_Spark_P7h54ZePFPHpYwvB.json | 2 +- ...y_Fallen_Shock_Troop_OsLG2BjaEdTZUJU9.json | 2 +- ...ary_Giant_Mosquitoes_IIWV4ysJPFPnTP7W.json | 2 +- .../adversary_Giant_Rat_4PfLnaCrOcMdb4dK.json | 2 +- ...ersary_Giant_Recruit_5s8wSvpyC5rxY5aD.json | 2 +- ...ary_Hallowed_Soldier_VENwg7xEFcYObjmT.json | 2 +- ..._Jagged_Knife_Lackey_C0OMQqV7pN6t7ouR.json | 2 +- ...versary_Minor_Treant_G62k4oSkhkoXEs2D.json | 2 +- ..._Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json | 2 +- ...rsary_Pirate_Raiders_5YgEajn0wa4i85kC.json | 2 +- ...ersary_Rotted_Zombie_gP3fWTLzSFnpA8EJ.json | 2 +- .../adversary_Sellsword_bgreCaQ6ap2DVpCr.json | 2 +- ...sary_Skeleton_Dredge_6l1a3Fazq8BoKIcc.json | 2 +- ...ersary_Swarm_of_Rats_qNgs3AbLyJrY19nt.json | 2 +- ...Tangle_Bramble_Swarm_PKSXFuaIHUCoH63A.json | 4 +- ...rsary_Tangle_Bramble_XcAGOSmtCFLT1unN.json | 2 +- ...rsary_Treant_Sapling_o63nS0k3wHu6EgKP.json | 2 +- ...ersary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json | 4 +- ...dversary_Zombie_Pack_Nf0v43rtflV56V2T.json | 4 +- tools/analyze-damage.mjs | 137 +++++++++ 32 files changed, 762 insertions(+), 32 deletions(-) create mode 100644 tools/analyze-damage.mjs diff --git a/lang/en.json b/lang/en.json index 19317228..461eafc6 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2978,7 +2978,9 @@ "tier": "Tier {tier} {type}", "character": "Level {level} Character", "companion": "Level {level} - {partner}", - "companionNoPartner": "No Partner" + "companionNoPartner": "No Partner", + "duplicateToNewTier": "Duplicate to New Tier", + "pickTierTitle": "Pick a new tier for this adversary" }, "daggerheartMenu": { "title": "Daggerheart Menu", diff --git a/module/applications/sidebar/tabs/actorDirectory.mjs b/module/applications/sidebar/tabs/actorDirectory.mjs index d40443a0..9d8f16e1 100644 --- a/module/applications/sidebar/tabs/actorDirectory.mjs +++ b/module/applications/sidebar/tabs/actorDirectory.mjs @@ -43,4 +43,54 @@ export default class DhActorDirectory extends foundry.applications.sidebar.tabs. event.dataTransfer.setDragImage(preview, w / 2, h / 2); } } + + _getEntryContextOptions() { + const options = super._getEntryContextOptions(); + options.push({ + name: 'DAGGERHEART.UI.Sidebar.actorDirectory.duplicateToNewTier', + icon: ``, + condition: li => { + const actor = game.actors.get(li.dataset.entryId); + return actor?.type === 'adversary' && actor.system.type !== 'social'; + }, + callback: async li => { + const actor = game.actors.get(li.dataset.entryId); + if (!actor) throw new Error('Unexpected missing actor'); + + const tiers = [1, 2, 3, 4].filter(t => t !== actor.system.tier); + const content = document.createElement('div'); + const select = document.createElement('select'); + select.name = 'tier'; + select.append( + ...tiers.map(t => { + const option = document.createElement('option'); + option.value = t; + option.textContent = game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${t}`); + return option; + }) + ); + content.append(select); + + const tier = await foundry.applications.api.Dialog.input({ + classes: ['dh-style', 'dialog'], + window: { title: 'DAGGERHEART.UI.Sidebar.actorDirectory.pickTierTitle' }, + content, + ok: { + label: 'Create Adversary', + callback: (event, button, dialog) => Number(button.form.elements.tier.value) + } + }); + + if (tier === actor.system.tier) { + ui.notifications.warn('This actor is already at this tier'); + } else if (tier) { + const source = actor.system.adjustForTier(tier); + await Actor.create(source); + ui.notifications.info(`Tier ${tier} ${actor.name} created`); + } + } + }); + + return options; + } } diff --git a/module/config/actorConfig.mjs b/module/config/actorConfig.mjs index fdef7d03..ac55117a 100644 --- a/module/config/actorConfig.mjs +++ b/module/config/actorConfig.mjs @@ -494,3 +494,275 @@ export const subclassFeatureLabels = { 2: 'DAGGERHEART.ITEMS.DomainCard.specializationTitle', 3: 'DAGGERHEART.ITEMS.DomainCard.masteryTitle' }; + +/** + * @typedef {Object} TierData + * @property {number} difficulty + * @property {number} majorThreshold + * @property {number} severeThreshold + * @property {number} hp + * @property {number} stress + * @property {number} attack + * @property {number[]} damage + */ + +/** + * @type {Record} + * Scaling data used to change an adversary's tier. Each rank is applied incrementally. + */ +export const adversaryScalingData = { + bruiser: { + 2: { + difficulty: 2, + majorThreshold: 5, + severeThreshold: 10, + hp: 1, + stress: 2, + attack: 2, + }, + 3: { + difficulty: 2, + majorThreshold: 7, + severeThreshold: 15, + hp: 1, + stress: 0, + attack: 2, + }, + 4: { + difficulty: 2, + majorThreshold: 12, + severeThreshold: 25, + hp: 1, + stress: 0, + attack: 2, + } + }, + horde: { + 2: { + difficulty: 2, + majorThreshold: 5, + severeThreshold: 8, + hp: 2, + stress: 0, + attack: 0, + }, + 3: { + difficulty: 2, + majorThreshold: 5, + severeThreshold: 12, + hp: 0, + stress: 1, + attack: 1, + }, + 4: { + difficulty: 2, + majorThreshold: 10, + severeThreshold: 15, + hp: 2, + stress: 0, + attack: 0, + } + }, + leader: { + 2: { + difficulty: 2, + majorThreshold: 6, + severeThreshold: 10, + hp: 0, + stress: 0, + attack: 1, + }, + 3: { + difficulty: 2, + majorThreshold: 6, + severeThreshold: 15, + hp: 1, + stress: 0, + attack: 2, + }, + 4: { + difficulty: 2, + majorThreshold: 12, + severeThreshold: 25, + hp: 1, + stress: 1, + attack: 3, + } + }, + minion: { + 2: { + difficulty: 2, + majorThreshold: 0, + severeThreshold: 0, + hp: 0, + stress: 0, + attack: 1, + }, + 3: { + difficulty: 2, + majorThreshold: 0, + severeThreshold: 0, + hp: 0, + stress: 1, + attack: 1, + }, + 4: { + difficulty: 2, + majorThreshold: 0, + severeThreshold: 0, + hp: 0, + stress: 0, + attack: 1, + } + }, + ranged: { + 2: { + difficulty: 2, + majorThreshold: 3, + severeThreshold: 6, + hp: 1, + stress: 0, + attack: 1, + }, + 3: { + difficulty: 2, + majorThreshold: 7, + severeThreshold: 14, + hp: 1, + stress: 1, + attack: 2, + }, + 4: { + difficulty: 2, + majorThreshold: 5, + severeThreshold: 10, + hp: 1, + stress: 1, + attack: 1, + } + }, + skulk: { + 2: { + difficulty: 2, + majorThreshold: 3, + severeThreshold: 8, + hp: 1, + stress: 1, + attack: 1, + }, + 3: { + difficulty: 2, + majorThreshold: 8, + severeThreshold: 12, + hp: 1, + stress: 1, + attack: 1, + }, + 4: { + difficulty: 2, + majorThreshold: 8, + severeThreshold: 10, + hp: 1, + stress: 1, + attack: 1, + } + }, + solo: { + 2: { + difficulty: 2, + majorThreshold: 5, + severeThreshold: 10, + hp: 0, + stress: 1, + attack: 2, + }, + 3: { + difficulty: 2, + majorThreshold: 7, + severeThreshold: 15, + hp: 2, + stress: 1, + attack: 2, + }, + 4: { + difficulty: 2, + majorThreshold: 12, + severeThreshold: 25, + hp: 0, + stress: 1, + attack: 3, + } + }, + standard: { + 2: { + difficulty: 2, + majorThreshold: 3, + severeThreshold: 8, + hp: 0, + stress: 0, + attack: 1, + }, + 3: { + difficulty: 2, + majorThreshold: 7, + severeThreshold: 15, + hp: 1, + stress: 1, + attack: 1, + }, + 4: { + difficulty: 2, + majorThreshold: 10, + severeThreshold: 15, + hp: 0, + stress: 1, + attack: 1, + } + }, + support: { + 2: { + difficulty: 2, + majorThreshold: 3, + severeThreshold: 8, + hp: 1, + stress: 1, + attack: 1, + }, + 3: { + difficulty: 2, + majorThreshold: 7, + severeThreshold: 12, + hp: 0, + stress: 0, + attack: 1, + }, + 4: { + difficulty: 2, + majorThreshold: 8, + severeThreshold: 10, + hp: 1, + stress: 1, + attack: 1, + } + } +}; + +/** + * Scaling data used for an adversary's damage. + * Tier 4 is missing certain adversary types and therefore skews upwards. + * We manually set tier 4 data to hopefully lead to better results + */ +export const adversaryExpectedDamage = { + basic: { + 1: { mean: 7.321428571428571, deviation: 1.962519002770912 }, + 2: { mean: 12.444444444444445, deviation: 2.0631069425529676 }, + 3: { mean: 15.722222222222221, deviation: 2.486565208464823 }, + 4: { mean: 26, deviation: 5.2 } + }, + minion: { + 1: { mean: 2.142857142857143, deviation: 1.0690449676496976 }, + 2: { mean: 5, deviation: 0.816496580927726 }, + 3: { mean: 6.5, deviation: 2.1213203435596424 }, + 4: { mean: 11, deviation: 1 } + } +}; diff --git a/module/data/action/attackAction.mjs b/module/data/action/attackAction.mjs index 7be7461d..60112c40 100644 --- a/module/data/action/attackAction.mjs +++ b/module/data/action/attackAction.mjs @@ -34,6 +34,20 @@ export default class DHAttackAction extends DHDamageAction { }; } + get damageFormula() { + const hitPointsPart = this.damage.parts.find(x => x.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id); + if (!hitPointsPart) return '0'; + + return hitPointsPart.value.getFormula(); + } + + get altDamageFormula() { + const hitPointsPart = this.damage.parts.find(x => x.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id); + if (!hitPointsPart) return '0'; + + return hitPointsPart.valueAlt.getFormula(); + } + async use(event, options) { const result = await super.use(event, options); if (!result.message) return; diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index f2c38090..d3844bcb 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -2,6 +2,8 @@ import DHAdversarySettings from '../../applications/sheets-configs/adversary-set import { ActionField } from '../fields/actionField.mjs'; import BaseDataActor, { commonActorRules } from './base.mjs'; import { resourceField, bonusField } from '../fields/actorField.mjs'; +import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; +import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; export default class DhpAdversary extends BaseDataActor { static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary']; @@ -195,4 +197,211 @@ export default class DhpAdversary extends BaseDataActor { ]; return tags; } + + /** Returns source data for this actor adjusted to a new tier, which can be used to create a new actor. */ + adjustForTier(tier) { + const source = this.parent.toObject(true); + + /** @type {(2 | 3 | 4)[]} */ + const tiers = new Array(Math.abs(tier - this.tier)) + .fill(0) + .map((_, idx) => idx + Math.min(tier, this.tier) + 1); + if (tier < this.tier) tiers.reverse(); + const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard]; + const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] })); + + // Apply simple tier changes + const scale = tier > this.tier ? 1 : -1; + for (const entry of tierEntries) { + source.system.difficulty += scale * entry.difficulty; + source.system.damageThresholds.major += scale * entry.majorThreshold; + source.system.damageThresholds.severe += scale * entry.severeThreshold; + source.system.resources.hitPoints.max += scale * entry.hp; + source.system.resources.stress.max += scale * entry.stress; + source.system.attack.roll.bonus += scale * entry.attack; + } + + // Get the mean and standard deviation of expected damage in the previous and new tier + // The data we have is for attack scaling, but we reuse this for action scaling later + const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic; + const damageMeta = { + currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] }, + newDamageRange: { tier, ...expectedDamageData[tier] }, + type: 'attack' + }; + + // Update damage of base attack + try { + this.#adjustActionDamage(source.system.attack, damageMeta); + } catch (err) { + ui.notifications.warn('Failed to convert attack damage of adversary'); + console.error(err); + } + + // Update damage of each item action, making sure to also update the description if possible + const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g; + for (const item of source.items) { + // Replace damage inlines with new formulas + for (const withDescription of [item.system, ...Object.values(item.system.actions)]) { + withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => { + const { value: formula } = parseInlineParams(inner); + if (!formula || !type) return match; + + try { + const adjusted = this.#calculateAdjustedDamage(formula, { ...damageMeta, type: 'action' }); + const newFormula = [ + adjusted.diceQuantity ? `${adjusted.diceQuantity}d${adjusted.faces}` : null, + adjusted.bonus + ] + .filter(p => !!p) + .join('+'); + return match.replace(formula, newFormula); + } catch { + return match; + } + }); + } + + // Update damage in item actions + for (const action of Object.values(item.system.actions)) { + if (!action.damage) continue; + + // Parse damage, and convert all formula matches in the descriptions to the new damage + try { + const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' }); + for (const { previousFormula, formula } of Object.values(result)) { + const oldFormulaRegexp = new RegExp( + previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?') + ); + item.system.description = item.system.description.replace(oldFormulaRegexp, formula); + action.description = action.description.replace(oldFormulaRegexp, formula); + } + } catch (err) { + ui.notifications.warn(`Failed to convert action damage for item ${item.name}`); + console.error(err); + } + } + } + + // Finally set the tier of the source data, now that everything is complete + source.system.tier = tier; + return source; + } + + /** + * Converts a damage object to a new damage range + * @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term + * @throws error if the formula is the wrong type + */ + #calculateAdjustedDamage(formula, { currentDamageRange, newDamageRange, type }) { + const terms = parseTermsFromSimpleFormula(formula); + const flatTerms = terms.filter(t => t.diceQuantity === 0); + const diceTerms = terms.filter(t => t.diceQuantity > 0); + if (flatTerms.length > 1 || diceTerms.length > 1) { + throw new Error('invalid formula for conversion'); + } + const value = { + ...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }), + bonus: flatTerms[0]?.bonus ?? 0 + }; + const previousExpected = calculateExpectedValue(value); + if (previousExpected === 0) return value; // nothing to do + + const dieSizes = [4, 6, 8, 10, 12, 20]; + const steps = newDamageRange.tier - currentDamageRange.tier; + const increasing = steps > 0; + const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation; + const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation); + + // If this was just a flat number, convert to the expected damage and exit + if (value.diceQuantity === 0) { + value.bonus = Math.round(expected); + return value; + } + + const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1; + const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 }); + + // Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die + const baseOverages = Math.floor(value.bonus / getExpectedDie()); + + // Prestep. Change number of dice for attacks, bump up/down for actions + // We never bump up to d20, though we might bump down from it + if (type === 'attack') { + const minimum = increasing ? value.diceQuantity : 0; + value.diceQuantity = Math.max(minimum, newDamageRange.tier); + } else { + const currentIdx = dieSizes.indexOf(value.faces); + value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)]; + } + + value.bonus = Math.round(expected - getBaseAverage()); + + // Attempt to handle negative values. + // If we can do it with only step downs, do so. Otherwise remove tier dice, and try again + if (value.bonus < 0) { + let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity); + const currentIdx = dieSizes.indexOf(value.faces); + + // If step downs alone don't suffice, change the flat modifier, then calculate steps required again + // If this isn't sufficient, the result will be slightly off. This is unlikely to happen + if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) { + value.diceQuantity -= increasing ? 1 : Math.abs(steps); + value.bonus = Math.round(expected - getBaseAverage()); + if (value.bonus >= 0) return value; // complete + } + + stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity); + value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)]; + value.bonus = Math.max(0, Math.round(expected - getBaseAverage())); + } + + // If value is really high, we add a number of dice based on the number of overages + // This attempts to preserve a similar amount of variance when increasing an action + const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages; + if (type !== 'attack' && increasing && overagesToRemove > 0) { + value.diceQuantity += overagesToRemove; + value.bonus = Math.round(expected - getBaseAverage()); + } + + return value; + } + + /** + * 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 + */ + #adjustActionDamage(action, damageMeta) { + // The current algorithm only returns a value if there is a single damage part + const hpDamageParts = action.damage.parts.filter(d => d.applyTo === 'hitPoints'); + if (hpDamageParts.length !== 1) throw new Error('incorrect number of hp parts'); + + const result = {}; + for (const property of ['value', 'valueAlt']) { + const data = hpDamageParts[0][property]; + const previousFormula = data.custom.enabled + ? data.custom.formula + : [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0] + .filter(p => !!p) + .join('+'); + const value = this.#calculateAdjustedDamage(previousFormula, damageMeta); + const formula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus] + .filter(p => !!p) + .join('+'); + if (value.diceQuantity) { + data.custom.enabled = false; + data.bonus = value.bonus; + data.dice = `d${value.faces}`; + data.flatMultiplier = value.diceQuantity; + } else if (!value.diceQuantity) { + data.custom.enabled = true; + data.custom.formula = formula; + } + + result[property] = { previousFormula, formula, value }; + } + + return result; + } } diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs index c0dd45bd..607ce94e 100644 --- a/module/helpers/utils.mjs +++ b/module/helpers/utils.mjs @@ -495,3 +495,49 @@ export function htmlToText(html) { return tempDivElement.textContent || tempDivElement.innerText || ''; } + +/** + * Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms. + * All subtracted terms become negative terms. + * If there are no dice, it returns 0d1 for that term. + */ +export function parseTermsFromSimpleFormula(formula) { + const roll = formula instanceof Roll ? formula : new Roll(formula); + + // Parse from right to left so that when we hit an operator, we already have the term. + return roll.terms.reduceRight((result, term) => { + // Ignore + terms, we assume + by default + if (term.expression === ' + ') return result; + + // - terms modify the last term we parsed + if (term.expression === ' - ') { + const termToModify = result[0]; + if (termToModify) { + if (termToModify.bonus) termToModify.bonus *= -1; + if (termToModify.dice) termToModify.dice *= -1; + } + return result; + } + + result.unshift({ + bonus: term instanceof foundry.dice.terms.NumericTerm ? term.number : 0, + diceQuantity: term instanceof foundry.dice.terms.Die ? term.number : 0, + faces: term.faces ?? 1 + }); + + return result; + }, []); +} + +/** + * Calculates the expectede value from a formula or the results of parseTermsFromSimpleFormula. + * @returns {number} the average result of rolling the given dice + */ +export function calculateExpectedValue(formulaOrTerms) { + const terms = Array.isArray(formulaOrTerms) + ? formulaOrTerms + : typeof formulaOrTerms === 'string' + ? parseTermsFromSimpleFormula(formulaOrTerms) + : [formulaOrTerms]; + return terms.reduce((r, t) => r + (t.bonus ?? 0) + (t.diceQuantity ? (t.diceQuantity * (t.faces + 1)) / 2 : 0), 0); +} diff --git a/src/packs/adversaries/adversary_Apprentice_Assassin_vNIbYQ4YSzNf0WPE.json b/src/packs/adversaries/adversary_Apprentice_Assassin_vNIbYQ4YSzNf0WPE.json index 3f31ff76..23f1f339 100644 --- a/src/packs/adversaries/adversary_Apprentice_Assassin_vNIbYQ4YSzNf0WPE.json +++ b/src/packs/adversaries/adversary_Apprentice_Assassin_vNIbYQ4YSzNf0WPE.json @@ -246,7 +246,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "vgguNWz8vG8aoLXR": { diff --git a/src/packs/adversaries/adversary_Archer_Squadron_0ts6CGd93lLqGZI5.json b/src/packs/adversaries/adversary_Archer_Squadron_0ts6CGd93lLqGZI5.json index 55229040..5b15bc09 100644 --- a/src/packs/adversaries/adversary_Archer_Squadron_0ts6CGd93lLqGZI5.json +++ b/src/packs/adversaries/adversary_Archer_Squadron_0ts6CGd93lLqGZI5.json @@ -218,10 +218,10 @@ }, "items": [ { - "name": "Horde (1d6+3)", + "name": "Horde", "type": "feature", "system": { - "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals 1d6+3 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Conscript_99TqczuQipBmaB8i.json b/src/packs/adversaries/adversary_Conscript_99TqczuQipBmaB8i.json index c5b4357d..35c43a3b 100644 --- a/src/packs/adversaries/adversary_Conscript_99TqczuQipBmaB8i.json +++ b/src/packs/adversaries/adversary_Conscript_99TqczuQipBmaB8i.json @@ -239,7 +239,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 6 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "cbAvPSIhwBMBTI3D": { diff --git a/src/packs/adversaries/adversary_Cult_Initiate_zx99sOGTXicP4SSD.json b/src/packs/adversaries/adversary_Cult_Initiate_zx99sOGTXicP4SSD.json index 0e14a661..a0c0713d 100644 --- a/src/packs/adversaries/adversary_Cult_Initiate_zx99sOGTXicP4SSD.json +++ b/src/packs/adversaries/adversary_Cult_Initiate_zx99sOGTXicP4SSD.json @@ -239,7 +239,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all Cult @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all Cult @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "EH1preaTWBD4rOvx": { diff --git a/src/packs/adversaries/adversary_Demonic_Hound_Pack_NoRZ1PqB8N5wcIw0.json b/src/packs/adversaries/adversary_Demonic_Hound_Pack_NoRZ1PqB8N5wcIw0.json index 2947b7a1..7482c734 100644 --- a/src/packs/adversaries/adversary_Demonic_Hound_Pack_NoRZ1PqB8N5wcIw0.json +++ b/src/packs/adversaries/adversary_Demonic_Hound_Pack_NoRZ1PqB8N5wcIw0.json @@ -224,10 +224,10 @@ }, "items": [ { - "name": "Horde (2d4+1)", + "name": "Horde", "type": "feature", "system": { - "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals 2d4+1 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Electric_Eels_TLzY1nDw0Bu9Ud40.json b/src/packs/adversaries/adversary_Electric_Eels_TLzY1nDw0Bu9Ud40.json index 7b41b9e5..9386944f 100644 --- a/src/packs/adversaries/adversary_Electric_Eels_TLzY1nDw0Bu9Ud40.json +++ b/src/packs/adversaries/adversary_Electric_Eels_TLzY1nDw0Bu9Ud40.json @@ -218,10 +218,10 @@ }, "items": [ { - "name": "Horde (2d4+1)", + "name": "Horde", "type": "feature", "system": { - "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals 2d4+1 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Elemental_Spark_P7h54ZePFPHpYwvB.json b/src/packs/adversaries/adversary_Elemental_Spark_P7h54ZePFPHpYwvB.json index b17cae1c..5c25f63e 100644 --- a/src/packs/adversaries/adversary_Elemental_Spark_P7h54ZePFPHpYwvB.json +++ b/src/packs/adversaries/adversary_Elemental_Spark_P7h54ZePFPHpYwvB.json @@ -239,7 +239,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "vXHZVb0Y7Hqu3uso": { diff --git a/src/packs/adversaries/adversary_Fallen_Shock_Troop_OsLG2BjaEdTZUJU9.json b/src/packs/adversaries/adversary_Fallen_Shock_Troop_OsLG2BjaEdTZUJU9.json index 163c61f7..931e4c0a 100644 --- a/src/packs/adversaries/adversary_Fallen_Shock_Troop_OsLG2BjaEdTZUJU9.json +++ b/src/packs/adversaries/adversary_Fallen_Shock_Troop_OsLG2BjaEdTZUJU9.json @@ -317,7 +317,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 12 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "QHNRSEQmqOcaoXq4": { diff --git a/src/packs/adversaries/adversary_Giant_Mosquitoes_IIWV4ysJPFPnTP7W.json b/src/packs/adversaries/adversary_Giant_Mosquitoes_IIWV4ysJPFPnTP7W.json index 54f12efa..fbb30d40 100644 --- a/src/packs/adversaries/adversary_Giant_Mosquitoes_IIWV4ysJPFPnTP7W.json +++ b/src/packs/adversaries/adversary_Giant_Mosquitoes_IIWV4ysJPFPnTP7W.json @@ -229,7 +229,7 @@ "_id": "9RduwBLYcBaiouYk", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "system": { - "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals 1d4+1 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Giant_Rat_4PfLnaCrOcMdb4dK.json b/src/packs/adversaries/adversary_Giant_Rat_4PfLnaCrOcMdb4dK.json index d4655880..d1df6b57 100644 --- a/src/packs/adversaries/adversary_Giant_Rat_4PfLnaCrOcMdb4dK.json +++ b/src/packs/adversaries/adversary_Giant_Rat_4PfLnaCrOcMdb4dK.json @@ -248,7 +248,7 @@ "_id": "fsaBlCjTdq1jM23G", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "q8chow47nQLR9qeF": { diff --git a/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json b/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json index 75da96b2..ebdea711 100644 --- a/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json +++ b/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json @@ -239,7 +239,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "DjbPQowW1OdBD9Zn": { diff --git a/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json b/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json index cceed989..96107752 100644 --- a/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json +++ b/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json @@ -294,7 +294,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 10 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "eo7J0v1B5zPHul1M": { diff --git a/src/packs/adversaries/adversary_Jagged_Knife_Lackey_C0OMQqV7pN6t7ouR.json b/src/packs/adversaries/adversary_Jagged_Knife_Lackey_C0OMQqV7pN6t7ouR.json index 1a95bf87..a52ec1c9 100644 --- a/src/packs/adversaries/adversary_Jagged_Knife_Lackey_C0OMQqV7pN6t7ouR.json +++ b/src/packs/adversaries/adversary_Jagged_Knife_Lackey_C0OMQqV7pN6t7ouR.json @@ -248,7 +248,7 @@ "_id": "1k5TmQIAunM7Bv32", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name] within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name] within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "aoQDb2m32NDxE6ZP": { diff --git a/src/packs/adversaries/adversary_Minor_Treant_G62k4oSkhkoXEs2D.json b/src/packs/adversaries/adversary_Minor_Treant_G62k4oSkhkoXEs2D.json index 0f1e7ded..f05ba5fc 100644 --- a/src/packs/adversaries/adversary_Minor_Treant_G62k4oSkhkoXEs2D.json +++ b/src/packs/adversaries/adversary_Minor_Treant_G62k4oSkhkoXEs2D.json @@ -242,7 +242,7 @@ "_id": "K08WlZwGqzEo4idT", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "xTMNAHcoErKuR6TZ": { diff --git a/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json b/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json index 370182a5..276dd3ed 100644 --- a/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json +++ b/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json @@ -239,7 +239,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 11 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "tvQetauskZoHDR5y": { diff --git a/src/packs/adversaries/adversary_Pirate_Raiders_5YgEajn0wa4i85kC.json b/src/packs/adversaries/adversary_Pirate_Raiders_5YgEajn0wa4i85kC.json index 7d3733ce..41f79b49 100644 --- a/src/packs/adversaries/adversary_Pirate_Raiders_5YgEajn0wa4i85kC.json +++ b/src/packs/adversaries/adversary_Pirate_Raiders_5YgEajn0wa4i85kC.json @@ -229,7 +229,7 @@ "_id": "Q7DRbWjHl64CNwag", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "system": { - "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals 1d4+1 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Rotted_Zombie_gP3fWTLzSFnpA8EJ.json b/src/packs/adversaries/adversary_Rotted_Zombie_gP3fWTLzSFnpA8EJ.json index a9bf3a67..7672961c 100644 --- a/src/packs/adversaries/adversary_Rotted_Zombie_gP3fWTLzSFnpA8EJ.json +++ b/src/packs/adversaries/adversary_Rotted_Zombie_gP3fWTLzSFnpA8EJ.json @@ -242,7 +242,7 @@ "_id": "R9vrwFNl5BD1YXJo", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "DJBNtd3hWjwsjPwq": { diff --git a/src/packs/adversaries/adversary_Sellsword_bgreCaQ6ap2DVpCr.json b/src/packs/adversaries/adversary_Sellsword_bgreCaQ6ap2DVpCr.json index e26b48eb..514be8f5 100644 --- a/src/packs/adversaries/adversary_Sellsword_bgreCaQ6ap2DVpCr.json +++ b/src/packs/adversaries/adversary_Sellsword_bgreCaQ6ap2DVpCr.json @@ -242,7 +242,7 @@ "_id": "CQZQiEiRH70Br5Ge", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 3 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "ghgFZskDiizJDjcn": { diff --git a/src/packs/adversaries/adversary_Skeleton_Dredge_6l1a3Fazq8BoKIcc.json b/src/packs/adversaries/adversary_Skeleton_Dredge_6l1a3Fazq8BoKIcc.json index 5a973b17..4013d7fe 100644 --- a/src/packs/adversaries/adversary_Skeleton_Dredge_6l1a3Fazq8BoKIcc.json +++ b/src/packs/adversaries/adversary_Skeleton_Dredge_6l1a3Fazq8BoKIcc.json @@ -242,7 +242,7 @@ "_id": "wl9KKEpVWDBu62hU", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "Sz55uB8xkoNytLwJ": { diff --git a/src/packs/adversaries/adversary_Swarm_of_Rats_qNgs3AbLyJrY19nt.json b/src/packs/adversaries/adversary_Swarm_of_Rats_qNgs3AbLyJrY19nt.json index 33fe06d7..014b3dc6 100644 --- a/src/packs/adversaries/adversary_Swarm_of_Rats_qNgs3AbLyJrY19nt.json +++ b/src/packs/adversaries/adversary_Swarm_of_Rats_qNgs3AbLyJrY19nt.json @@ -223,7 +223,7 @@ "_id": "9Zuu892SO5NmtI4w", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "system": { - "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals 1d4+1 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Tangle_Bramble_Swarm_PKSXFuaIHUCoH63A.json b/src/packs/adversaries/adversary_Tangle_Bramble_Swarm_PKSXFuaIHUCoH63A.json index 639fa956..40297eb6 100644 --- a/src/packs/adversaries/adversary_Tangle_Bramble_Swarm_PKSXFuaIHUCoH63A.json +++ b/src/packs/adversaries/adversary_Tangle_Bramble_Swarm_PKSXFuaIHUCoH63A.json @@ -254,12 +254,12 @@ }, "items": [ { - "name": "Horde (1d4+2)", + "name": "Horde", "type": "feature", "_id": "4dSzqtYvH385r9Ng", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "system": { - "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals 1d4+2 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Tangle_Bramble_XcAGOSmtCFLT1unN.json b/src/packs/adversaries/adversary_Tangle_Bramble_XcAGOSmtCFLT1unN.json index 0f1ba28f..33afaa3a 100644 --- a/src/packs/adversaries/adversary_Tangle_Bramble_XcAGOSmtCFLT1unN.json +++ b/src/packs/adversaries/adversary_Tangle_Bramble_XcAGOSmtCFLT1unN.json @@ -281,7 +281,7 @@ "_id": "WiobzuyvJ46zfsOv", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "ZC5pKIb9N82vgMWu": { diff --git a/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json b/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json index 8959f78a..c9ca695e 100644 --- a/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json +++ b/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json @@ -239,7 +239,7 @@ "name": "Group Attack", "type": "feature", "system": { - "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 8 physical damage each. Combine this damage.

      ", + "description": "

      Spend a Fear to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.

      ", "resource": null, "actions": { "euP8VA4wvfsCpwN1": { diff --git a/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json b/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json index 1b2cce2a..a6a488e9 100644 --- a/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json +++ b/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json @@ -218,10 +218,10 @@ }, "items": [ { - "name": "Horde (2d6+5)", + "name": "Horde", "type": "feature", "system": { - "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals 2d6+5 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] has marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/src/packs/adversaries/adversary_Zombie_Pack_Nf0v43rtflV56V2T.json b/src/packs/adversaries/adversary_Zombie_Pack_Nf0v43rtflV56V2T.json index 32519ac6..017537ad 100644 --- a/src/packs/adversaries/adversary_Zombie_Pack_Nf0v43rtflV56V2T.json +++ b/src/packs/adversaries/adversary_Zombie_Pack_Nf0v43rtflV56V2T.json @@ -218,12 +218,12 @@ }, "items": [ { - "name": "Horde (1d4+2)", + "name": "Horde", "type": "feature", "_id": "nNJGAhWu0IuS2ybn", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "system": { - "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals 1d4+2 physical damage instead.

      ", + "description": "

      When the @Lookup[@name] have marked half or more of their HP, their standard attack deals @Lookup[@system.attack.altDamageFormula] physical damage instead.

      ", "resource": null, "actions": {}, "originItemType": null, diff --git a/tools/analyze-damage.mjs b/tools/analyze-damage.mjs new file mode 100644 index 00000000..6d5da3de --- /dev/null +++ b/tools/analyze-damage.mjs @@ -0,0 +1,137 @@ +/** + * Internal script to analyze damage and spit out results. + * There isn't enough entries in the database to make a full analysis, some tiers miss some types. + * This script only checks for "minions" and "everything else". + * Maybe if future book monsters can be part of what we release, we can analyze those too. + */ + +import fs from "fs/promises"; +import path from "path"; + +const allData = []; + +// Read adversary pack data for average damage for attacks +const adversariesDirectory = path.join("src/packs/adversaries"); +for (const basefile of await fs.readdir(adversariesDirectory)) { + if (!basefile.endsWith(".json")) continue; + const filepath = path.join(adversariesDirectory, basefile); + const data = JSON.parse(await fs.readFile(filepath, "utf8")); + if (data?.type !== "adversary" || data.system.type === "social") continue; + + allData.push({ + name: data.name, + tier: data.system.tier, + adversaryType: data.system.type, + damage: parseDamage(data.system.attack.damage), + }); +} + +const adversaryTypes = new Set(allData.map(a => a.adversaryType)); +for (const type of [...adversaryTypes].toSorted()) { + const perTier = Object.groupBy(allData.filter(a => a.adversaryType === type), a => a.tier); + console.log(`${type} per Tier: ${[1, 2, 3, 4].map(t => perTier[t]?.length ?? 0).join(" ")}`) +} + +const result = { + basic: compileData(allData.filter(d => d.adversaryType !== "minion")), + solos_and_bruisers: compileData(allData.filter(d => ["solo", "bruiser"].includes(d.adversaryType))), + leader_and_ranged: compileData(allData.filter(d => ["leader", "ranged"].includes(d.adversaryType))), + minion: compileData(allData.filter(d => d.adversaryType === "minion")), +}; + +console.log(result); + +/** Compiles all data for an adversary type (or all entries) */ +function compileData(entries) { + // Note: sorting numbers sorts by their string version by default + const results = {}; + for (const tier of [1, 2, 3, 4]) { + const tierEntries = entries.filter(e => e.tier === tier); + const allDamage = removeOutliers(tierEntries.map(d => d.damage).sort((a, b) => a - b)); + const mean = getMean(allDamage); + if (tier === 4) console.log(allDamage); + results[tier] = { + mean, + deviation: getStandardDeviation(allDamage, { mean }), + }; + } + + return results; +} + +function removeOutliers(data) { + if (data.length <= 4) return data; + const startIdx = Math.floor(data.length * 0.25); + const endIdx = Math.ceil(data.length * 0.75); + const iqrBound = (data[endIdx] - data[startIdx]) * 1.25; + return data.filter((d) => d >= data[startIdx] - iqrBound && d <= data[endIdx] + iqrBound); +} + +function getMedian(numbers) { + numbers = numbers.toSorted((a, b) => a - b); + const medianIdx = numbers.length / 2; + return medianIdx % 1 ? numbers[Math.floor(medianIdx)] : (numbers[medianIdx] + numbers[medianIdx - 1]) / 2; +} + +function getMean(numbers) { + if (numbers.length === 0) return NaN; + return numbers.reduce((r, a) => r + a, 0) / numbers.length; +} + +function getMedianAverageDeviation(numbers, { median }) { + const residuals = allDamage.map(d => Math.abs(d - median)); + return getMedian(residuals); +} + +function getStandardDeviation(numbers, { mean }) { + const deviations = numbers.map((r) => r - mean); + return Math.sqrt(deviations.reduce((r, d) => r + d * d, 0) / (numbers.length - 1)); +} + +function parseDamage(damage) { + const formula = damage.parts + .filter(p => p.applyTo === 'hitPoints') + .map(p => + p.value.custom.enabled + ? p.value.custom.formula + : [p.value.flatMultiplier ? `${p.value.flatMultiplier}${p.value.dice}` : 0, p.value.bonus ?? 0] + .filter(p => !!p) + .join('+') + ) + .join('+'); + return getExpectedDamage(formula); +} + +/** + * Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms. + * All subtracted terms become negative terms. + */ +function getExpectedDamage(formula) { + const terms = formula.replace("+", " + ").replace("-", " - ").split(" ").map(t => t.trim()); + let multiplier = 1; + return terms.reduce((total, term) => { + if (term === "-") { + multiplier = -1; + return total; + } else if (term === "+") { + return total; + } + + const currentMultiplier = multiplier; + multiplier = 1; + + const number = Number(term); + if (!Number.isNaN(number)) { + return total + currentMultiplier * number; + } + + const dieMatch = term.match(/(\d+)d(\d+)/); + if (dieMatch) { + const numDice = Number(dieMatch[1]); + const faces = Number(dieMatch[2]); + return total + currentMultiplier * numDice * ((faces + 1) / 2); + } + + throw Error(`Unexpected term ${term} in formula ${formula}`); + }, 0); +} From b64a9002eaeceb1f2a21c8e785faab80ae2506b8 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:07:47 +0100 Subject: [PATCH 72/95] Fixed advantage/disadvantage sources for adversaries and companions (#1659) --- daggerheart.mjs | 4 ++-- module/data/actor/adversary.mjs | 5 +++-- module/data/actor/character.mjs | 13 +++---------- module/data/actor/companion.mjs | 4 ++-- module/data/actor/creature.mjs | 20 ++++++++++++++++++++ system.json | 2 +- 6 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 module/data/actor/creature.mjs diff --git a/daggerheart.mjs b/daggerheart.mjs index e418401a..1987ec12 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -252,7 +252,7 @@ Hooks.on('setup', () => { ); const actorCommon = { bar: ['resources.stress'], - value: [...resistance] + value: [...resistance, 'advantageSources', 'disadvantageSources'] }; CONFIG.Actor.trackableAttributes = { character: { @@ -270,7 +270,7 @@ Hooks.on('setup', () => { }, adversary: { bar: [...actorCommon.bar, 'resources.hitPoints'], - value: [...actorCommon.value, ...damageThresholds, 'criticalThreshold'] + value: [...actorCommon.value, ...damageThresholds, 'criticalThreshold', 'difficulty'] }, companion: { bar: [...actorCommon.bar], diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index d3844bcb..78964720 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -1,11 +1,12 @@ import DHAdversarySettings from '../../applications/sheets-configs/adversary-settings.mjs'; import { ActionField } from '../fields/actionField.mjs'; -import BaseDataActor, { commonActorRules } from './base.mjs'; +import { commonActorRules } from './base.mjs'; +import DhCreature from './creature.mjs'; import { resourceField, bonusField } from '../fields/actorField.mjs'; import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; -export default class DhpAdversary extends BaseDataActor { +export default class DhpAdversary extends DhCreature { static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary']; static get metadata() { diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index c79bb078..aca87c5c 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -1,12 +1,13 @@ import { burden } from '../../config/generalConfig.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import DhLevelData from '../levelData.mjs'; -import BaseDataActor, { commonActorRules } from './base.mjs'; +import { commonActorRules } from './base.mjs'; +import DhCreature from './creature.mjs'; import { attributeField, resourceField, stressDamageReductionRule, bonusField } from '../fields/actorField.mjs'; import { ActionField } from '../fields/actionField.mjs'; import DHCharacterSettings from '../../applications/sheets-configs/character-settings.mjs'; -export default class DhCharacter extends BaseDataActor { +export default class DhCharacter extends DhCreature { /**@override */ static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Character']; @@ -131,14 +132,6 @@ export default class DhCharacter extends BaseDataActor { } } }), - advantageSources: new fields.ArrayField(new fields.StringField(), { - label: 'DAGGERHEART.ACTORS.Character.advantageSources.label', - hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint' - }), - disadvantageSources: new fields.ArrayField(new fields.StringField(), { - label: 'DAGGERHEART.ACTORS.Character.disadvantageSources.label', - hint: 'DAGGERHEART.ACTORS.Character.disadvantageSources.hint' - }), levelData: new fields.EmbeddedDataField(DhLevelData), bonuses: new fields.SchemaField({ roll: new fields.SchemaField({ diff --git a/module/data/actor/companion.mjs b/module/data/actor/companion.mjs index 40cece72..6f51b593 100644 --- a/module/data/actor/companion.mjs +++ b/module/data/actor/companion.mjs @@ -1,4 +1,4 @@ -import BaseDataActor from './base.mjs'; +import DhCreature from './creature.mjs'; import DhLevelData from '../levelData.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import { ActionField } from '../fields/actionField.mjs'; @@ -6,7 +6,7 @@ import { adjustDice, adjustRange } from '../../helpers/utils.mjs'; import DHCompanionSettings from '../../applications/sheets-configs/companion-settings.mjs'; import { resourceField, bonusField } from '../fields/actorField.mjs'; -export default class DhCompanion extends BaseDataActor { +export default class DhCompanion extends DhCreature { static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Companion']; /**@inheritdoc */ diff --git a/module/data/actor/creature.mjs b/module/data/actor/creature.mjs new file mode 100644 index 00000000..4b927aed --- /dev/null +++ b/module/data/actor/creature.mjs @@ -0,0 +1,20 @@ +import BaseDataActor from './base.mjs'; + +export default class DhCreature extends BaseDataActor { + /**@inheritdoc */ + static defineSchema() { + const fields = foundry.data.fields; + + return { + ...super.defineSchema(), + advantageSources: new fields.ArrayField(new fields.StringField(), { + label: 'DAGGERHEART.ACTORS.Character.advantageSources.label', + hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint' + }), + disadvantageSources: new fields.ArrayField(new fields.StringField(), { + label: 'DAGGERHEART.ACTORS.Character.disadvantageSources.label', + hint: 'DAGGERHEART.ACTORS.Character.disadvantageSources.hint' + }) + }; + } +} diff --git a/system.json b/system.json index 96636bab..fb23ad7b 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.7.1", + "version": "1.7.2", "compatibility": { "minimum": "13.346", "verified": "13.351", From ca434d33f1a8c67a7bf47988b0d20186b4490c8d Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:09:13 +0100 Subject: [PATCH 73/95] Fixed so that advantage/disadvantage dice are properly considered when rerolling (#1662) --- module/dice/dualityRoll.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/dice/dualityRoll.mjs b/module/dice/dualityRoll.mjs index e65d0ff5..cafe0cff 100644 --- a/module/dice/dualityRoll.mjs +++ b/module/dice/dualityRoll.mjs @@ -409,7 +409,9 @@ export default class DualityRoll extends D20Roll { difficulty: message.system.roll.difficulty ? Number(message.system.roll.difficulty) : null } }); - newRoll.extra = newRoll.extra.slice(2); + + const extraIndex = newRoll.advantage ? 3 : 2; + newRoll.extra = newRoll.extra.slice(extraIndex); const tagTeamSettings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll); From 9296b8fcc2fb28cfde80c7a53c7b466b116dedd6 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:10:06 +0100 Subject: [PATCH 74/95] Fixed so that scars are applied to hope.max during derived data prep (#1673) --- module/data/actor/character.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index aca87c5c..10fba63c 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -670,7 +670,7 @@ export default class DhCharacter extends DhCreature { }; const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope; - this.resources.hope.max = globalHopeMax - this.scars; + this.resources.hope.max = globalHopeMax; this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0; /* Companion Related Data */ @@ -694,6 +694,7 @@ export default class DhCharacter extends DhCreature { } } + this.resources.hope.max -= this.scars; this.resources.hope.value = Math.min(baseHope, this.resources.hope.max); this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait; From 267de9a8cfce8eac6471cf8ece858d2b760cb874 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:12:26 +0100 Subject: [PATCH 75/95] Fixed so that saving custom scene measurements work (#1664) --- module/applications/scene/sceneConfigSettings.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/module/applications/scene/sceneConfigSettings.mjs b/module/applications/scene/sceneConfigSettings.mjs index 5335be16..57a2bda4 100644 --- a/module/applications/scene/sceneConfigSettings.mjs +++ b/module/applications/scene/sceneConfigSettings.mjs @@ -104,7 +104,10 @@ export default class DhSceneConfigSettings extends foundry.applications.sheets.S /** @override */ async _processSubmitData(event, form, submitData, options) { if (!submitData.flags) submitData.flags = {}; - submitData.flags.daggerheart = this.daggerheartFlag.toObject(); + submitData.flags.daggerheart = foundry.utils.mergeObject( + this.daggerheartFlag.toObject(), + submitData.flags.daggerheart + ); submitData.flags.daggerheart.sceneEnvironments = submitData.flags.daggerheart.sceneEnvironments.filter(x => foundry.utils.fromUuidSync(x) ); From 56cc16b39ad4c6b96b681a13118b6df91294a4a6 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:32:35 +0100 Subject: [PATCH 76/95] [Feature] Item Description Enrichment (#1666) * Added enrichment for Ancestries and Communities * Fixed remainder * Bit of padding * Increased left padding --- lang/en.json | 18 ++++++- module/data/item/ancestry.mjs | 16 +++++- module/data/item/armor.mjs | 3 +- module/data/item/class.mjs | 54 ++++++++++++++++++- module/data/item/community.mjs | 14 +++++ module/data/item/subclass.mjs | 25 +++++++++ module/data/item/weapon.mjs | 17 +++++- module/helpers/utils.mjs | 16 ++++++ .../ancestry_Clank_ed8BoLR4SHOpeV00.json | 2 +- .../ancestry_Drakona_VLeOEqkLS0RbF0tB.json | 2 +- .../ancestry_Dwarf_pDt6fI6otv2E2odf.json | 2 +- .../ancestry_Elf_q2l6g3Ssa04K84GO.json | 2 +- .../ancestry_Faerie_XzJVbb5NT9k79ykR.json | 2 +- .../ancestry_Faun_HaYhe6WqoXW5EbRl.json | 2 +- .../ancestry_Firbolg_hzKmydI8sR3uk4CO.json | 2 +- .../ancestry_Fungril_J1hX7nBBc5jQiHli.json | 2 +- .../ancestry_Galapa_eZNG5Iv0yfbHs5CO.json | 2 +- .../ancestry_Giant_3U8CncG92a7ERIJ0.json | 2 +- .../ancestry_Goblin_EKPEdIz9lA9grPqH.json | 2 +- .../ancestry_Halfling_CtL2jDjvPOJxNJKm.json | 2 +- .../ancestry_Human_wtJ5V5qRppLQn61n.json | 2 +- .../ancestry_Infernis_hyxcuF2I0xcZSGkm.json | 2 +- .../ancestry_Katari_yyW0UM8srD9WuwW7.json | 2 +- .../ancestry_Orc_D1RbUsRV9HpTrPuF.json | 2 +- .../ancestry_Ribbet_HwOoBKXOL9Tf5j85.json | 2 +- .../ancestry_Simiah_2yMLxxn7CHEvmShj.json | 2 +- .../classes/class_Bard_vegl3bFOq3pcFTWT.json | 2 +- .../classes/class_Druid_ZNwUTCyGCEcidZFv.json | 2 +- .../class_Guardian_nRAyoC0fOzXPDa4z.json | 2 +- .../class_Ranger_BTyfve69LKqoOi9S.json | 2 +- .../classes/class_Rogue_CvHlkHZfpMiCz5uT.json | 2 +- .../class_Seraph_5ZnlJ5bEoyOTkUJv.json | 2 +- .../class_Sorcerer_DchOzHcWIJE9FKcR.json | 2 +- .../class_Warrior_xCUWwJz4WSthvLfy.json | 2 +- .../class_Wizard_5LwX4m8ziY3F1ZGC.json | 2 +- .../community_Highborne_DVw2mOCHB8i0XeBz.json | 2 +- .../community_Loreborne_YsvlyqYoi8QQ8kwm.json | 2 +- ...community_Orderborne_TY2TejenASXtS484.json | 2 +- ...community_Ridgeborne_WHLA4qrdszXQHOuo.json | 2 +- .../community_Seaborne_o5AA5J05N7EvH1rN.json | 2 +- .../community_Slyborne_rGwCPMqZtky7SE6d.json | 2 +- ...community_Underborne_eX0I1ZNMyD3nfaL1.json | 2 +- ...ommunity_Wanderborne_82mDY2EIBfLkNwQj.json | 2 +- .../community_Wildborne_CRJ5pzJj4FjCtIlx.json | 2 +- styles/less/global/elements.less | 6 +++ styles/less/global/global.less | 23 ++++++++ styles/less/global/inventory-item.less | 5 +- styles/less/ui/item-browser/item-browser.less | 2 +- templates/sheets/items/armor/description.hbs | 25 +++++++-- templates/sheets/items/class/description.hbs | 34 ++++++++++++ templates/sheets/items/description.hbs | 8 +++ .../sheets/items/subclass/description.hbs | 24 +++++++++ templates/sheets/items/weapon/description.hbs | 42 +++++++++++++-- 53 files changed, 350 insertions(+), 54 deletions(-) create mode 100644 templates/sheets/items/class/description.hbs create mode 100644 templates/sheets/items/description.hbs create mode 100644 templates/sheets/items/subclass/description.hbs diff --git a/lang/en.json b/lang/en.json index 055e49c6..937de844 100755 --- a/lang/en.json +++ b/lang/en.json @@ -2323,6 +2323,7 @@ "single": "Target", "plural": "Targets" }, + "thingsAndThing": "{things} and {thing}", "title": "Title", "tokenSize": "Token Size", "total": "Total", @@ -2361,7 +2362,8 @@ }, "Ancestry": { "primaryFeature": "Primary Feature", - "secondaryFeature": "Secondary Feature" + "secondaryFeature": "Secondary Feature", + "featuresLabel": "Ancestry Features" }, "Armor": { "baseScore": "Base Score", @@ -2414,7 +2416,12 @@ "evolvedImagePlaceholder": "The image for the form selected for evolution will be used" }, "Class": { + "startingEvasionScore": "Starting Evasion Score", + "startingHitPoints": "Starting Hit Points", + "classItems": "Class Items", + "hopeFeatureLabel": "{class}'s Hope Feature", "hopeFeatures": "Hope Features", + "classFeature": "Class Feature", "classFeatures": "Class Features", "guide": { "suggestedEquipment": "Suggested Equipments", @@ -2427,6 +2434,9 @@ } } }, + "Community": { + "featuresLabel": "Community Feature" + }, "Consumable": { "consumeOnUse": "Consume On Use", "destroyOnEmpty": "Destroy On Empty" @@ -2442,7 +2452,11 @@ "masteryTitle": "Mastery" }, "Subclass": { - "spellcastingTrait": "Spellcasting Trait" + "spellcastingTrait": "Spellcasting Trait", + "spellcastTrait": "Spellcast Trait", + "foundationFeatures": "Foundation Features", + "specializationFeature": "Specialization Feature", + "masteryFeature": "Mastery Feature" }, "Weapon": { "weaponType": "Weapon Type", diff --git a/module/data/item/ancestry.mjs b/module/data/item/ancestry.mjs index 6abdd334..b9253a3c 100644 --- a/module/data/item/ancestry.mjs +++ b/module/data/item/ancestry.mjs @@ -1,5 +1,6 @@ import BaseDataItem from './base.mjs'; import ItemLinkFields from '../../data/fields/itemLinkFields.mjs'; +import { getFeaturesHTMLData } from '../../helpers/utils.mjs'; export default class DHAncestry extends BaseDataItem { /** @inheritDoc */ @@ -19,7 +20,6 @@ export default class DHAncestry extends BaseDataItem { }; } - /* -------------------------------------------- */ /**@override */ @@ -42,4 +42,18 @@ export default class DHAncestry extends BaseDataItem { get secondaryFeature() { return this.features.find(x => x.type === CONFIG.DH.ITEM.featureSubTypes.secondary)?.item; } + + /**@inheritdoc */ + async getDescriptionData() { + const baseDescription = this.description; + const features = await getFeaturesHTMLData(this.features); + + if (!features.length) return { prefix: null, value: baseDescription, suffix: null }; + const suffix = await foundry.applications.handlebars.renderTemplate( + 'systems/daggerheart/templates/sheets/items/description.hbs', + { label: 'DAGGERHEART.ITEMS.Ancestry.featuresLabel', features } + ); + + return { prefix: null, value: baseDescription, suffix }; + } } diff --git a/module/data/item/armor.mjs b/module/data/item/armor.mjs index 3d4a62fa..2d31c290 100644 --- a/module/data/item/armor.mjs +++ b/module/data/item/armor.mjs @@ -59,11 +59,10 @@ export default class DHArmor extends AttachableItem { 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 } + { item: this.parent, features } ); return { prefix, value: baseDescription, suffix: null }; diff --git a/module/data/item/class.mjs b/module/data/item/class.mjs index c233a31b..d3738318 100644 --- a/module/data/item/class.mjs +++ b/module/data/item/class.mjs @@ -2,7 +2,7 @@ import BaseDataItem from './base.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs'; import ItemLinkFields from '../fields/itemLinkFields.mjs'; -import { addLinkedItemsDiff, updateLinkedItemApps } from '../../helpers/utils.mjs'; +import { addLinkedItemsDiff, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs'; export default class DHClass extends BaseDataItem { /** @inheritDoc */ @@ -163,4 +163,56 @@ export default class DHClass extends BaseDataItem { updateLinkedItemApps(options, this.parent.sheet); } + + /**@inheritdoc */ + async getDescriptionData() { + const baseDescription = this.description; + + const getDomainLabel = domain => { + const data = CONFIG.DH.DOMAIN.allDomains()[domain]; + return data ? game.i18n.localize(data.label) : ''; + }; + let domainsLabel = ''; + if (this.domains.length) { + if (this.domains.length === 1) domainsLabel = getDomainLabel(this.domains[0]); + else { + const firstDomains = this.domains + .slice(0, this.domains.length - 1) + .map(getDomainLabel) + .join(', '); + const lastDomain = getDomainLabel(this.domains[this.domains.length - 1]); + domainsLabel = game.i18n.format('DAGGERHEART.GENERAL.thingsAndThing', { + things: firstDomains, + thing: lastDomain + }); + } + } + + const classItems = []; + for (const itemData of this.inventory.choiceB) { + const linkData = [ + undefined, + 'UUID', // type + itemData.uuid // target + ]; + const contentLink = await foundry.applications.ux.TextEditor.implementation._createContentLink(linkData); + classItems.push(contentLink.outerHTML); + } + + const hopeFeatures = await getFeaturesHTMLData(this.hopeFeatures); + const classFeatures = await getFeaturesHTMLData(this.classFeatures); + + const suffix = await foundry.applications.handlebars.renderTemplate( + 'systems/daggerheart/templates/sheets/items/class/description.hbs', + { + class: this.parent, + domains: domainsLabel, + classItems, + hopeFeatures, + classFeatures + } + ); + + return { prefix: null, value: baseDescription, suffix }; + } } diff --git a/module/data/item/community.mjs b/module/data/item/community.mjs index a8000144..6d054976 100644 --- a/module/data/item/community.mjs +++ b/module/data/item/community.mjs @@ -1,3 +1,4 @@ +import { getFeaturesHTMLData } from '../../helpers/utils.mjs'; import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs'; import BaseDataItem from './base.mjs'; @@ -24,4 +25,17 @@ export default class DHCommunity extends BaseDataItem { /**@override */ static DEFAULT_ICON = 'systems/daggerheart/assets/icons/documents/items/village.svg'; + /**@inheritdoc */ + async getDescriptionData() { + const baseDescription = this.description; + const features = await getFeaturesHTMLData(this.features); + + if (!features.length) return { prefix: null, value: baseDescription, suffix: null }; + const suffix = await foundry.applications.handlebars.renderTemplate( + 'systems/daggerheart/templates/sheets/items/description.hbs', + { label: 'DAGGERHEART.ITEMS.Community.featuresLabel', features } + ); + + return { prefix: null, value: baseDescription, suffix }; + } } diff --git a/module/data/item/subclass.mjs b/module/data/item/subclass.mjs index 375588fb..06a80f7b 100644 --- a/module/data/item/subclass.mjs +++ b/module/data/item/subclass.mjs @@ -1,3 +1,4 @@ +import { getFeaturesHTMLData } from '../../helpers/utils.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import ItemLinkFields from '../fields/itemLinkFields.mjs'; import BaseDataItem from './base.mjs'; @@ -89,4 +90,28 @@ export default class DHSubclass extends BaseDataItem { const allowed = await super._preCreate(data, options, user); if (allowed === false) return; } + + /**@inheritdoc */ + async getDescriptionData() { + const baseDescription = this.description; + + const spellcastTrait = this.spellcastingTrait + ? game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.spellcastingTrait].label) + : null; + const foundationFeatures = await getFeaturesHTMLData(this.foundationFeatures); + const specializationFeatures = await getFeaturesHTMLData(this.specializationFeatures); + const masteryFeatures = await getFeaturesHTMLData(this.masteryFeatures); + + const suffix = await foundry.applications.handlebars.renderTemplate( + 'systems/daggerheart/templates/sheets/items/subclass/description.hbs', + { + spellcastTrait, + foundationFeatures, + specializationFeatures, + masteryFeatures + } + ); + + return { prefix: null, value: baseDescription, suffix }; + } } diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index f333e5f3..5c6f8514 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -113,13 +113,26 @@ export default class DHWeapon extends AttachableItem { /**@inheritdoc */ async getDescriptionData() { const baseDescription = this.description; + + const tier = game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${this.tier}`); + const trait = game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.attack.roll.trait].label); + const range = game.i18n.localize(`DAGGERHEART.CONFIG.Range.${this.attack.range}.name`); + const damage = Roll.replaceFormulaData(this.attack.damageFormula, this.parent.parent ?? this.parent); + const burden = game.i18n.localize(CONFIG.DH.GENERAL.burden[this.burden].label); + 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 } + { + features, + tier, + trait, + range, + damage, + burden + } ); return { prefix, value: baseDescription, suffix: null }; diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs index 607ce94e..4ecc7809 100644 --- a/module/helpers/utils.mjs +++ b/module/helpers/utils.mjs @@ -496,6 +496,22 @@ export function htmlToText(html) { return tempDivElement.textContent || tempDivElement.innerText || ''; } +export async function getFeaturesHTMLData(features) { + const result = []; + for (const feature of features) { + if (feature) { + const base = feature.item ?? feature; + const item = base.system ? base : await foundry.utils.fromUuid(base.uuid); + const itemDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML( + item.system.description + ); + result.push({ label: item.name, description: itemDescription }); + } + } + + return result; +} + /** * Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms. * All subtracted terms become negative terms. diff --git a/src/packs/ancestries/ancestry_Clank_ed8BoLR4SHOpeV00.json b/src/packs/ancestries/ancestry_Clank_ed8BoLR4SHOpeV00.json index 1108fe2e..c975a035 100644 --- a/src/packs/ancestries/ancestry_Clank_ed8BoLR4SHOpeV00.json +++ b/src/packs/ancestries/ancestry_Clank_ed8BoLR4SHOpeV00.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Clanks are sentient mechanical beings built from a variety of materials, including metal, wood, and stone. They can resemble humanoids, animals, or even inanimate objects. Like organic beings, their bodies come in a wide array of sizes. Because of their bespoke construction, many clanks have highly specialized physical configurations. Examples include clawed hands for grasping, wheels for movement, or built-in weaponry.

      Many clanks embrace body modifications for style as well as function, and members of other ancestries often turn to clank artisans to construct customized mobility aids and physical adornments. Other ancestries can create clanks, even using their own physical characteristics as inspiration, but it’s also common for clanks to build one another. A clank’s lifespan extends as long as they’re able to acquire or craft new parts, making their physical form effectively immortal. That said, their minds are subject to the effects of time, and deteriorate as the magic that powers them loses potency.

      ANCESTRY FEATURES

      Purposeful Design: Decide who made you and for what purpose. At character creation, choose one of your Experiences that best aligns with this purpose and gain a permanent +1 bonus to it.

      Efficient: When you take a short rest, you can choose a long rest move instead of a short rest move.

      ", + "description": "

      Clanks are sentient mechanical beings built from a variety of materials, including metal, wood, and stone. They can resemble humanoids, animals, or even inanimate objects. Like organic beings, their bodies come in a wide array of sizes. Because of their bespoke construction, many clanks have highly specialized physical configurations. Examples include clawed hands for grasping, wheels for movement, or built-in weaponry.

      Many clanks embrace body modifications for style as well as function, and members of other ancestries often turn to clank artisans to construct customized mobility aids and physical adornments. Other ancestries can create clanks, even using their own physical characteristics as inspiration, but it’s also common for clanks to build one another. A clank’s lifespan extends as long as they’re able to acquire or craft new parts, making their physical form effectively immortal. That said, their minds are subject to the effects of time, and deteriorate as the magic that powers them loses potency.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Drakona_VLeOEqkLS0RbF0tB.json b/src/packs/ancestries/ancestry_Drakona_VLeOEqkLS0RbF0tB.json index 49229d1d..5f4e31f6 100644 --- a/src/packs/ancestries/ancestry_Drakona_VLeOEqkLS0RbF0tB.json +++ b/src/packs/ancestries/ancestry_Drakona_VLeOEqkLS0RbF0tB.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Drakona resemble wingless dragons in humanoid form and possess a powerful elemental breath. All drakona have thick scales that provide excellent natural armor against both attacks and the forces of nature. They are large in size, ranging from 5 feet to 7 feet on average, with long sharp teeth. New teeth grow throughout a Drakona’s approximately 350-year lifespan, so they are never in danger of permanently losing an incisor. Unlike their dragon ancestors, drakona don’t have wings and can’t fly without magical aid. Members of this ancestry pass down the element of their breath through generations, though in rare cases, a drakona’s elemental power will differ from the rest of their family’s.

      ANCESTRY FEATURES

      Scales: Your scales act as natural protection. When you would take Severe damage, you can mark a Stress to mark 1 fewer Hit Points.

      Elemental Breath: Choose an element for your breath (such as electricity, fire, or ice). You can use this breath against a target or group of targets within Very Close range, treating it as an Instinct weapon that deals d8 magic damage using your Proficiency.

      ", + "description": "

      Drakona resemble wingless dragons in humanoid form and possess a powerful elemental breath. All drakona have thick scales that provide excellent natural armor against both attacks and the forces of nature. They are large in size, ranging from 5 feet to 7 feet on average, with long sharp teeth. New teeth grow throughout a Drakona’s approximately 350-year lifespan, so they are never in danger of permanently losing an incisor. Unlike their dragon ancestors, drakona don’t have wings and can’t fly without magical aid. Members of this ancestry pass down the element of their breath through generations, though in rare cases, a drakona’s elemental power will differ from the rest of their family’s.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Dwarf_pDt6fI6otv2E2odf.json b/src/packs/ancestries/ancestry_Dwarf_pDt6fI6otv2E2odf.json index 640ca729..7ca63d09 100644 --- a/src/packs/ancestries/ancestry_Dwarf_pDt6fI6otv2E2odf.json +++ b/src/packs/ancestries/ancestry_Dwarf_pDt6fI6otv2E2odf.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Dwarves are most easily recognized as short humanoids with square frames, dense musculature, and thick hair. Their average height ranges from 4 to 5 ½ feet, and they are often broad in proportion to their stature. Their skin and nails contain a high amount of keratin, making them naturally resilient. This allows dwarves to embed gemstones into their bodies and decorate themselves with tattoos or piercings. Their hair grows thickly—usually on their heads, but some dwarves have thick hair across their bodies as well. Dwarves of all genders can grow facial hair, which they often style in elaborate arrangements. Typically, dwarves live up to 250 years of age, maintaining their muscle mass well into later life.

      ANCESTRY FEATURES

      Thick Skin: When you take Minor damage, you can mark 2 Stress instead of marking a Hit Point.

      Increased Fortitude: Spend 3 Hope to halve incoming physical damage.

      ", + "description": "

      Dwarves are most easily recognized as short humanoids with square frames, dense musculature, and thick hair. Their average height ranges from 4 to 5 ½ feet, and they are often broad in proportion to their stature. Their skin and nails contain a high amount of keratin, making them naturally resilient. This allows dwarves to embed gemstones into their bodies and decorate themselves with tattoos or piercings. Their hair grows thickly—usually on their heads, but some dwarves have thick hair across their bodies as well. Dwarves of all genders can grow facial hair, which they often style in elaborate arrangements. Typically, dwarves live up to 250 years of age, maintaining their muscle mass well into later life.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Elf_q2l6g3Ssa04K84GO.json b/src/packs/ancestries/ancestry_Elf_q2l6g3Ssa04K84GO.json index 32868972..10b847f5 100644 --- a/src/packs/ancestries/ancestry_Elf_q2l6g3Ssa04K84GO.json +++ b/src/packs/ancestries/ancestry_Elf_q2l6g3Ssa04K84GO.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Elves are typically tall humanoids with pointed ears and acutely attuned senses. Their ears vary in size and pointed shape, and as they age, the tips begin to droop. While elves come in a wide range of body types, they are all fairly tall, with heights ranging from about 6 to 6 ½ feet. All elves have the ability to drop into a celestial trance, rather than sleep. This allows them to rest effectively in a short amount of time.

      Some elves possess what is known as a “mystic form,” which occurs when an elf has dedicated themself to the study or protection of the natural world so deeply that their physical form changes. These characteristics can include celestial freckles, the presence of leaves, vines, or flowers in their hair, eyes that flicker like fire, and more. Sometimes these traits are inherited from parents, but if an elf changes their environment or magical focus, their appearance changes over time. Because elves live for about 350 years, these traits can shift more than once throughout their lifespan.

      ANCESTRY FEATURES

      Quick Reactions: Mark a Stress to gain advantage on a reaction roll.

      Celestial Trance: During a rest, you can drop into a trance to choose an additional downtime move.

      ", + "description": "

      Elves are typically tall humanoids with pointed ears and acutely attuned senses. Their ears vary in size and pointed shape, and as they age, the tips begin to droop. While elves come in a wide range of body types, they are all fairly tall, with heights ranging from about 6 to 6 ½ feet. All elves have the ability to drop into a celestial trance, rather than sleep. This allows them to rest effectively in a short amount of time.

      Some elves possess what is known as a “mystic form,” which occurs when an elf has dedicated themself to the study or protection of the natural world so deeply that their physical form changes. These characteristics can include celestial freckles, the presence of leaves, vines, or flowers in their hair, eyes that flicker like fire, and more. Sometimes these traits are inherited from parents, but if an elf changes their environment or magical focus, their appearance changes over time. Because elves live for about 350 years, these traits can shift more than once throughout their lifespan.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Faerie_XzJVbb5NT9k79ykR.json b/src/packs/ancestries/ancestry_Faerie_XzJVbb5NT9k79ykR.json index 26e58162..c2318491 100644 --- a/src/packs/ancestries/ancestry_Faerie_XzJVbb5NT9k79ykR.json +++ b/src/packs/ancestries/ancestry_Faerie_XzJVbb5NT9k79ykR.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Faeries are winged humanoid creatures with insectile features. These characteristics cover a broad spectrum from humanoid to insectoid—some possess additional arms, compound eyes, lantern organs, chitinous exoskeletons, or stingers. Because of their close ties to the natural world, they also frequently possess attributes that allow them to blend in with various plants. The average height of a faerie ranges from about 2 feet to 5 feet, but some faeries grow up to 7 feet tall. All faeries possess membranous wings and they each go through a process of metamorphosis. The process and changes differ from faerie to faerie, but during this transformation each individual manifests the unique appearance they will carry throughout the rest of their approximately 50-year lifespan.

      ANCESTRY FEATURE

      Luckbender: Once per session, after you or a willing ally within Close range makes an action roll, you can spend 3 Hope to reroll the Duality Dice.

      Wings: You can fly. While flying, you can mark a Stress after an adversary makes an attack against you to gain a +2 bonus to your Evasion against that attack.

      ", + "description": "

      Faeries are winged humanoid creatures with insectile features. These characteristics cover a broad spectrum from humanoid to insectoid—some possess additional arms, compound eyes, lantern organs, chitinous exoskeletons, or stingers. Because of their close ties to the natural world, they also frequently possess attributes that allow them to blend in with various plants. The average height of a faerie ranges from about 2 feet to 5 feet, but some faeries grow up to 7 feet tall. All faeries possess membranous wings and they each go through a process of metamorphosis. The process and changes differ from faerie to faerie, but during this transformation each individual manifests the unique appearance they will carry throughout the rest of their approximately 50-year lifespan.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Faun_HaYhe6WqoXW5EbRl.json b/src/packs/ancestries/ancestry_Faun_HaYhe6WqoXW5EbRl.json index b4cf3c4a..d3234f0f 100644 --- a/src/packs/ancestries/ancestry_Faun_HaYhe6WqoXW5EbRl.json +++ b/src/packs/ancestries/ancestry_Faun_HaYhe6WqoXW5EbRl.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Fauns resemble humanoid goats with curving horns, square pupils, and cloven hooves. Though their appearances may vary, most fauns have a humanoid torso and a goatlike lower body covered in dense fur. Faun faces can be more caprine or more humanlike, and they have a wide variety of ear and horn shapes. Faun horns range from short with minimal curvature to much larger with a distinct curl. The average faun ranges from 4 feet to 6 ½ feet tall, but their height can change dramatically from one moment to the next based on their stance. The majority of fauns have proportionately long limbs, no matter their size or shape, and are known for their ability to deliver powerful blows with their split hooves. Fauns live for roughly 225 years, and as they age, their appearance can become increasingly goatlike.

      ANCESTRY FEATURES

      Caprine Leap: You can leap anywhere within Close range as though you were using normal movement, allowing you to vault obstacles, jump across gaps, or scale barriers with ease.

      Kick: When you succeed on an attack against a target within Melee range, you can mark a Stress to kick yourself off them, dealing an extra 2d6 damage and knocking back either yourself or the target to Very Close range.

      ", + "description": "

      Fauns resemble humanoid goats with curving horns, square pupils, and cloven hooves. Though their appearances may vary, most fauns have a humanoid torso and a goatlike lower body covered in dense fur. Faun faces can be more caprine or more humanlike, and they have a wide variety of ear and horn shapes. Faun horns range from short with minimal curvature to much larger with a distinct curl. The average faun ranges from 4 feet to 6 ½ feet tall, but their height can change dramatically from one moment to the next based on their stance. The majority of fauns have proportionately long limbs, no matter their size or shape, and are known for their ability to deliver powerful blows with their split hooves. Fauns live for roughly 225 years, and as they age, their appearance can become increasingly goatlike.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Firbolg_hzKmydI8sR3uk4CO.json b/src/packs/ancestries/ancestry_Firbolg_hzKmydI8sR3uk4CO.json index 0722517e..b98473ac 100644 --- a/src/packs/ancestries/ancestry_Firbolg_hzKmydI8sR3uk4CO.json +++ b/src/packs/ancestries/ancestry_Firbolg_hzKmydI8sR3uk4CO.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Firbolgs are bovine humanoids typically recognized by their broad noses and long, drooping ears. Some have faces that are a blend of humanoid and bison, ox, cow, or other bovine creatures. Others, often referred to as minotaurs, have heads that entirely resemble cattle. They are tall and muscular creatures, with heights ranging from around 5 feet to 7 feet, and possess remarkable strength no matter their age. Some firbolgs are known to use this strength to charge their adversaries, an action that is particuarly effective for those who have one of the many varieties of horn styles commonly found in this ancestry. Though their unique characteristics can vary, all firbolgs are covered in fur, which can be muted and earth-toned in color, or come in a variety of pastels, such as soft pinks and blues. On average, firbolgs live for about 150 years.

      ANCESTRY FEATURES

      Charge: When you succeed on an Agility Roll to move from Far or Very Far range into Melee range with one or more targets, you can mark a Stress to deal 1d12 physical damage to all targets within Melee range.

      Unshakable: When you would mark a Stress, roll a d6. On a result of 6, don’t mark it.

      ", + "description": "

      Firbolgs are bovine humanoids typically recognized by their broad noses and long, drooping ears. Some have faces that are a blend of humanoid and bison, ox, cow, or other bovine creatures. Others, often referred to as minotaurs, have heads that entirely resemble cattle. They are tall and muscular creatures, with heights ranging from around 5 feet to 7 feet, and possess remarkable strength no matter their age. Some firbolgs are known to use this strength to charge their adversaries, an action that is particuarly effective for those who have one of the many varieties of horn styles commonly found in this ancestry. Though their unique characteristics can vary, all firbolgs are covered in fur, which can be muted and earth-toned in color, or come in a variety of pastels, such as soft pinks and blues. On average, firbolgs live for about 150 years.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Fungril_J1hX7nBBc5jQiHli.json b/src/packs/ancestries/ancestry_Fungril_J1hX7nBBc5jQiHli.json index 75e52c8c..32657c24 100644 --- a/src/packs/ancestries/ancestry_Fungril_J1hX7nBBc5jQiHli.json +++ b/src/packs/ancestries/ancestry_Fungril_J1hX7nBBc5jQiHli.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Fungril resemble humanoid mushrooms. They can be either more humanoid or more fungal in appearance, and they come in an assortment of colors, from earth tones to bright reds, yellows, purples, and blues. Fungril display an incredible variety of bodies, faces, and limbs, as there’s no single common shape among them. Even their heights range from a tiny 2 feet tall to a staggering 7 feet tall. While the common lifespan of a fungril is about 300 years, some have been reported to live much longer. They can communicate nonverbally, and many members of this ancestry use a mycelial array to chemically exchange information with other fungril across long distances.

      ANCESTRY FEATURES

      Fungril Network: Make an Instinct Roll (12) to use your mycelial array to speak with others of your ancestry. On a success, you can communicate across any distance.

      Death Connection: While touching a corpse that died recently, you can mark a Stress to extract one memory from the corpse related to a specific emotion or sensation of your choice.

      ", + "description": "

      Fungril resemble humanoid mushrooms. They can be either more humanoid or more fungal in appearance, and they come in an assortment of colors, from earth tones to bright reds, yellows, purples, and blues. Fungril display an incredible variety of bodies, faces, and limbs, as there’s no single common shape among them. Even their heights range from a tiny 2 feet tall to a staggering 7 feet tall. While the common lifespan of a fungril is about 300 years, some have been reported to live much longer. They can communicate nonverbally, and many members of this ancestry use a mycelial array to chemically exchange information with other fungril across long distances.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Galapa_eZNG5Iv0yfbHs5CO.json b/src/packs/ancestries/ancestry_Galapa_eZNG5Iv0yfbHs5CO.json index b15fed89..afb0d5e2 100644 --- a/src/packs/ancestries/ancestry_Galapa_eZNG5Iv0yfbHs5CO.json +++ b/src/packs/ancestries/ancestry_Galapa_eZNG5Iv0yfbHs5CO.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Galapa resemble anthropomorphic turtles with large, domed shells into which they can retract. On average, they range from 4 feet to 6 feet in height, and their head and body shapes can resemble any type of turtle. Galapa come in a variety of earth tones—most often shades of green and brown— and possess unique patterns on their shells. Members of this ancestry can draw their head, arms, and legs into their shell for protection to use it as a natural shield when defensive measures are needed. Some supplement their shell's strength or appearance by attaching armor or carving unique designs, but the process is exceedingly painful. Most galapa move slowly no matter their age, and they can live approximately 150 years.

      ANCESTRY FEATURES

      Shell: Gain a bonus to your damage thresholds equal to your Proficiency.

      Retract: Mark a Stress to retract into your shell. While in your shell, you have resistance to physical damage, you have disadvantage on action rolls, and you can’t move.

      ", + "description": "

      Galapa resemble anthropomorphic turtles with large, domed shells into which they can retract. On average, they range from 4 feet to 6 feet in height, and their head and body shapes can resemble any type of turtle. Galapa come in a variety of earth tones—most often shades of green and brown— and possess unique patterns on their shells. Members of this ancestry can draw their head, arms, and legs into their shell for protection to use it as a natural shield when defensive measures are needed. Some supplement their shell's strength or appearance by attaching armor or carving unique designs, but the process is exceedingly painful. Most galapa move slowly no matter their age, and they can live approximately 150 years.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Giant_3U8CncG92a7ERIJ0.json b/src/packs/ancestries/ancestry_Giant_3U8CncG92a7ERIJ0.json index 2c0031dd..1ce5acfd 100644 --- a/src/packs/ancestries/ancestry_Giant_3U8CncG92a7ERIJ0.json +++ b/src/packs/ancestries/ancestry_Giant_3U8CncG92a7ERIJ0.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Giants are towering humanoids with broad shoulders, long arms, and one to three eyes. Adult giants range from 6 ½ to 8 ½ feet tall and are naturally muscular, regardless of body type. They are easily recognized by their wide frames and elongated arms and necks. Though they can have up to three eyes, all giants are born with none and remain sightless for their first year of life. Until a giant reaches the age of 10 and their features fully develop, the formation of their eyes may fluctuate. Those with a single eye are commonly known as cyclops. The average giant lifespan is about 75 years.

      ANCESTRY FEATURES

      Endurance: Gain an additional Hit Point slot at character creation.

      Reach: Treat any weapon, ability, spell, or other feature that has a Melee range as though it has a Very Close range instead.

      ", + "description": "

      Giants are towering humanoids with broad shoulders, long arms, and one to three eyes. Adult giants range from 6 ½ to 8 ½ feet tall and are naturally muscular, regardless of body type. They are easily recognized by their wide frames and elongated arms and necks. Though they can have up to three eyes, all giants are born with none and remain sightless for their first year of life. Until a giant reaches the age of 10 and their features fully develop, the formation of their eyes may fluctuate. Those with a single eye are commonly known as cyclops. The average giant lifespan is about 75 years.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Goblin_EKPEdIz9lA9grPqH.json b/src/packs/ancestries/ancestry_Goblin_EKPEdIz9lA9grPqH.json index 7b292343..fcc6eea3 100644 --- a/src/packs/ancestries/ancestry_Goblin_EKPEdIz9lA9grPqH.json +++ b/src/packs/ancestries/ancestry_Goblin_EKPEdIz9lA9grPqH.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Goblins are small humanoids easily recognizable by their large eyes and massive membranous ears. With keen hearing and sharp eyesight, they perceive details both at great distances and in darkness, allowing them to move through less-optimal environments with ease. Their skin and eye colors are incredibly varied, with no one hue, either vibrant or subdued, more dominant than another. A typical goblin stands between 3 feet and 4 feet tall, and each of their ears is about the size of their head. Goblins are known to use ear positions to very specific effect when communicating nonverbally. A goblin’s lifespan is roughly 100 years, and many maintain their keen hearing and sight well into advanced age.

      ANCESTRY FEATURES

      Surefooted: You ignore disadvantage on Agility Rolls.

      Danger Sense: Once per rest, mark a Stress to force an adversary to reroll an attack against you or an ally within Very Close range.

      ", + "description": "

      Goblins are small humanoids easily recognizable by their large eyes and massive membranous ears. With keen hearing and sharp eyesight, they perceive details both at great distances and in darkness, allowing them to move through less-optimal environments with ease. Their skin and eye colors are incredibly varied, with no one hue, either vibrant or subdued, more dominant than another. A typical goblin stands between 3 feet and 4 feet tall, and each of their ears is about the size of their head. Goblins are known to use ear positions to very specific effect when communicating nonverbally. A goblin’s lifespan is roughly 100 years, and many maintain their keen hearing and sight well into advanced age.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Halfling_CtL2jDjvPOJxNJKm.json b/src/packs/ancestries/ancestry_Halfling_CtL2jDjvPOJxNJKm.json index 4c694f1a..e70184d4 100644 --- a/src/packs/ancestries/ancestry_Halfling_CtL2jDjvPOJxNJKm.json +++ b/src/packs/ancestries/ancestry_Halfling_CtL2jDjvPOJxNJKm.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Halflings are small humanoids with large hairy feet and prominent rounded ears. On average, halflings are 3 to 4 feet in height, and their ears, nose, and feet are larger in proportion to the rest of their body. Members of this ancestry live for around 150 years, and a halfling’s appearance is likely to remain youthful even as they progress from adulthood into old age. Halflings are naturally attuned to the magnetic fields of the Mortal Realm, granting them a strong internal compass. They also possess acute senses of hearing and smell, and can often detect those who are familiar to them by the sound of their movements.

      ANCESTRY FEATURES

      Luckbringer: At the start of each session, everyone in your party gains a Hope.

      Internal Compass: When you roll a 1 on your Hope Die, you can reroll it.

      ", + "description": "

      Halflings are small humanoids with large hairy feet and prominent rounded ears. On average, halflings are 3 to 4 feet in height, and their ears, nose, and feet are larger in proportion to the rest of their body. Members of this ancestry live for around 150 years, and a halfling’s appearance is likely to remain youthful even as they progress from adulthood into old age. Halflings are naturally attuned to the magnetic fields of the Mortal Realm, granting them a strong internal compass. They also possess acute senses of hearing and smell, and can often detect those who are familiar to them by the sound of their movements.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Human_wtJ5V5qRppLQn61n.json b/src/packs/ancestries/ancestry_Human_wtJ5V5qRppLQn61n.json index 553b2168..d4575e91 100644 --- a/src/packs/ancestries/ancestry_Human_wtJ5V5qRppLQn61n.json +++ b/src/packs/ancestries/ancestry_Human_wtJ5V5qRppLQn61n.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Humans are most easily recognized by their dexterous hands, rounded ears, and bodies built for endurance. Their average height ranges from just under 5 feet to about 6 ½ feet. They have a wide variety of builds, with some being quite broad, others lithe, and many inhabiting the spectrum in between. Humans are physically adaptable and adjust to harsh climates with relative ease. In general, humans live to an age of about 100, with their bodies changing dramatically between their youngest and oldest years.

      ANCESTRY FEATURES

      High Stamina: Gain an additional Stress slot at character creation.

      Adaptability: When you fail a roll that utilized one of your Experiences, you can mark a Stress to reroll.

      ", + "description": "

      Humans are most easily recognized by their dexterous hands, rounded ears, and bodies built for endurance. Their average height ranges from just under 5 feet to about 6 ½ feet. They have a wide variety of builds, with some being quite broad, others lithe, and many inhabiting the spectrum in between. Humans are physically adaptable and adjust to harsh climates with relative ease. In general, humans live to an age of about 100, with their bodies changing dramatically between their youngest and oldest years.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Infernis_hyxcuF2I0xcZSGkm.json b/src/packs/ancestries/ancestry_Infernis_hyxcuF2I0xcZSGkm.json index d5688721..6dc43b66 100644 --- a/src/packs/ancestries/ancestry_Infernis_hyxcuF2I0xcZSGkm.json +++ b/src/packs/ancestries/ancestry_Infernis_hyxcuF2I0xcZSGkm.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Infernis are humanoids who possess sharp canine teeth, pointed ears, and horns. They are the descendants of demons from the Circles Below. On average, infernis range in height from 5 feet to 7 feet and are known to have long fingers and pointed nails. Some have long, thin, and smooth tails that end in points, forks, or arrowheads. It’s common for infernis to have two or four horns—though some have crowns of many horns, or only one. These horns can also grow asymmetrically, forming unique, often curving, shapes that infernis enhance with carving and ornamentation. Their skin, hair, and horns come in an assortment of colors that can include soft pastels, stark tones, or vibrant hues, such as rosy scarlet, deep purple, and pitch black.

      Infernis possess a “dread visage” that manifests both involuntarily, such as when they experience fear or other strong emotions, or purposefully, such as when they wish to intimidate an adversary. This visage can briefly modify their appearance in a variety of ways, including lengthening their teeth and nails, changing the colors of their eyes, twisting their horns, or enhancing their height. On average, infernis live up to 350 years, with some attributing this lifespan to their demonic lineage.

      ANCESTRY FEATURES

      Fearless: When you roll with Fear, you can mark 2 Stress to change it into a roll with Hope instead.

      Dread Visage: You have advantage on rolls to intimidate hostile creatures.

      ", + "description": "

      Infernis are humanoids who possess sharp canine teeth, pointed ears, and horns. They are the descendants of demons from the Circles Below. On average, infernis range in height from 5 feet to 7 feet and are known to have long fingers and pointed nails. Some have long, thin, and smooth tails that end in points, forks, or arrowheads. It’s common for infernis to have two or four horns—though some have crowns of many horns, or only one. These horns can also grow asymmetrically, forming unique, often curving, shapes that infernis enhance with carving and ornamentation. Their skin, hair, and horns come in an assortment of colors that can include soft pastels, stark tones, or vibrant hues, such as rosy scarlet, deep purple, and pitch black.

      Infernis possess a “dread visage” that manifests both involuntarily, such as when they experience fear or other strong emotions, or purposefully, such as when they wish to intimidate an adversary. This visage can briefly modify their appearance in a variety of ways, including lengthening their teeth and nails, changing the colors of their eyes, twisting their horns, or enhancing their height. On average, infernis live up to 350 years, with some attributing this lifespan to their demonic lineage.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Katari_yyW0UM8srD9WuwW7.json b/src/packs/ancestries/ancestry_Katari_yyW0UM8srD9WuwW7.json index 7849f7ec..47d6c1b9 100644 --- a/src/packs/ancestries/ancestry_Katari_yyW0UM8srD9WuwW7.json +++ b/src/packs/ancestries/ancestry_Katari_yyW0UM8srD9WuwW7.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Katari are feline humanoids with retractable claws, vertically slit pupils, and high, triangular ears. They can also have small, pointed canine teeth, soft fur, and long whiskers that assist their perception and navigation. Their ears can swivel nearly 180 degrees to detect sound, adding to their heightened senses. Katari may look more or less feline or humanoid, with catlike attributes in the form of hair, whiskers, and a muzzle. About half of the katari population have tails. Their skin and fur come in a wide range of hues and patterns, including solid colors, calico tones, tabby stripes, and an array of spots, patches, marbling, or bands. Their height ranges from about 3 feet to 6 ½ feet, and they live to around 150 years.

      ANCESTRY FEATURES

      Feline Instincts: When you make an Agility Roll, you can spend 2 Hope to reroll your Hope Die.

      Retracting Claws: Make an Agility Roll to scratch a target within Melee range. On a success, they become temporarily Vulnerable.

      ", + "description": "

      Katari are feline humanoids with retractable claws, vertically slit pupils, and high, triangular ears. They can also have small, pointed canine teeth, soft fur, and long whiskers that assist their perception and navigation. Their ears can swivel nearly 180 degrees to detect sound, adding to their heightened senses. Katari may look more or less feline or humanoid, with catlike attributes in the form of hair, whiskers, and a muzzle. About half of the katari population have tails. Their skin and fur come in a wide range of hues and patterns, including solid colors, calico tones, tabby stripes, and an array of spots, patches, marbling, or bands. Their height ranges from about 3 feet to 6 ½ feet, and they live to around 150 years.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Orc_D1RbUsRV9HpTrPuF.json b/src/packs/ancestries/ancestry_Orc_D1RbUsRV9HpTrPuF.json index 264d3da3..fdb60eb5 100644 --- a/src/packs/ancestries/ancestry_Orc_D1RbUsRV9HpTrPuF.json +++ b/src/packs/ancestries/ancestry_Orc_D1RbUsRV9HpTrPuF.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Orcs are humanoids most easily recognized by their square features and boar-like tusks that protrude from their lower jaw. Tusks come in various sizes, and though they extend from the mouth, they aren’t used for consuming food. Instead, many orcs choose to decorate their tusks with significant ornamentation. Orcs typically live for 125 years, and unless altered, their tusks continue to grow throughout the course of their lives. Their ears are pointed, and their hair and skin typically have green, blue, pink, or gray tones. Orcs tend toward a muscular build, and their average height ranges from 5 feet to 6 ½ feet.

      ANCESTRY FEATURES

      Sturdy: When you have 1 Hit Point remaining, attacks against you have disadvantage.

      Tusks: When you succeed on an attack against a target within Melee range, you can spend a Hope to gore the target with your tusks, dealing an extra 1d6 damage.

      ", + "description": "

      Orcs are humanoids most easily recognized by their square features and boar-like tusks that protrude from their lower jaw. Tusks come in various sizes, and though they extend from the mouth, they aren’t used for consuming food. Instead, many orcs choose to decorate their tusks with significant ornamentation. Orcs typically live for 125 years, and unless altered, their tusks continue to grow throughout the course of their lives. Their ears are pointed, and their hair and skin typically have green, blue, pink, or gray tones. Orcs tend toward a muscular build, and their average height ranges from 5 feet to 6 ½ feet.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Ribbet_HwOoBKXOL9Tf5j85.json b/src/packs/ancestries/ancestry_Ribbet_HwOoBKXOL9Tf5j85.json index 48400eaf..3ff1549d 100644 --- a/src/packs/ancestries/ancestry_Ribbet_HwOoBKXOL9Tf5j85.json +++ b/src/packs/ancestries/ancestry_Ribbet_HwOoBKXOL9Tf5j85.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Ribbets resemble anthropomorphic frogs with protruding eyes and webbed hands and feet. They have smooth (though sometimes warty) moist skin and eyes positioned on either side of their head. Some ribbets have hind legs more than twice the length of their torso, while others have short limbs. No matter their size (which ranges from about 3 feet to 4 ½ feet), ribbets primarily move by hopping. All ribbets have webbed appendages, allowing them to swim with ease. Some ribbets possess a natural green-and-brown camouflage, while others are vibrantly colored with bold patterns. No matter their appearance, all ribbets are born from eggs laid in the water, hatch into tadpoles, and after about 6 to 7 years, grow into amphibians that can move around on land. Ribbets live for approximately 100 years.

      ANCESTRY FEATURES

      Amphibious: You can breathe and move naturally underwater.

      Long Tongue: You can use your long tongue to grab onto things within Close range. Mark a Stress to use your tongue as a Finesse Close weapon that deals d12 physical damage using your Proficiency.

      ", + "description": "

      Ribbets resemble anthropomorphic frogs with protruding eyes and webbed hands and feet. They have smooth (though sometimes warty) moist skin and eyes positioned on either side of their head. Some ribbets have hind legs more than twice the length of their torso, while others have short limbs. No matter their size (which ranges from about 3 feet to 4 ½ feet), ribbets primarily move by hopping. All ribbets have webbed appendages, allowing them to swim with ease. Some ribbets possess a natural green-and-brown camouflage, while others are vibrantly colored with bold patterns. No matter their appearance, all ribbets are born from eggs laid in the water, hatch into tadpoles, and after about 6 to 7 years, grow into amphibians that can move around on land. Ribbets live for approximately 100 years.

      ", "features": [ { "type": "primary", diff --git a/src/packs/ancestries/ancestry_Simiah_2yMLxxn7CHEvmShj.json b/src/packs/ancestries/ancestry_Simiah_2yMLxxn7CHEvmShj.json index d3f77f36..995e0c95 100644 --- a/src/packs/ancestries/ancestry_Simiah_2yMLxxn7CHEvmShj.json +++ b/src/packs/ancestries/ancestry_Simiah_2yMLxxn7CHEvmShj.json @@ -4,7 +4,7 @@ "type": "ancestry", "folder": null, "system": { - "description": "

      Simiah resemble anthropomorphic monkeys and apes with long limbs and prehensile feet. While their appearance reflects all simian creatures, from the largest gorilla to the smallest marmoset, their size does not align with their animal counterparts, and they can be anywhere from 2 to 6 feet tall. All simiah can use their dexterous feet for nonverbal communication, work, and combat. Additionally, some also have prehensile tails that can grasp objects or help with balance during difficult maneuvers. These traits grant members of this ancestry unique agility that aids them in a variety of physical tasks. In particular, simiah are skilled climbers and can easily transition from bipedal movement to knuckle-walking and climbing, and back again. On average, simiah live for about 100 years.

      ANCESTRY FEATURES

      Natural Climber: You have advantage on Agility Rolls that involve balancing and climbing.

      Nimble: Gain a permanent +1 bonus to your Evasion at character creation.

      ", + "description": "

      Simiah resemble anthropomorphic monkeys and apes with long limbs and prehensile feet. While their appearance reflects all simian creatures, from the largest gorilla to the smallest marmoset, their size does not align with their animal counterparts, and they can be anywhere from 2 to 6 feet tall. All simiah can use their dexterous feet for nonverbal communication, work, and combat. Additionally, some also have prehensile tails that can grasp objects or help with balance during difficult maneuvers. These traits grant members of this ancestry unique agility that aids them in a variety of physical tasks. In particular, simiah are skilled climbers and can easily transition from bipedal movement to knuckle-walking and climbing, and back again. On average, simiah live for about 100 years.

      ", "features": [ { "type": "primary", diff --git a/src/packs/classes/class_Bard_vegl3bFOq3pcFTWT.json b/src/packs/classes/class_Bard_vegl3bFOq3pcFTWT.json index b596d5c0..b7830722 100644 --- a/src/packs/classes/class_Bard_vegl3bFOq3pcFTWT.json +++ b/src/packs/classes/class_Bard_vegl3bFOq3pcFTWT.json @@ -4,7 +4,7 @@ "type": "class", "img": "icons/tools/instruments/harp-red.webp", "system": { - "description": "

      Note: At level 5 use Rally (Level 5) instead of Rally under class feature. (Automation will be implemented in a later release)


      Bards are the most charismatic people in all the realms. Members of this class are masters of captivation and specialize in a variety of performance types, including singing, playing musical instruments, weaving tales, or telling jokes. Whether performing for an audience or speaking to an individual, bards thrive in social situations. Members of this profession bond and train at schools or guilds, but a current of egotism runs through those of the bardic persuasion. While they may be the most likely class to bring people together, a bard of ill temper can just as easily tear a party apart.

      CLASS ITEMS

      A romance novel or a letter never opened

      BARD’S HOPE FEATURE

      Make a Scene: Spend 3 Hope to temporarily Distract a target within Close range, giving them a -2 penalty to their Difficulty.

      CLASS FEATURE

      Rally

      Once per session, describe how you rally the party and give yourself and each of your allies a Rally Die. At level 1, your Rally Die is a d6. A PC can spend their Rally Die to roll it, adding the result to their action roll, reaction roll, damage roll, or to clear a number of Stress equal to the result. At the end of each session, clear all unspent Rally Dice.

      At level 5, your Rally Die increases to a d8.

      ", + "description": "

      Note: At level 5 use Rally (Level 5) instead of Rally under class feature. (Automation will be implemented in a later release)


      Bards are the most charismatic people in all the realms. Members of this class are masters of captivation and specialize in a variety of performance types, including singing, playing musical instruments, weaving tales, or telling jokes. Whether performing for an audience or speaking to an individual, bards thrive in social situations. Members of this profession bond and train at schools or guilds, but a current of egotism runs through those of the bardic persuasion. While they may be the most likely class to bring people together, a bard of ill temper can just as easily tear a party apart.

      ", "domains": [ "grace", "codex" diff --git a/src/packs/classes/class_Druid_ZNwUTCyGCEcidZFv.json b/src/packs/classes/class_Druid_ZNwUTCyGCEcidZFv.json index 09deb8e6..c6ccaf53 100644 --- a/src/packs/classes/class_Druid_ZNwUTCyGCEcidZFv.json +++ b/src/packs/classes/class_Druid_ZNwUTCyGCEcidZFv.json @@ -4,7 +4,7 @@ "_id": "ZNwUTCyGCEcidZFv", "img": "icons/creatures/mammals/wolf-howl-moon-black.webp", "system": { - "description": "

      Becoming a druid is more than an occupation; it’s a calling for those who wish to learn from and protect the magic of the wilderness. While one might underestimate a gentle druid who practices the often-quiet work of cultivating flora, druids who channel the untamed forces of nature are terrifying to behold. Druids cultivate their abilities in small groups, often connected by a specific ethos or locale, but some choose to work alone. Through years of study and dedication, druids can learn to transform into beasts and shape nature itself.

      CLASS ITEMS

      A small bag of rocks and bones or a strange pendant found in the dirt

      DRUID’S HOPE FEATURE

      Evolution: Spend 3 Hope to transform into a Beastform without marking a Stress. When you do, choose one trait to raise by +1 until you drop out of that Beastform.

      CLASS FEATURES

      Beastform: Mark a Stress to magically transform into a creature of your tier or lower from the Beastform list. You can drop out of this form at any time. While transformed, you can’t use weapons or cast spells from domain cards, but you can still use other features or abilities you have access to. Spells you cast before you transform stay active and last for their normal duration, and you can talk and communicate as normal. Additionally, you gain the Beastform’s features, add their Evasion bonus to your Evasion, and use the trait specified in their statistics for your attack. While you’re in a Beastform, your armor becomes part of your body and you mark Armor Slots as usual; when you drop out of a Beastform, those marked Armor Slots remain marked. If you mark your last Hit Point, you automatically drop out of this form.

      Wildtouch: You can perform harmless, subtle effects that involve nature—such as causing a flower to rapidly grow, summoning a slight gust of wind, or starting a campfire—at will.

      ", + "description": "

      Becoming a druid is more than an occupation; it’s a calling for those who wish to learn from and protect the magic of the wilderness. While one might underestimate a gentle druid who practices the often-quiet work of cultivating flora, druids who channel the untamed forces of nature are terrifying to behold. Druids cultivate their abilities in small groups, often connected by a specific ethos or locale, but some choose to work alone. Through years of study and dedication, druids can learn to transform into beasts and shape nature itself.

      ", "domains": [ "sage", "arcana" diff --git a/src/packs/classes/class_Guardian_nRAyoC0fOzXPDa4z.json b/src/packs/classes/class_Guardian_nRAyoC0fOzXPDa4z.json index eec4ba5a..8293ecd5 100644 --- a/src/packs/classes/class_Guardian_nRAyoC0fOzXPDa4z.json +++ b/src/packs/classes/class_Guardian_nRAyoC0fOzXPDa4z.json @@ -4,7 +4,7 @@ "_id": "nRAyoC0fOzXPDa4z", "img": "icons/equipment/shield/heater-wooden-sword-green.webp", "system": { - "description": "

      The title of guardian represents an array of martial professions, speaking more to their moral compass and unshakeable fortitude than the means by which they fight. While many guardians join groups of militants for either a country or cause, they’re more likely to follow those few they truly care for, majority be damned. Guardians are known for fighting with remarkable ferocity even against overwhelming odds, defending their cohort above all else. Woe betide those who harm the ally of a guardian, as the guardian will answer this injury in kind.

      CLASS ITEMS

      A totem from your mentor or a secret key

      GUARDIAN’S HOPE FEATURE

      Frontline Tank: Spend 3 Hope to clear 2 Armor Slots.

      CLASS FEATURE

      Unstoppable: Once per long rest, you can become Unstoppable. You gain an Unstoppable Die. At level 1, your Unstoppable Die is a d4. Place it on your character sheet in the space provided, starting with the 1 value facing up. After you make a damage roll that deals 1 or more Hit Points to a target, increase the Unstoppable Die value by one. When the die’s value would exceed its maximum value or when the scene ends, remove the die and drop out of Unstoppable. At level 5, your Unstoppable Die increases to a d6.

      While Unstoppable, you gain the following benefits:

      • You reduce the severity of physical damage by one threshold (Severe to Major, Major to Minor, Minor to None).

      • You add the current value of the Unstoppable Die to your damage roll.

      • You can’t be Restrained or Vulnerable.

      ", + "description": "

      The title of guardian represents an array of martial professions, speaking more to their moral compass and unshakeable fortitude than the means by which they fight. While many guardians join groups of militants for either a country or cause, they’re more likely to follow those few they truly care for, majority be damned. Guardians are known for fighting with remarkable ferocity even against overwhelming odds, defending their cohort above all else. Woe betide those who harm the ally of a guardian, as the guardian will answer this injury in kind.

      ", "domains": [ "valor", "blade" diff --git a/src/packs/classes/class_Ranger_BTyfve69LKqoOi9S.json b/src/packs/classes/class_Ranger_BTyfve69LKqoOi9S.json index a0e16bf9..2d459b7a 100644 --- a/src/packs/classes/class_Ranger_BTyfve69LKqoOi9S.json +++ b/src/packs/classes/class_Ranger_BTyfve69LKqoOi9S.json @@ -4,7 +4,7 @@ "_id": "BTyfve69LKqoOi9S", "img": "icons/weapons/bows/shortbow-recurve-yellow-blue.webp", "system": { - "description": "

      Rangers are highly skilled hunters who, despite their martial abilities, rarely lend their skills to an army. Through mastery of the body and a deep understanding of the wilderness, rangers become sly tacticians, pursuing their quarry with cunning and patience. Many rangers track and fight alongside an animal companion with whom they’ve forged a powerful spiritual bond. By honing their skills in the wild, rangers become expert trackers, as likely to ensnare their foes in a trap as they are to assail them head-on.

      CLASS ITEMS

      A trophy from your first kill or a seemingly broken compass

      RANGER’S HOPE FEATURE

      Hold Them Off: Spend 3 Hope when you succeed on an attack with a weapon to use that same roll against two additional adversaries within range of the attack.

      CLASS FEATURE

      Ranger’s Focus: Spend a Hope and make an attack against a target. On a success, deal your attack’s normal damage and temporarily make the attack’s target your Focus. Until this feature ends or you make a different creature your Focus, you gain the following benefits against your Focus:

      • You know precisely what direction they are in.

      • When you deal damage to them, they must mark a Stress.

      • When you fail an attack against them, you can end your Ranger’s Focus feature to reroll your Duality Dice.

      ", + "description": "

      Rangers are highly skilled hunters who, despite their martial abilities, rarely lend their skills to an army. Through mastery of the body and a deep understanding of the wilderness, rangers become sly tacticians, pursuing their quarry with cunning and patience. Many rangers track and fight alongside an animal companion with whom they’ve forged a powerful spiritual bond. By honing their skills in the wild, rangers become expert trackers, as likely to ensnare their foes in a trap as they are to assail them head-on.

      ", "domains": [ "bone", "sage" diff --git a/src/packs/classes/class_Rogue_CvHlkHZfpMiCz5uT.json b/src/packs/classes/class_Rogue_CvHlkHZfpMiCz5uT.json index 64602794..f4b267ae 100644 --- a/src/packs/classes/class_Rogue_CvHlkHZfpMiCz5uT.json +++ b/src/packs/classes/class_Rogue_CvHlkHZfpMiCz5uT.json @@ -4,7 +4,7 @@ "_id": "CvHlkHZfpMiCz5uT", "img": "icons/magic/defensive/shield-barrier-blades-teal.webp", "system": { - "description": "

      Rogues are scoundrels, often in both attitude and practice. Broadly known as liars and thieves, the best among this class move through the world anonymously. Utilizing their sharp wits and blades, rogues trick their foes through social manipulation as easily as breaking locks, climbing through windows, or dealing underhanded blows. These masters of magical craft manipulate shadow and movement, adding an array of useful and deadly tools to their repertoire. Rogues frequently establish guilds to meet future accomplices, hire out jobs, and hone secret skills, proving that there’s honor among thieves for those who know where to look.

      CLASS ITEMS

      A set of forgery tools or a grappling hook

      ROGUE’S HOPE FEATURE

      Rogue’s Dodge: Spend 3 Hope to gain a +2 bonus to your Evasion until the next time an attack succeeds against you. Otherwise, this bonus lasts until your next rest.

      CLASS FEATURES

      Cloaked: Any time you would be Hidden, you are instead Cloaked. In addition to the benefits of the Hidden condition, while Cloaked you remain unseen if you are stationary when an adversary moves to where they would normally see you.

      After you make an attack or end a move within line of sight of an adversary, you are no longer Cloaked.

      Sneak Attack: When you succeed on an attack while Cloaked or while an ally is within Melee range of your target, add a number of d6s equal to your tier to your damage roll.

      ", + "description": "

      Rogues are scoundrels, often in both attitude and practice. Broadly known as liars and thieves, the best among this class move through the world anonymously. Utilizing their sharp wits and blades, rogues trick their foes through social manipulation as easily as breaking locks, climbing through windows, or dealing underhanded blows. These masters of magical craft manipulate shadow and movement, adding an array of useful and deadly tools to their repertoire. Rogues frequently establish guilds to meet future accomplices, hire out jobs, and hone secret skills, proving that there’s honor among thieves for those who know where to look.

      ", "domains": [ "midnight", "grace" diff --git a/src/packs/classes/class_Seraph_5ZnlJ5bEoyOTkUJv.json b/src/packs/classes/class_Seraph_5ZnlJ5bEoyOTkUJv.json index 479aa70d..524d2d85 100644 --- a/src/packs/classes/class_Seraph_5ZnlJ5bEoyOTkUJv.json +++ b/src/packs/classes/class_Seraph_5ZnlJ5bEoyOTkUJv.json @@ -4,7 +4,7 @@ "_id": "5ZnlJ5bEoyOTkUJv", "img": "icons/magic/holy/barrier-shield-winged-cross.webp", "system": { - "description": "

      Seraphs are divine fighters and healers imbued with sacred purpose. A wide array of deities exist within the realms, and thus numerous kinds of seraphs are appointed by these gods. Their ethos traditionally aligns with the domain or goals of their god, such as defending the weak, exacting vengeance, protecting a land or artifact, or upholding a particular faith. Some seraphs ally themselves with an army or locale, much to the satisfaction of their rulers, but other crusaders fight in opposition to the follies of the Mortal Realm. It is better to be a seraph’s ally than their enemy, as they are terrifying foes to those who defy their purpose.

      CLASS ITEMS

      A bundle of offerings or a sigil of your god

      SERAPH’S HOPE FEATURE

      Life Support: Spend 3 Hope to clear a Hit Point on an ally within Close range.

      CLASS FEATURE

      Prayer Dice: At the beginning of each session, roll a number of d4s equal to your subclass’s Spellcast trait and place them on your character sheet in the space provided. These are your Prayer Dice. You can spend any number of Prayer Dice to aid yourself or an ally within Far range. You can use a spent die’s value to reduce incoming damage, add to a roll’s result after the roll is made, or gain Hope equal to the result. At the end of each session, clear all unspent Prayer Dice.

      ", + "description": "

      Seraphs are divine fighters and healers imbued with sacred purpose. A wide array of deities exist within the realms, and thus numerous kinds of seraphs are appointed by these gods. Their ethos traditionally aligns with the domain or goals of their god, such as defending the weak, exacting vengeance, protecting a land or artifact, or upholding a particular faith. Some seraphs ally themselves with an army or locale, much to the satisfaction of their rulers, but other crusaders fight in opposition to the follies of the Mortal Realm. It is better to be a seraph’s ally than their enemy, as they are terrifying foes to those who defy their purpose.

      ", "domains": [ "valor", "splendor" diff --git a/src/packs/classes/class_Sorcerer_DchOzHcWIJE9FKcR.json b/src/packs/classes/class_Sorcerer_DchOzHcWIJE9FKcR.json index c2780c92..c80bf31e 100644 --- a/src/packs/classes/class_Sorcerer_DchOzHcWIJE9FKcR.json +++ b/src/packs/classes/class_Sorcerer_DchOzHcWIJE9FKcR.json @@ -4,7 +4,7 @@ "_id": "DchOzHcWIJE9FKcR", "img": "icons/magic/symbols/rune-sigil-horned-white-purple.webp", "system": { - "description": "

      Not all innate magic users choose to hone their craft, but those who do can become powerful sorcerers. The gifts of these wielders are passed down through families, even if the family is unaware of or reluctant to practice them. A sorcerer’s abilities can range from the elemental to the illusionary and beyond, and many practitioners band together into collectives based on their talents. The act of becoming a formidable sorcerer is not the practice of acquiring power, but learning to cultivate and control the power one already possesses. The magic of a misguided or undisciplined sorcerer is a dangerous force indeed.

      CLASS ITEMS

      A whispering orb or a family heirloom

      SORCERER’S HOPE FEATURE

      Volatile Magic: Spend 3 Hope to reroll any number of your damage dice on an attack that deals magic damage.

      CLASS FEATURES

      Arcane Sense: You can sense the presence of magical people and objects within Close range.

      Minor Illusion: Make a Spellcast Roll (10). On a success, you create a minor visual illusion no larger than yourself

      within Close range. This illusion is convincing to anyone at Close range or farther.

      Channel Raw Power: Once per long rest, you can place a domain card from your loadout into your vault and choose to either:

      • Gain Hope equal to the level of the card.

      • Enhance a spell that deals damage, gaining a bonus to your damage roll equal to twice the level of the card.

      ", + "description": "

      Not all innate magic users choose to hone their craft, but those who do can become powerful sorcerers. The gifts of these wielders are passed down through families, even if the family is unaware of or reluctant to practice them. A sorcerer’s abilities can range from the elemental to the illusionary and beyond, and many practitioners band together into collectives based on their talents. The act of becoming a formidable sorcerer is not the practice of acquiring power, but learning to cultivate and control the power one already possesses. The magic of a misguided or undisciplined sorcerer is a dangerous force indeed.

      ", "domains": [ "arcana", "midnight" diff --git a/src/packs/classes/class_Warrior_xCUWwJz4WSthvLfy.json b/src/packs/classes/class_Warrior_xCUWwJz4WSthvLfy.json index 67ccade8..ce896ff6 100644 --- a/src/packs/classes/class_Warrior_xCUWwJz4WSthvLfy.json +++ b/src/packs/classes/class_Warrior_xCUWwJz4WSthvLfy.json @@ -4,7 +4,7 @@ "_id": "xCUWwJz4WSthvLfy", "img": "icons/weapons/swords/sword-broad-crystal-paired.webp", "system": { - "description": "

      Becoming a warrior requires years, often a lifetime, of training and dedication to the mastery of weapons and violence. While many who seek to fight hone only their strength, warriors understand the importance of an agile body and mind, making them some of the most sought-after fighters across the realms. Frequently, warriors find employment within an army, a band of mercenaries, or even a royal guard, but their potential is wasted in any position where they cannot continue to improve and expand their skills. Warriors are known to have a favored weapon; to come between them and their blade would be a grievous mistake.

      CLASS ITEMS

      The drawing of a lover or a sharpening stone

      WARRIOR’S HOPE FEATURE

      No Mercy: Spend 3 Hope to gain a +1 bonus to your attack rolls until your next rest.

      CLASS FEATURES

      Attack of Opportunity: If an adversary within Melee range attempts to leave that range, make a reaction roll using a trait of your choice against their Difficulty. Choose one effect on a success, or two if you critically succeed:

      • They can’t move from where they are.

      • You deal damage to them equal to your primary weapon’s damage.

      • You move with them.

      Combat Training: You ignore burden when equipping weapons. When you deal physical damage, you gain a bonus to your damage roll equal to your level.

      ", + "description": "

      Becoming a warrior requires years, often a lifetime, of training and dedication to the mastery of weapons and violence. While many who seek to fight hone only their strength, warriors understand the importance of an agile body and mind, making them some of the most sought-after fighters across the realms. Frequently, warriors find employment within an army, a band of mercenaries, or even a royal guard, but their potential is wasted in any position where they cannot continue to improve and expand their skills. Warriors are known to have a favored weapon; to come between them and their blade would be a grievous mistake.

      ", "domains": [ "blade", "bone" diff --git a/src/packs/classes/class_Wizard_5LwX4m8ziY3F1ZGC.json b/src/packs/classes/class_Wizard_5LwX4m8ziY3F1ZGC.json index 7257ea38..5d9928db 100644 --- a/src/packs/classes/class_Wizard_5LwX4m8ziY3F1ZGC.json +++ b/src/packs/classes/class_Wizard_5LwX4m8ziY3F1ZGC.json @@ -4,7 +4,7 @@ "_id": "5LwX4m8ziY3F1ZGC", "img": "icons/magic/symbols/circled-gem-pink.webp", "system": { - "description": "

      Whether through an institution or individual study, those known as wizards acquire and hone immense magical power over years of learning using a variety of tools, including books, stones, potions, and herbs. Some wizards dedicate their lives to mastering a particular school of magic, while others learn from a wide variety of disciplines. Many wizards become wise and powerful figures in their communities, advising rulers, providing medicines and healing, and even leading war councils. While these mages all work toward the common goal of collecting magical knowledge, wizards often have the most conflict within their own ranks, as the acquisition, keeping, and sharing of powerful secrets is a topic of intense debate that has resulted in innumerable deaths.

      CLASS ITEMS

      A book you’re trying to translate or a tiny, harmless elemental pet

      WIZARD’S HOPE FEATURE

      Not This Time: Spend 3 Hope to force an adversary within Far range to reroll an attack or damage roll.

      CLASS FEATURES

      Prestidigitation: You can perform harmless, subtle magical effects at will. For example, you can change an object’s color, create a smell, light a candle, cause a tiny object to float, illuminate a room, or repair a small object.

      Strange Patterns: Choose a number between 1 and 12. When you roll that number on a Duality Die, gain a Hope or clear a Stress.

      You can change this number when you take a long rest.

      ", + "description": "

      Whether through an institution or individual study, those known as wizards acquire and hone immense magical power over years of learning using a variety of tools, including books, stones, potions, and herbs. Some wizards dedicate their lives to mastering a particular school of magic, while others learn from a wide variety of disciplines. Many wizards become wise and powerful figures in their communities, advising rulers, providing medicines and healing, and even leading war councils. While these mages all work toward the common goal of collecting magical knowledge, wizards often have the most conflict within their own ranks, as the acquisition, keeping, and sharing of powerful secrets is a topic of intense debate that has resulted in innumerable deaths.

      ", "domains": [ "codex", "splendor" diff --git a/src/packs/communities/community_Highborne_DVw2mOCHB8i0XeBz.json b/src/packs/communities/community_Highborne_DVw2mOCHB8i0XeBz.json index dbb5ca78..708fc553 100644 --- a/src/packs/communities/community_Highborne_DVw2mOCHB8i0XeBz.json +++ b/src/packs/communities/community_Highborne_DVw2mOCHB8i0XeBz.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of a highborne community means you’re accustomed to a life of elegance, opulence, and prestige within the upper echelons of society. Traditionally, members of a highborne community possess incredible material wealth. While this can take a variety of forms depending on the community—including gold and other minerals, land, or controlling the means of production—this status always comes with power and influence. Highborne place great value on titles and possessions, and there is little social mobility within their ranks. Members of a highborne community often control the political and economic status of the areas in which they live due to their ability to influence people and the economy with their substantial wealth. The health and safety of the less affluent people who live in these locations often hinges on the ability of this highborne ruling class to prioritize the well-being of their subjects over profit.

      Highborne are often amiable, candid, conniving, enterprising, ostentatious, and unflappable.

      COMMUNITY FEATURE

      Privilege: You have advantage on rolls to consort with nobles, negotiate prices, or leverage your reputation to get what you want.

      ", + "description": "

      Being part of a highborne community means you’re accustomed to a life of elegance, opulence, and prestige within the upper echelons of society. Traditionally, members of a highborne community possess incredible material wealth. While this can take a variety of forms depending on the community—including gold and other minerals, land, or controlling the means of production—this status always comes with power and influence. Highborne place great value on titles and possessions, and there is little social mobility within their ranks. Members of a highborne community often control the political and economic status of the areas in which they live due to their ability to influence people and the economy with their substantial wealth. The health and safety of the less affluent people who live in these locations often hinges on the ability of this highborne ruling class to prioritize the well-being of their subjects over profit.

      Highborne are often amiable, candid, conniving, enterprising, ostentatious, and unflappable.

      ", "features": [ "Compendium.daggerheart.communities.Item.C7NR6qRatawZusmg" ], diff --git a/src/packs/communities/community_Loreborne_YsvlyqYoi8QQ8kwm.json b/src/packs/communities/community_Loreborne_YsvlyqYoi8QQ8kwm.json index ff7cae4e..ee8e2fc5 100644 --- a/src/packs/communities/community_Loreborne_YsvlyqYoi8QQ8kwm.json +++ b/src/packs/communities/community_Loreborne_YsvlyqYoi8QQ8kwm.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of a loreborne community means you’re from a society that favors strong academic or political prowess. Loreborne communities highly value knowledge, frequently in the form of historical preservation, political advancement, scientific study, skill development, or lore and mythology compilation. Most members of these communities research in institutions built in bastions of civilization, while some eclectic few thrive in gathering information from the natural world. Some may be isolationists, operating in smaller enclaves, schools, or guilds and following their own unique ethos. Others still wield their knowledge on a larger scale, making deft political maneuvers across governmental landscapes.

      Loreborne are often direct, eloquent, inquisitive, patient, rhapsodic, and witty.

      COMMUNITY FEATURE

      Well-Read: You have advantage on rolls that involve the history, culture, or politics of a prominent person or place.

      ", + "description": "

      Being part of a loreborne community means you’re from a society that favors strong academic or political prowess. Loreborne communities highly value knowledge, frequently in the form of historical preservation, political advancement, scientific study, skill development, or lore and mythology compilation. Most members of these communities research in institutions built in bastions of civilization, while some eclectic few thrive in gathering information from the natural world. Some may be isolationists, operating in smaller enclaves, schools, or guilds and following their own unique ethos. Others still wield their knowledge on a larger scale, making deft political maneuvers across governmental landscapes.

      Loreborne are often direct, eloquent, inquisitive, patient, rhapsodic, and witty.

      ", "features": [ "Compendium.daggerheart.communities.Item.JBZJmywisJg5X3tH" ], diff --git a/src/packs/communities/community_Orderborne_TY2TejenASXtS484.json b/src/packs/communities/community_Orderborne_TY2TejenASXtS484.json index 869d426a..2af54ca4 100644 --- a/src/packs/communities/community_Orderborne_TY2TejenASXtS484.json +++ b/src/packs/communities/community_Orderborne_TY2TejenASXtS484.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of an orderborne community means you’re from a collective that focuses on discipline or faith, and you uphold a set of principles that reflect your experience there. Orderborne are frequently some of the most powerful among the surrounding communities. By aligning the members of their society around a common value or goal, such as a god, doctrine, ethos, or even a shared business or trade, the ruling bodies of these enclaves are able to mobilize larger populations with less effort. While orderborne communities take a variety of forms—some even profoundly pacifistic—perhaps the most feared are those that structure themselves around military prowess. In such a case, it’s not uncommon for orderborne to provide soldiers for hire to other cities or countries.

      Orderborne are often ambitious, benevolent, pensive, prudent, sardonic, and stoic.

      COMMUNITY FEATURE

      Dedicated: Record three sayings or values your upbringing instilled in you. Once per rest, when you describe how you’re embodying one of these principles through your current action, you can roll a d20 as your Hope Die.

      ", + "description": "

      Being part of an orderborne community means you’re from a collective that focuses on discipline or faith, and you uphold a set of principles that reflect your experience there. Orderborne are frequently some of the most powerful among the surrounding communities. By aligning the members of their society around a common value or goal, such as a god, doctrine, ethos, or even a shared business or trade, the ruling bodies of these enclaves are able to mobilize larger populations with less effort. While orderborne communities take a variety of forms—some even profoundly pacifistic—perhaps the most feared are those that structure themselves around military prowess. In such a case, it’s not uncommon for orderborne to provide soldiers for hire to other cities or countries.

      Orderborne are often ambitious, benevolent, pensive, prudent, sardonic, and stoic.

      ", "features": [ "Compendium.daggerheart.communities.Item.7aXWdH3gzaYREK0X" ], diff --git a/src/packs/communities/community_Ridgeborne_WHLA4qrdszXQHOuo.json b/src/packs/communities/community_Ridgeborne_WHLA4qrdszXQHOuo.json index 60f82899..5ebad846 100644 --- a/src/packs/communities/community_Ridgeborne_WHLA4qrdszXQHOuo.json +++ b/src/packs/communities/community_Ridgeborne_WHLA4qrdszXQHOuo.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of a ridgeborne community means you’ve called the rocky peaks and sharp cliffs of the mountainside home. Those who’ve lived in the mountains often consider themselves hardier than most because they’ve thrived among the most dangerous terrain many continents have to offer. These groups are adept at adaptation, developing unique technologies and equipment to move both people and products across difficult terrain. As such, ridgeborne grow up scrambling and climbing, making them sturdy and strong-willed. Ridgeborne localities appear in a variety of forms—some cities carve out entire cliff faces, others construct castles of stone, and still more live in small homes on windblown peaks. Outside forces often struggle to attack ridgeborne groups, as the small militias and large military forces of the mountains are adept at utilizing their high-ground advantage.

      Ridgeborne are often bold, hardy, indomitable, loyal, reserved, and stubborn.

      COMMUNITY FEATURE

      Steady: You have advantage on rolls to traverse dangerous cliffs and ledges, navigate harsh environments, and use your survival knowledge.

      ", + "description": "

      Being part of a ridgeborne community means you’ve called the rocky peaks and sharp cliffs of the mountainside home. Those who’ve lived in the mountains often consider themselves hardier than most because they’ve thrived among the most dangerous terrain many continents have to offer. These groups are adept at adaptation, developing unique technologies and equipment to move both people and products across difficult terrain. As such, ridgeborne grow up scrambling and climbing, making them sturdy and strong-willed. Ridgeborne localities appear in a variety of forms—some cities carve out entire cliff faces, others construct castles of stone, and still more live in small homes on windblown peaks. Outside forces often struggle to attack ridgeborne groups, as the small militias and large military forces of the mountains are adept at utilizing their high-ground advantage.

      Ridgeborne are often bold, hardy, indomitable, loyal, reserved, and stubborn.

      ", "features": [ "Compendium.daggerheart.communities.Item.DYmmr5CknLtHnwuj" ], diff --git a/src/packs/communities/community_Seaborne_o5AA5J05N7EvH1rN.json b/src/packs/communities/community_Seaborne_o5AA5J05N7EvH1rN.json index bb430a20..1e2f0970 100644 --- a/src/packs/communities/community_Seaborne_o5AA5J05N7EvH1rN.json +++ b/src/packs/communities/community_Seaborne_o5AA5J05N7EvH1rN.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of a seaborne community means you lived on or near a large body of water. Seaborne communities are built, both physically and culturally, around the specific waters they call home. Some of these groups live along the shore, constructing ports for locals and travelers alike. These harbors function as centers of commerce, tourist attractions, or even just a safe place to lay down one’s head after weeks of travel. Other seaborne live on the water in small boats or large ships, with the idea of “home” comprising a ship and its crew, rather than any one landmass. No matter their exact location, seaborne communities are closely tied to the ocean tides and the creatures who inhabit them. Seaborne learn to fish at a young age, and train from birth to hold their breath and swim in even the most tumultuous waters. Individuals from these groups are highly sought after for their sailing skills, and many become captains of vessels, whether within their own community, working for another, or even at the helm of a powerful naval operation.

      Seaborne are often candid, cooperative, exuberant, fierce, resolute, and weathered.

      COMMUNITY FEATURE

      Know the Tide: You can sense the ebb and flow of life. When you roll with Fear, place a token on your community card. You can hold a number of tokens equal to your level. Before you make an action roll, you can spend any number of these tokens to gain a +1 bonus to the roll for each token spent. At the end of each session, clear all unspent tokens.

      ", + "description": "

      Being part of a seaborne community means you lived on or near a large body of water. Seaborne communities are built, both physically and culturally, around the specific waters they call home. Some of these groups live along the shore, constructing ports for locals and travelers alike. These harbors function as centers of commerce, tourist attractions, or even just a safe place to lay down one’s head after weeks of travel. Other seaborne live on the water in small boats or large ships, with the idea of “home” comprising a ship and its crew, rather than any one landmass. No matter their exact location, seaborne communities are closely tied to the ocean tides and the creatures who inhabit them. Seaborne learn to fish at a young age, and train from birth to hold their breath and swim in even the most tumultuous waters. Individuals from these groups are highly sought after for their sailing skills, and many become captains of vessels, whether within their own community, working for another, or even at the helm of a powerful naval operation.

      Seaborne are often candid, cooperative, exuberant, fierce, resolute, and weathered.

      ", "features": [ "Compendium.daggerheart.communities.Item.07x6Qe6qMzDw2xN4" ], diff --git a/src/packs/communities/community_Slyborne_rGwCPMqZtky7SE6d.json b/src/packs/communities/community_Slyborne_rGwCPMqZtky7SE6d.json index fe69a8fe..724d8c36 100644 --- a/src/packs/communities/community_Slyborne_rGwCPMqZtky7SE6d.json +++ b/src/packs/communities/community_Slyborne_rGwCPMqZtky7SE6d.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of a slyborne community means you come from a group that operates outside the law, including all manner of criminals, grifters, and con artists. Being part of a slyborne community means you come from a group that operates outside the law, including all manner of criminals, grifters, and con artists. Members of slyborne communities are brought together by their disreputable goals and their clever means of achieving them. Many people in these communities have an array of unscrupulous skills: forging, thievery, smuggling, and violence. People of any social class can be slyborne, from those who have garnered vast wealth and influence to those without a coin to their name. To the outside eye, slyborne might appear to be ruffians with no loyalty, but these communities possess some of the strictest codes of honor which, when broken, can result in a terrifying end for the transgressor.

      Slyborne are often calculating, clever, formidable, perceptive, shrewd, and tenacious.

      COMMUNITY FEATURE

      Scoundrel: You have advantage on rolls to negotiate with criminals, detect lies, or find a safe place to hide.

      ", + "description": "

      Being part of a slyborne community means you come from a group that operates outside the law, including all manner of criminals, grifters, and con artists. Being part of a slyborne community means you come from a group that operates outside the law, including all manner of criminals, grifters, and con artists. Members of slyborne communities are brought together by their disreputable goals and their clever means of achieving them. Many people in these communities have an array of unscrupulous skills: forging, thievery, smuggling, and violence. People of any social class can be slyborne, from those who have garnered vast wealth and influence to those without a coin to their name. To the outside eye, slyborne might appear to be ruffians with no loyalty, but these communities possess some of the strictest codes of honor which, when broken, can result in a terrifying end for the transgressor.

      Slyborne are often calculating, clever, formidable, perceptive, shrewd, and tenacious.

      ", "features": [ "Compendium.daggerheart.communities.Item.ZmEuBdL0JrvuA8le" ], diff --git a/src/packs/communities/community_Underborne_eX0I1ZNMyD3nfaL1.json b/src/packs/communities/community_Underborne_eX0I1ZNMyD3nfaL1.json index 360adc3b..c0b7ba06 100644 --- a/src/packs/communities/community_Underborne_eX0I1ZNMyD3nfaL1.json +++ b/src/packs/communities/community_Underborne_eX0I1ZNMyD3nfaL1.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of an underborne community means you’re from a subterranean society. Many underborne live right beneath the cities and villages of other collectives, while some live much deeper. These communities range from small family groups in burrows to massive metropolises in caverns of stone. In many locales, underborne are recognized for their incredible boldness and skill that enable great feats of architecture and engineering. Underborne are regularly hired for their bravery, as even the least daring among them has likely encountered formidable belowground beasts, and learning to dispatch such creatures is common practice amongst these societies. Because of the dangers of their environment, many underborne communities develop unique nonverbal languages that prove equally useful on the surface.

      Underborne are often composed, elusive, indomitable, innovative, resourceful, and unpretentious.

      COMMUNITY FEATURE

      Low-Light Living: When you’re in an area with low light or heavy shadow, you have advantage on rolls to hide, investigate, or perceive details within that area.

      ", + "description": "

      Being part of an underborne community means you’re from a subterranean society. Many underborne live right beneath the cities and villages of other collectives, while some live much deeper. These communities range from small family groups in burrows to massive metropolises in caverns of stone. In many locales, underborne are recognized for their incredible boldness and skill that enable great feats of architecture and engineering. Underborne are regularly hired for their bravery, as even the least daring among them has likely encountered formidable belowground beasts, and learning to dispatch such creatures is common practice amongst these societies. Because of the dangers of their environment, many underborne communities develop unique nonverbal languages that prove equally useful on the surface.

      Underborne are often composed, elusive, indomitable, innovative, resourceful, and unpretentious.

      ", "features": [ "Compendium.daggerheart.communities.Item.aMla3xQuCHEwORGD" ], diff --git a/src/packs/communities/community_Wanderborne_82mDY2EIBfLkNwQj.json b/src/packs/communities/community_Wanderborne_82mDY2EIBfLkNwQj.json index 0adccad8..8f595dc9 100644 --- a/src/packs/communities/community_Wanderborne_82mDY2EIBfLkNwQj.json +++ b/src/packs/communities/community_Wanderborne_82mDY2EIBfLkNwQj.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of a wanderborne community means you’ve lived as a nomad, forgoing a permanent home and experiencing a wide variety of cultures. Unlike many communities that are defined by their locale, wanderborne are defined by their traveling lifestyle. Because of their frequent migration, wanderborne put less value on the accumulation of material possessions in favor of acquiring information, skills, and connections. While some wanderborne are allied by a common ethos, such as a religion or a set of political or economic values, others come together after shared tragedy, such as the loss of their home or land. No matter the reason, the dangers posed by life on the road and the choice to continue down that road together mean that wanderborne are known for their unwavering loyalty.

      Wanderborne are often inscrutable, magnanimous, mirthful, reliable, savvy, and unorthodox.

      COMMUNITY FEATURE

      Nomadic Pack: Add a Nomadic Pack to your inventory. Once per session, you can spend a Hope to reach into this pack and pull out a mundane item that’s useful to your situation. Work with the GM to figure out what item you take out.

      ", + "description": "

      Being part of a wanderborne community means you’ve lived as a nomad, forgoing a permanent home and experiencing a wide variety of cultures. Unlike many communities that are defined by their locale, wanderborne are defined by their traveling lifestyle. Because of their frequent migration, wanderborne put less value on the accumulation of material possessions in favor of acquiring information, skills, and connections. While some wanderborne are allied by a common ethos, such as a religion or a set of political or economic values, others come together after shared tragedy, such as the loss of their home or land. No matter the reason, the dangers posed by life on the road and the choice to continue down that road together mean that wanderborne are known for their unwavering loyalty.

      Wanderborne are often inscrutable, magnanimous, mirthful, reliable, savvy, and unorthodox.

      ", "features": [ "Compendium.daggerheart.communities.Item.2RSrQouA2zEJ5Xee" ], diff --git a/src/packs/communities/community_Wildborne_CRJ5pzJj4FjCtIlx.json b/src/packs/communities/community_Wildborne_CRJ5pzJj4FjCtIlx.json index 68787784..7974c147 100644 --- a/src/packs/communities/community_Wildborne_CRJ5pzJj4FjCtIlx.json +++ b/src/packs/communities/community_Wildborne_CRJ5pzJj4FjCtIlx.json @@ -4,7 +4,7 @@ "type": "community", "folder": null, "system": { - "description": "

      Being part of a wildborne community means you lived deep within the forest. Wildborne communities are defined by their dedication to the conservation of their homelands, and many have strong religious or cultural ties to the fauna they live among. This results in unique architectural and technological advancements that favor sustainability over short-term, high-yield results. It is a hallmark of wildborne societies to integrate their villages and cities with the natural environment and avoid disturbing the lives of the plants and animals. While some construct their lodgings high in the branches of trees, others establish their homes on the ground beneath the forest canopy. It’s not uncommon for wildborne to remain reclusive and hidden within their woodland homes.

      Wildborne are often hardy, loyal, nurturing, reclusive, sagacious, and vibrant.

      COMMUNITY FEATURE

      Lightfoot: Your movement is naturally silent. You have advantage on rolls to move without being heard.

      ", + "description": "

      Being part of a wildborne community means you lived deep within the forest. Wildborne communities are defined by their dedication to the conservation of their homelands, and many have strong religious or cultural ties to the fauna they live among. This results in unique architectural and technological advancements that favor sustainability over short-term, high-yield results. It is a hallmark of wildborne societies to integrate their villages and cities with the natural environment and avoid disturbing the lives of the plants and animals. While some construct their lodgings high in the branches of trees, others establish their homes on the ground beneath the forest canopy. It’s not uncommon for wildborne to remain reclusive and hidden within their woodland homes.

      Wildborne are often hardy, loyal, nurturing, reclusive, sagacious, and vibrant.

      ", "features": [ "Compendium.daggerheart.communities.Item.TQ1AIQjndC4mYmmU" ], diff --git a/styles/less/global/elements.less b/styles/less/global/elements.less index 98c05348..f5e92e2c 100755 --- a/styles/less/global/elements.less +++ b/styles/less/global/elements.less @@ -462,6 +462,12 @@ } } + .three-columns { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; + } + line-div { display: block; height: 1px; diff --git a/styles/less/global/global.less b/styles/less/global/global.less index 6c63fe7a..fb995b8c 100644 --- a/styles/less/global/global.less +++ b/styles/less/global/global.less @@ -50,6 +50,29 @@ transform: rotate(360deg); } } + + .item-description-outer-container { + display: flex; + flex-direction: column; + gap: 8px; + + .item-description-container { + h4 { + margin-bottom: 4px; + } + + .item-description-inner-container { + p { + display: inline; + } + } + + .item-links { + display: flex; + gap: 4px; + } + } + } } /* TODO: Remove me when this issue is resolved https://github.com/foundryvtt/foundryvtt/issues/13734 */ diff --git a/styles/less/global/inventory-item.less b/styles/less/global/inventory-item.less index c8a29795..9045baf5 100644 --- a/styles/less/global/inventory-item.less +++ b/styles/less/global/inventory-item.less @@ -174,9 +174,12 @@ &.extensible { display: grid; grid-template-rows: 0fr; - transition: grid-template-rows 0.3s ease-in-out; + transition: + grid-template-rows 0.3s ease-in-out, + padding-top 0.3s ease-in-out; &.extended { grid-template-rows: 1fr; + padding-top: 4px; } .invetory-description { overflow: hidden; diff --git a/styles/less/ui/item-browser/item-browser.less b/styles/less/ui/item-browser/item-browser.less index 23844128..b395f8c8 100644 --- a/styles/less/ui/item-browser/item-browser.less +++ b/styles/less/ui/item-browser/item-browser.less @@ -301,7 +301,7 @@ } .item-desc .wrapper { - padding: 0 10px; + padding: 0 0 0 50px; display: flex; flex-direction: column; gap: 5px; diff --git a/templates/sheets/items/armor/description.hbs b/templates/sheets/items/armor/description.hbs index c5a9924e..c234fa10 100644 --- a/templates/sheets/items/armor/description.hbs +++ b/templates/sheets/items/armor/description.hbs @@ -1,5 +1,22 @@ -
      - {{#each features as | feature |}} -
      {{localize feature.label}}: {{{localize feature.description}}}
      - {{/each}} +
      +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Armor.baseThresholds.base"}}

      + {{item.system.baseThresholds.major}}/{{item.system.baseThresholds.severe}} +
      + +
      +

      {{localize "DAGGERHEART.ITEMS.Armor.baseScore"}}

      + {{item.system.baseScore}} +
      +
      + + {{#if features.length}} +
      +

      {{localize "DAGGERHEART.GENERAL.features"}}

      + {{#each features as | feature |}} +
      {{localize feature.label}}: {{{localize feature.description}}}
      + {{/each}} +
      + {{/if}}
      \ No newline at end of file diff --git a/templates/sheets/items/class/description.hbs b/templates/sheets/items/class/description.hbs new file mode 100644 index 00000000..6fe2f6e2 --- /dev/null +++ b/templates/sheets/items/class/description.hbs @@ -0,0 +1,34 @@ +
      +
      +

      {{localize "DAGGERHEART.GENERAL.Domain.plural"}}

      + {{domains}} +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Class.startingEvasionScore"}}

      + {{class.system.evasion}} +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Class.startingHitPoints"}}

      + {{class.system.hitPoints}} +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Class.classItems"}}

      + +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Class.hopeFeatureLabel" class=class.name}}

      + {{#each hopeFeatures as | feature |}} +
      {{localize feature.label}}: {{{localize feature.description}}}
      + {{/each}} +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Class.classFeature"}}

      + {{#each classFeatures as | feature |}} +
      {{localize feature.label}}: {{{localize feature.description}}}
      + {{/each}} +
      +
      \ No newline at end of file diff --git a/templates/sheets/items/description.hbs b/templates/sheets/items/description.hbs new file mode 100644 index 00000000..d253fe11 --- /dev/null +++ b/templates/sheets/items/description.hbs @@ -0,0 +1,8 @@ +
      +
      +

      {{localize label}}

      + {{#each features as | feature |}} +
      {{localize feature.label}}: {{{localize feature.description}}}
      + {{/each}} +
      +
      \ No newline at end of file diff --git a/templates/sheets/items/subclass/description.hbs b/templates/sheets/items/subclass/description.hbs new file mode 100644 index 00000000..4591bd1a --- /dev/null +++ b/templates/sheets/items/subclass/description.hbs @@ -0,0 +1,24 @@ +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Subclass.spellcastTrait"}}

      + {{spellcastTrait}} +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Subclass.foundationFeatures"}}

      + {{#each foundationFeatures as | feature |}} +
      {{localize feature.label}}: {{{localize feature.description}}}
      + {{/each}} +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Subclass.specializationFeature"}}

      + {{#each specializationFeatures as | feature |}} +
      {{localize feature.label}}: {{{localize feature.description}}}
      + {{/each}} +
      +
      +

      {{localize "DAGGERHEART.ITEMS.Subclass.masteryFeature"}}

      + {{#each masteryFeatures 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 index c5a9924e..d8e128e7 100644 --- a/templates/sheets/items/weapon/description.hbs +++ b/templates/sheets/items/weapon/description.hbs @@ -1,5 +1,39 @@ -
      - {{#each features as | feature |}} -
      {{localize feature.label}}: {{{localize feature.description}}}
      - {{/each}} +
      +
      +
      +

      {{localize "DAGGERHEART.GENERAL.Tiers.singular"}}

      + {{tier}} +
      + +
      +

      {{localize "DAGGERHEART.GENERAL.Trait.single"}}

      + {{trait}} +
      + +
      +

      {{localize "DAGGERHEART.GENERAL.range"}}

      + {{range}} +
      +
      + +
      +
      +

      {{localize "DAGGERHEART.GENERAL.damage"}}

      + {{damage}} +
      + +
      +

      {{localize "DAGGERHEART.GENERAL.burden"}}

      + {{burden}} +
      +
      + + {{#if features.length}} +
      +

      {{localize "DAGGERHEART.GENERAL.features"}}

      + {{#each features as | feature |}} +
      {{localize feature.label}}: {{{localize feature.description}}}
      + {{/each}} +
      + {{/if}}
      \ No newline at end of file From 340abbc98c5aed125cc25f6ae7604ea689532110 Mon Sep 17 00:00:00 2001 From: Cipher Date: Mon, 23 Feb 2026 16:34:06 +0100 Subject: [PATCH 77/95] Fix incorrect adversary data (ranges, stats, types, names) (#1680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adult Flickerfly: damage dice d10 → d20 - Giant Recruit: stress max 1 → 2 - Hallowed Soldier: stress max 1 → 2 - Jagged Knife Sniper: type standard → ranged - Minor Demon: add missing melee range - Oak Treant: type standard → bruiser, attack name → Branch, range → veryClose - Outer Realms Thrall: range melee → veryClose - Treant Sapling: add missing melee range - Young Ice Dragon: range melee → close - Zombie Legion: attack name Tentacles → Undead Hands Co-authored-by: Sebastian Will --- .../adversary_Adult_Flickerfly_G7jiltRjgvVhZewm.json | 2 +- .../adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json | 2 +- .../adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json | 2 +- .../adversary_Jagged_Knife_Sniper_1zuyof1XuIfi3aMG.json | 2 +- .../adversaries/adversary_Minor_Demon_3tqCjDwJAQ7JKqMb.json | 1 + .../adversaries/adversary_Oak_Treant_XK78QUfY8c8Go8Uv.json | 6 +++--- .../adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json | 2 +- .../adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json | 4 ++-- .../adversary_Young_Ice_Dragon_UGPiPLJsPvMTSKEF.json | 2 +- .../adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json | 2 +- 10 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/packs/adversaries/adversary_Adult_Flickerfly_G7jiltRjgvVhZewm.json b/src/packs/adversaries/adversary_Adult_Flickerfly_G7jiltRjgvVhZewm.json index 266cba24..16fb61d8 100644 --- a/src/packs/adversaries/adversary_Adult_Flickerfly_G7jiltRjgvVhZewm.json +++ b/src/packs/adversaries/adversary_Adult_Flickerfly_G7jiltRjgvVhZewm.json @@ -82,7 +82,7 @@ "enabled": false }, "flatMultiplier": 3, - "dice": "d10", + "dice": "d20", "bonus": null, "multiplier": "flat" }, diff --git a/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json b/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json index ebdea711..adcdf015 100644 --- a/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json +++ b/src/packs/adversaries/adversary_Giant_Recruit_5s8wSvpyC5rxY5aD.json @@ -55,7 +55,7 @@ "max": 1 }, "stress": { - "max": 1 + "max": 2 } }, "attack": { diff --git a/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json b/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json index 96107752..95a2ecd0 100644 --- a/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json +++ b/src/packs/adversaries/adversary_Hallowed_Soldier_VENwg7xEFcYObjmT.json @@ -55,7 +55,7 @@ "max": 1 }, "stress": { - "max": 1 + "max": 2 } }, "attack": { diff --git a/src/packs/adversaries/adversary_Jagged_Knife_Sniper_1zuyof1XuIfi3aMG.json b/src/packs/adversaries/adversary_Jagged_Knife_Sniper_1zuyof1XuIfi3aMG.json index 166c521b..6fd02cb5 100644 --- a/src/packs/adversaries/adversary_Jagged_Knife_Sniper_1zuyof1XuIfi3aMG.json +++ b/src/packs/adversaries/adversary_Jagged_Knife_Sniper_1zuyof1XuIfi3aMG.json @@ -33,7 +33,7 @@ "reduction": 0 } }, - "type": "standard", + "type": "ranged", "notes": "", "hordeHp": 1, "experiences": { diff --git a/src/packs/adversaries/adversary_Minor_Demon_3tqCjDwJAQ7JKqMb.json b/src/packs/adversaries/adversary_Minor_Demon_3tqCjDwJAQ7JKqMb.json index 3a330fdf..0fceeba1 100644 --- a/src/packs/adversaries/adversary_Minor_Demon_3tqCjDwJAQ7JKqMb.json +++ b/src/packs/adversaries/adversary_Minor_Demon_3tqCjDwJAQ7JKqMb.json @@ -104,6 +104,7 @@ ] }, "type": "attack", + "range": "melee", "chatDisplay": false }, "attribution": { diff --git a/src/packs/adversaries/adversary_Oak_Treant_XK78QUfY8c8Go8Uv.json b/src/packs/adversaries/adversary_Oak_Treant_XK78QUfY8c8Go8Uv.json index c0999e70..3c110024 100644 --- a/src/packs/adversaries/adversary_Oak_Treant_XK78QUfY8c8Go8Uv.json +++ b/src/packs/adversaries/adversary_Oak_Treant_XK78QUfY8c8Go8Uv.json @@ -33,7 +33,7 @@ "reduction": 0 } }, - "type": "standard", + "type": "bruiser", "notes": "", "hordeHp": 1, "experiences": {}, @@ -66,12 +66,12 @@ "tier": 3, "description": "

      A sturdy animate old-growth tree.

      ", "attack": { - "name": "Attack", + "name": "Branch", "roll": { "type": "attack", "bonus": 2 }, - "range": "close", + "range": "veryClose", "damage": { "parts": [ { diff --git a/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json b/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json index 276dd3ed..5347bf49 100644 --- a/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json +++ b/src/packs/adversaries/adversary_Outer_Realms_Thrall_moJhHgKqTKPS2WYS.json @@ -97,7 +97,7 @@ }, "img": "icons/creatures/claws/claw-talons-yellow-red.webp", "type": "attack", - "range": "melee", + "range": "veryClose", "chatDisplay": false }, "attribution": { diff --git a/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json b/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json index c9ca695e..ad9d8107 100644 --- a/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json +++ b/src/packs/adversaries/adversary_Treant_Sapling_o63nS0k3wHu6EgKP.json @@ -97,8 +97,8 @@ ] }, "type": "attack", - "chatDisplay": false, - "range": "" + "range": "melee", + "chatDisplay": false }, "attribution": { "source": "Daggerheart SRD", diff --git a/src/packs/adversaries/adversary_Young_Ice_Dragon_UGPiPLJsPvMTSKEF.json b/src/packs/adversaries/adversary_Young_Ice_Dragon_UGPiPLJsPvMTSKEF.json index b0a3bded..c55262e4 100644 --- a/src/packs/adversaries/adversary_Young_Ice_Dragon_UGPiPLJsPvMTSKEF.json +++ b/src/packs/adversaries/adversary_Young_Ice_Dragon_UGPiPLJsPvMTSKEF.json @@ -110,7 +110,7 @@ }, "img": "icons/creatures/claws/claw-scaled-red.webp", "type": "attack", - "range": "melee", + "range": "close", "chatDisplay": false }, "attribution": { diff --git a/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json b/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json index a6a488e9..91bdab81 100644 --- a/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json +++ b/src/packs/adversaries/adversary_Zombie_Legion_YhJrP7rTBiRdX5Fp.json @@ -97,7 +97,7 @@ } ] }, - "name": "Tentacles", + "name": "Undead Hands", "roll": { "bonus": 2, "type": "attack" From 1b09b44d6c46568ed625454e202327dc68b1a018 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:32:05 +0100 Subject: [PATCH 78/95] [Fix] 1676 - Horde Damage Fix (#1678) * Fixed so that horde damage reduction is only applied to the standard attack * Changed to just adding 'isStandardAttack' in adversary data prep * . --- module/data/actor/adversary.mjs | 4 ++++ module/data/fields/action/damageField.mjs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index 78964720..0a446c15 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -190,6 +190,10 @@ export default class DhpAdversary extends DhCreature { } } + prepareDerivedData() { + this.attack.roll.isStandardAttack = true; + } + _getTags() { const tags = [ game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${this.tier}`), diff --git a/module/data/fields/action/damageField.mjs b/module/data/fields/action/damageField.mjs index efad726c..6439344b 100644 --- a/module/data/fields/action/damageField.mjs +++ b/module/data/fields/action/damageField.mjs @@ -165,7 +165,8 @@ export default class DamageField extends fields.SchemaField { if (data.hasRoll && part.resultBased && data.roll.result.duality === -1) return part.valueAlt; const isAdversary = this.actor.type === 'adversary'; - if (isAdversary && this.actor.system.type === CONFIG.DH.ACTOR.adversaryTypes.horde.id) { + const isHorde = this.actor.system.type === CONFIG.DH.ACTOR.adversaryTypes.horde.id; + if (isAdversary && isHorde && this.roll?.isStandardAttack) { const hasHordeDamage = this.actor.effects.find(x => x.type === 'horde'); if (hasHordeDamage && !hasHordeDamage.disabled) return part.valueAlt; } From 4324c3abf23f909e7cb9d545868ebbcdd39b41c7 Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Thu, 26 Feb 2026 05:37:40 -0500 Subject: [PATCH 79/95] [Fix] Support elevation in token distance hovering and fix error when overlapping (#1675) * Support elevation in token distance hovering * Reduce diffs * Refine elevation check to handle stacked tokens * Fix issue with overlapping tokens * Fix tooltip reporting very close for adjacent diagonal tokens --- daggerheart.mjs | 5 +-- module/canvas/placeables/token.mjs | 68 ++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/daggerheart.mjs b/daggerheart.mjs index 1987ec12..05b57ac9 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -420,10 +420,7 @@ const updateActorsRangeDependentEffects = async token => { // Get required distance and special case 5 feet to test adjacency const required = rangeMeasurement[range]; const reverse = type === CONFIG.DH.GENERAL.rangeInclusion.outsideRange.id; - const inRange = - required === 5 - ? userTarget.isAdjacentWith(token.object) - : userTarget.distanceTo(token.object) <= required; + const inRange = userTarget.distanceTo(token.object) <= required; if (reverse ? inRange : !inRange) { enabledEffect = false; break; diff --git a/module/canvas/placeables/token.mjs b/module/canvas/placeables/token.mjs index 068f21e1..148466c1 100644 --- a/module/canvas/placeables/token.mjs +++ b/module/canvas/placeables/token.mjs @@ -54,30 +54,58 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token { if (this === target) return 0; const originPoint = this.center; - const destinationPoint = target.center; + const targetPoint = target.center; + const thisBounds = this.bounds; + const targetBounds = target.bounds; + const adjacencyBuffer = canvas.grid.distance * 1.75; // handles diagonals with one square elevation difference + + // Figure out the elevation difference. + // This intends to return "grid distance" for adjacent ones, so we add that number if not overlapping. + const sizePerUnit = canvas.grid.size / canvas.grid.distance; + const thisHeight = Math.max(thisBounds.width, thisBounds.height) / sizePerUnit; + const targetHeight = Math.max(targetBounds.width, targetBounds.height) / sizePerUnit; + const thisElevation = [this.document.elevation, this.document.elevation + thisHeight]; + const targetElevation = [target.document.elevation, target.document.elevation + targetHeight]; + const isSameAltitude = + thisElevation[0] < targetElevation[1] && // bottom of this must be at or below the top of target + thisElevation[1] > targetElevation[0]; // top of this must be at or above the bottom of target + const [lower, higher] = [targetElevation, thisElevation].sort((a, b) => a[1] - b[1]); + const elevation = isSameAltitude ? 0 : higher[0] - lower[1] + canvas.grid.distance; // Compute for gridless. This version returns circular edge to edge + grid distance, // so that tokens that are touching return 5. if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { const boundsCorrection = canvas.grid.distance / canvas.grid.size; - const originRadius = (this.bounds.width * boundsCorrection) / 2; - const targetRadius = (target.bounds.width * boundsCorrection) / 2; - const distance = canvas.grid.measurePath([originPoint, destinationPoint]).distance; - return Math.floor(distance - originRadius - targetRadius + canvas.grid.distance); + const originRadius = (thisBounds.width * boundsCorrection) / 2; + const targetRadius = (targetBounds.width * boundsCorrection) / 2; + const measuredDistance = canvas.grid.measurePath([ + { ...originPoint, elevation: 0 }, + { ...targetPoint, elevation } + ]).distance; + const distance = Math.floor(measuredDistance - originRadius - targetRadius + canvas.grid.distance); + return Math.min(distance, distance > adjacencyBuffer ? Infinity : canvas.grid.distance); } // Compute what the closest grid space of each token is, then compute that distance - const originEdge = this.#getEdgeBoundary(this.bounds, originPoint, destinationPoint); - const targetEdge = this.#getEdgeBoundary(target.bounds, originPoint, destinationPoint); - const adjustedOriginPoint = canvas.grid.getTopLeftPoint({ - x: originEdge.x + Math.sign(originPoint.x - originEdge.x), - y: originEdge.y + Math.sign(originPoint.y - originEdge.y) - }); - const adjustDestinationPoint = canvas.grid.getTopLeftPoint({ - x: targetEdge.x + Math.sign(destinationPoint.x - targetEdge.x), - y: targetEdge.y + Math.sign(destinationPoint.y - targetEdge.y) - }); - return canvas.grid.measurePath([adjustedOriginPoint, adjustDestinationPoint]).distance; + const originEdge = this.#getEdgeBoundary(thisBounds, originPoint, targetPoint); + const targetEdge = this.#getEdgeBoundary(targetBounds, originPoint, targetPoint); + const adjustedOriginPoint = originEdge + ? canvas.grid.getTopLeftPoint({ + x: originEdge.x + Math.sign(originPoint.x - originEdge.x), + y: originEdge.y + Math.sign(originPoint.y - originEdge.y) + }) + : originPoint; + const adjustDestinationPoint = targetEdge + ? canvas.grid.getTopLeftPoint({ + x: targetEdge.x + Math.sign(targetPoint.x - targetEdge.x), + y: targetEdge.y + Math.sign(targetPoint.y - targetEdge.y) + }) + : targetPoint; + const distance = canvas.grid.measurePath([ + { ...adjustedOriginPoint, elevation: 0 }, + { ...adjustDestinationPoint, elevation } + ]).distance; + return Math.min(distance, distance > adjacencyBuffer ? Infinity : canvas.grid.distance); } _onHoverIn(event, options) { @@ -103,8 +131,7 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token { // Determine the actual range const ranges = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).rangeMeasurement; - const distanceNum = originToken.distanceTo(this); - const distanceResult = DhMeasuredTemplate.getRangeLabels(distanceNum, ranges); + const distanceResult = DhMeasuredTemplate.getRangeLabels(originToken.distanceTo(this), ranges); const distanceLabel = `${distanceResult.distance} ${distanceResult.units}`.trim(); // Create the element @@ -156,11 +183,6 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token { return null; } - /** Tests if the token is at least adjacent with another, with some leeway for diagonals */ - isAdjacentWith(token) { - return this.distanceTo(token) <= canvas.grid.distance * 1.5; - } - /** @inheritDoc */ _drawBar(number, bar, data) { const val = Number(data.value); From e79ccd34e98bda971f81e88252ad16434a26a8ea Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:42:42 +0100 Subject: [PATCH 80/95] [Fix] 1671 - Compendium Context Menues (#1677) * Fixed * . --- .../sheets/api/application-mixin.mjs | 18 ++++++------- .../partials/inventory-fieldset-items-V2.hbs | 1 + .../global/partials/inventory-item-V2.hbs | 8 ++++-- templates/sheets/global/tabs/tab-effects.hbs | 25 ++++++++++--------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/module/applications/sheets/api/application-mixin.mjs b/module/applications/sheets/api/application-mixin.mjs index 49f7dcf0..449880fb 100644 --- a/module/applications/sheets/api/application-mixin.mjs +++ b/module/applications/sheets/api/application-mixin.mjs @@ -431,18 +431,18 @@ export default function DHApplicationMixin(Base) { { name: 'disableEffect', icon: 'fa-solid fa-lightbulb', - condition: target => { - const doc = getDocFromElementSync(target); - return doc && !doc.disabled && doc.type !== 'beastform'; + condition: element => { + const target = element.closest('[data-item-uuid]'); + return !target.dataset.disabled && target.dataset.itemType !== 'beastform'; }, callback: async target => (await getDocFromElement(target)).update({ disabled: true }) }, { name: 'enableEffect', icon: 'fa-regular fa-lightbulb', - condition: target => { - const doc = getDocFromElementSync(target); - return doc && doc.disabled && doc.type !== 'beastform'; + condition: element => { + const target = element.closest('[data-item-uuid]'); + return target.dataset.disabled && target.dataset.itemType !== 'beastform'; }, callback: async target => (await getDocFromElement(target)).update({ disabled: false }) } @@ -536,9 +536,9 @@ export default function DHApplicationMixin(Base) { options.push({ name: 'CONTROLS.CommonDelete', icon: 'fa-solid fa-trash', - condition: target => { - const doc = getDocFromElementSync(target); - return doc && doc.type !== 'beastform'; + condition: element => { + const target = element.closest('[data-item-uuid]'); + return target.dataset.itemType !== 'beastform'; }, callback: async (target, event) => { const doc = await getDocFromElement(target); diff --git a/templates/sheets/global/partials/inventory-fieldset-items-V2.hbs b/templates/sheets/global/partials/inventory-fieldset-items-V2.hbs index d2534a5a..0a3275d0 100644 --- a/templates/sheets/global/partials/inventory-fieldset-items-V2.hbs +++ b/templates/sheets/global/partials/inventory-fieldset-items-V2.hbs @@ -56,6 +56,7 @@ Parameters: {{> 'daggerheart.inventory-item' item=item type=../type + disabledEffect=../disabledEffect actorType=../actorType hideControls=../hideControls hideContextMenu=../hideContextMenu diff --git a/templates/sheets/global/partials/inventory-item-V2.hbs b/templates/sheets/global/partials/inventory-item-V2.hbs index 76e13a5c..fec215a0 100644 --- a/templates/sheets/global/partials/inventory-item-V2.hbs +++ b/templates/sheets/global/partials/inventory-item-V2.hbs @@ -17,8 +17,12 @@ Parameters: - showActions {boolean} : If true show feature's actions. --}} -
    • +
    • {{!-- Image --}}
      {{> 'daggerheart.inventory-items' - title='DAGGERHEART.GENERAL.activeEffects' - type='effect' - isGlassy=true - collection=effects.actives - canCreate=true - hideResources=true + title='DAGGERHEART.GENERAL.activeEffects' + type='effect' + isGlassy=true + collection=effects.actives + canCreate=true + hideResources=true }} {{> 'daggerheart.inventory-items' - title='DAGGERHEART.GENERAL.inactiveEffects' - type='effect' - isGlassy=true - collection=effects.inactives - canCreate=true - hideResources=true + title='DAGGERHEART.GENERAL.inactiveEffects' + type='effect' + disabledEffect=true + isGlassy=true + collection=effects.inactives + canCreate=true + hideResources=true }} \ No newline at end of file From c48842dd2d25c0f1487c9bb123d15cb4dad95964 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Thu, 26 Feb 2026 20:04:59 +0100 Subject: [PATCH 81/95] Fixed error on deleting a sceneEnvironment item --- module/data/registeredTriggers.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/data/registeredTriggers.mjs b/module/data/registeredTriggers.mjs index ee4f3b49..ab86351c 100644 --- a/module/data/registeredTriggers.mjs +++ b/module/data/registeredTriggers.mjs @@ -75,7 +75,7 @@ export default class RegisteredTriggers extends Map { unregisterSceneEnvironmentTriggers(flagSystemData) { const sceneData = new game.system.api.data.scenes.DHScene(flagSystemData); for (const environment of sceneData.sceneEnvironments) { - if (environment.pack) continue; + if (!environment || environment.pack) continue; this.unregisterItemTriggers(environment.system.features); } } From 0d0b5125bacc5d61b4f0596490842e79e8eec3de Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:37:33 +0100 Subject: [PATCH 82/95] [Fix] 1683 - Strange Patterns Explanation (#1693) * Added an explanation text to Strange Patterns trigger dialog * Update lang/en.json Co-authored-by: Chris Ryan <73275196+chrisryan10@users.noreply.github.com> --------- Co-authored-by: Chris Ryan <73275196+chrisryan10@users.noreply.github.com> --- lang/en.json | 1 + .../classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lang/en.json b/lang/en.json index 937de844..7b0840e4 100755 --- a/lang/en.json +++ b/lang/en.json @@ -1294,6 +1294,7 @@ "triggerTexts": { "strangePatternsContentTitle": "Matched {nr} times.", "strangePatternsContentSubTitle": "Increase hope and stress to a total of {nr}.", + "strangePatternsActionExplanation": "Left click to increase, right click to decrease", "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." }, diff --git a/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json b/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json index 95f42c06..953b3a2c 100644 --- a/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json +++ b/src/packs/classes/feature_Strange_Patterns_6YsfFjmCGuFYVhT4.json @@ -85,7 +85,7 @@ { "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;" + "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
      ${game.i18n.localize('DAGGERHEART.CONFIG.Triggers.triggerTexts.strangePatternsActionExplanation')}
      \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;" } ] } From 5459581f7fd0f33fbfe5f4ac3d84891ee086a332 Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 4 Mar 2026 01:10:40 +0100 Subject: [PATCH 83/95] Fixed styling in firefox (#1692) --- styles/less/sheets/actors/actor-sheet-shared.less | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/styles/less/sheets/actors/actor-sheet-shared.less b/styles/less/sheets/actors/actor-sheet-shared.less index bf6393f4..23db088a 100644 --- a/styles/less/sheets/actors/actor-sheet-shared.less +++ b/styles/less/sheets/actors/actor-sheet-shared.less @@ -183,6 +183,11 @@ } } + .domain-details { + display: flex; + flex-direction: column; + } + .level-details { align-self: center; } From 986544a653c35b5a424990b69b86514ab90e123f Mon Sep 17 00:00:00 2001 From: WBHarry <89362246+WBHarry@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:16:11 +0100 Subject: [PATCH 84/95] [Fix] 1689 - Missing Feature Errors (#1690) * Fixed so that weaponfeatures and armorFeatures are tolerant of features having been removed * . --- module/config/itemConfig.mjs | 8 ++------ module/data/item/armor.mjs | 6 ++---- module/data/item/weapon.mjs | 6 ++---- module/helpers/utils.mjs | 2 +- system.json | 2 +- 5 files changed, 8 insertions(+), 16 deletions(-) diff --git a/module/config/itemConfig.mjs b/module/config/itemConfig.mjs index 7d80e597..77328987 100644 --- a/module/config/itemConfig.mjs +++ b/module/config/itemConfig.mjs @@ -467,9 +467,7 @@ export const allArmorFeatures = () => { }; export const orderedArmorFeatures = () => { - const homebrewFeatures = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).itemFeatures - .armorFeatures; - const allFeatures = { ...armorFeatures, ...homebrewFeatures }; + const allFeatures = allArmorFeatures(); const all = Object.keys(allFeatures).map(key => { const feature = allFeatures[key]; return { @@ -1404,9 +1402,7 @@ export const allWeaponFeatures = () => { }; export const orderedWeaponFeatures = () => { - const homebrewFeatures = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).itemFeatures - .weaponFeatures; - const allFeatures = { ...weaponFeatures, ...homebrewFeatures }; + const allFeatures = allWeaponFeatures(); const all = Object.keys(allFeatures).map(key => { const feature = allFeatures[key]; return { diff --git a/module/data/item/armor.mjs b/module/data/item/armor.mjs index 2d31c290..0958a9f3 100644 --- a/module/data/item/armor.mjs +++ b/module/data/item/armor.mjs @@ -23,9 +23,7 @@ export default class DHArmor extends AttachableItem { armorFeatures: new fields.ArrayField( new fields.SchemaField({ value: new fields.StringField({ - required: true, - choices: CONFIG.DH.ITEM.allArmorFeatures, - blank: true + required: true }), effectIds: new fields.ArrayField(new fields.StringField({ required: true })), actionIds: new fields.ArrayField(new fields.StringField({ required: true })) @@ -58,7 +56,7 @@ export default class DHArmor extends AttachableItem { async getDescriptionData() { const baseDescription = this.description; const allFeatures = CONFIG.DH.ITEM.allArmorFeatures(); - const features = this.armorFeatures.map(x => allFeatures[x.value]); + const features = this.armorFeatures.map(x => allFeatures[x.value]).filter(x => x); const prefix = await foundry.applications.handlebars.renderTemplate( 'systems/daggerheart/templates/sheets/items/armor/description.hbs', diff --git a/module/data/item/weapon.mjs b/module/data/item/weapon.mjs index 5c6f8514..051fd42d 100644 --- a/module/data/item/weapon.mjs +++ b/module/data/item/weapon.mjs @@ -38,9 +38,7 @@ export default class DHWeapon extends AttachableItem { weaponFeatures: new fields.ArrayField( new fields.SchemaField({ value: new fields.StringField({ - required: true, - choices: CONFIG.DH.ITEM.allWeaponFeatures, - blank: true + required: true }), effectIds: new fields.ArrayField(new fields.StringField({ required: true })), actionIds: new fields.ArrayField(new fields.StringField({ required: true })) @@ -121,7 +119,7 @@ export default class DHWeapon extends AttachableItem { const burden = game.i18n.localize(CONFIG.DH.GENERAL.burden[this.burden].label); const allFeatures = CONFIG.DH.ITEM.allWeaponFeatures(); - const features = this.weaponFeatures.map(x => allFeatures[x.value]); + const features = this.weaponFeatures.map(x => allFeatures[x.value]).filter(x => x); const prefix = await foundry.applications.handlebars.renderTemplate( 'systems/daggerheart/templates/sheets/items/weapon/description.hbs', diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs index 4ecc7809..57badd89 100644 --- a/module/helpers/utils.mjs +++ b/module/helpers/utils.mjs @@ -119,8 +119,8 @@ export const tagifyElement = (element, baseOptions, onChange, tagifyOptions = {} }), maxTags: typeof maxTags === 'function' ? maxTags() : maxTags, dropdown: { + searchKeys: ['value', 'name'], mapValueTo: 'name', - searchKeys: ['value'], enabled: 0, maxItems: 100, closeOnSelect: true, diff --git a/system.json b/system.json index fb23ad7b..40de5fa1 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.7.2", + "version": "1.7.3", "compatibility": { "minimum": "13.346", "verified": "13.351", From 3267f3f53143ef4a0e7454fdae610d21319db774 Mon Sep 17 00:00:00 2001 From: Psitacus <59754077+Psitacus@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:56:35 -0700 Subject: [PATCH 85/95] fix instances of rolls being called checks (#1702) Co-authored-by: Psitacus --- lang/en.json | 8 ++++---- .../domains/domainCard_Wrangle_9DwSxHoUwl8Kxj3n.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lang/en.json b/lang/en.json index 7b0840e4..6c52beeb 100755 --- a/lang/en.json +++ b/lang/en.json @@ -1166,12 +1166,12 @@ }, "far": { "name": "Far", - "description": "means a distance where one can see the appearance of a person or object, but probably not in great detail-- across a small battlefield or down a large corridor. This is usually about 30-100 feet away. While under danger, a PC will likely have to make an Agility check to get here safely. Anything on a battle map that is within the length of a standard piece of paper (~10-11 inches) can usually be considered far.", + "description": "means a distance where one can see the appearance of a person or object, but probably not in great detail-- across a small battlefield or down a large corridor. This is usually about 30-100 feet away. While under danger, a PC will likely have to make an Agility roll to get here safely. Anything on a battle map that is within the length of a standard piece of paper (~10-11 inches) can usually be considered far.", "short": "Far" }, "veryFar": { "name": "Very Far", - "description": "means a distance where you can see the shape of a person or object, but probably not make outany details-- across a large battlefield or down a long street, generally about 100-300 feet away. While under danger, a PC likely has to make an Agility check to get here safely. Anything on a battle map that is beyond far distance, but still within sight of the characters can usually be considered very far.", + "description": "means a distance where you can see the shape of a person or object, but probably not make outany details-- across a large battlefield or down a long street, generally about 100-300 feet away. While under danger, a PC likely has to make an Agility roll to get here safely. Anything on a battle map that is beyond far distance, but still within sight of the characters can usually be considered very far.", "short": "V. Far" } }, @@ -2806,7 +2806,7 @@ "title": "Domain Card" }, "dualityRoll": { - "abilityCheckTitle": "{ability} Check" + "abilityCheckTitle": "{ability} Roll" }, "effectSummary": { "title": "Effects Applied", @@ -2821,7 +2821,7 @@ "selectLeader": "Select a Leader", "selectMember": "Select a Member", "rerollTitle": "Reroll Group Roll", - "rerollContent": "Are you sure you want to reroll your {trait} check?", + "rerollContent": "Are you sure you want to reroll your {trait} roll?", "rerollTooltip": "Reroll", "wholePartySelected": "The whole party is selected" }, diff --git a/src/packs/domains/domainCard_Wrangle_9DwSxHoUwl8Kxj3n.json b/src/packs/domains/domainCard_Wrangle_9DwSxHoUwl8Kxj3n.json index 8cdb62b0..16753e1e 100644 --- a/src/packs/domains/domainCard_Wrangle_9DwSxHoUwl8Kxj3n.json +++ b/src/packs/domains/domainCard_Wrangle_9DwSxHoUwl8Kxj3n.json @@ -53,7 +53,7 @@ "difficulty": null, "damageMod": "none" }, - "name": "Agility Check", + "name": "Agility Roll", "img": "icons/skills/melee/sword-engraved-glow-purple.webp", "range": "close" } From 1212bd01f8eb45b3f2911b3536c72e438c6900a8 Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Thu, 5 Mar 2026 15:30:31 -0500 Subject: [PATCH 86/95] Increase the click area of sidebar and inventory control buttons (#1703) --- styles/less/global/inventory-item.less | 7 ++++--- templates/sheets/global/partials/inventory-item-V2.hbs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/styles/less/global/inventory-item.less b/styles/less/global/inventory-item.less index 9045baf5..b6c09dbf 100644 --- a/styles/less/global/inventory-item.less +++ b/styles/less/global/inventory-item.less @@ -103,10 +103,9 @@ display: flex; align-items: center; justify-content: end; - gap: 8px; a { - width: 15px; + width: 20px; text-align: center; } @@ -275,8 +274,10 @@ grid-area: controls; align-self: start; padding-top: 0.3125rem; - gap: 4px; margin-bottom: -1px; + a { + width: 18px; + } } > .item-labels { align-self: start; diff --git a/templates/sheets/global/partials/inventory-item-V2.hbs b/templates/sheets/global/partials/inventory-item-V2.hbs index fec215a0..e496ce4b 100644 --- a/templates/sheets/global/partials/inventory-item-V2.hbs +++ b/templates/sheets/global/partials/inventory-item-V2.hbs @@ -109,7 +109,7 @@ Parameters: {{else if (eq type 'armor')}} - + {{/if}} {{#if (eq type 'domainCard')}} @@ -125,7 +125,7 @@ Parameters: {{/if}} {{#if (hasProperty item "toChat")}} - + {{/if}} {{else}} @@ -138,7 +138,7 @@ Parameters: {{/unless}} {{#unless hideContextMenu}} - + {{/unless}} {{/if}} From 0675e1f0199b2a195d77dcf9d41ff45f98b38ab7 Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Thu, 5 Mar 2026 15:31:49 -0500 Subject: [PATCH 87/95] Add recall cost to domain cards in grid view (#1700) --- styles/less/global/inventory-item.less | 21 +++++++++++++++++++ .../global/partials/domain-card-item.hbs | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/styles/less/global/inventory-item.less b/styles/less/global/inventory-item.less index b6c09dbf..d703d189 100644 --- a/styles/less/global/inventory-item.less +++ b/styles/less/global/inventory-item.less @@ -335,6 +335,27 @@ border-radius: 6px; } + .recall-cost { + position: absolute; + right: 4px; + top: 4px; + width: 1.75em; + height: 1.75em; + + align-items: center; + background: @dark-blue; + border-radius: 50%; + border: 1px solid @golden; + color: @golden; + display: flex; + justify-content: center; + padding-top: 0.1em; // compensate for font + + i { + font-size: 0.68em; + } + } + .card-label { display: flex; flex-direction: column; diff --git a/templates/sheets/global/partials/domain-card-item.hbs b/templates/sheets/global/partials/domain-card-item.hbs index ae95b7af..54e44e64 100644 --- a/templates/sheets/global/partials/domain-card-item.hbs +++ b/templates/sheets/global/partials/domain-card-item.hbs @@ -1,5 +1,9 @@
    • + + {{item.system.recallCost}} + +
      + {{formGroup settingFields.schema.fields.vulnerableAutomation value=settingFields._source.vulnerableAutomation localize=true}} {{formGroup settingFields.schema.fields.countdownAutomation value=settingFields._source.countdownAutomation localize=true}} {{formGroup settingFields.schema.fields.actionPoints value=settingFields._source.actionPoints localize=true}} {{formGroup settingFields.schema.fields.hordeDamage value=settingFields._source.hordeDamage localize=true}} From f1f5102af13cf8c69858ba6f9a8980decc587190 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Sat, 7 Mar 2026 01:33:45 +0100 Subject: [PATCH 95/95] Raised version --- system.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system.json b/system.json index 40de5fa1..fc5e1615 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.7.3", + "version": "1.8.0", "compatibility": { "minimum": "13.346", "verified": "13.351",