diff --git a/daggerheart.mjs b/daggerheart.mjs index 9e2c2f69..2fa66320 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -241,14 +241,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 6b485ea9..651a661e 100755 --- a/lang/en.json +++ b/lang/en.json @@ -357,7 +357,8 @@ "CompendiumBrowserSettings": { "title": "Enable Compendiums", "enableSource": "Enable Source", - "disableSource": "Disable Source" + "disableSource": "Disable Source", + "worldCompendiums": "World Compendiums" }, "ContextMenu": { "disableEffect": "Disable Effect", @@ -2113,7 +2114,7 @@ "thresholdImmunities": { "minor": { "label": "Threshold Immunities: Minor", - "hint": "Automatically ignores minor damage" + "hint": "Automatically ignores minor damage when set to 1" } } }, @@ -2980,7 +2981,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/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/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/applications/sheets-configs/activeEffectConfig.mjs b/module/applications/sheets-configs/activeEffectConfig.mjs index e3003a6d..27a94736 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); 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/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/applications/ui/sceneNavigation.mjs b/module/applications/ui/sceneNavigation.mjs index bc906dac..a0005fc7 100644 --- a/module/applications/ui/sceneNavigation.mjs +++ b/module/applications/ui/sceneNavigation.mjs @@ -64,7 +64,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( diff --git a/module/config/actorConfig.mjs b/module/config/actorConfig.mjs index fdef7d03..2235f873 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/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]; diff --git a/module/data/fields/actionField.mjs b/module/data/fields/actionField.mjs index de2bd394..624985fc 100644 --- a/module/data/fields/actionField.mjs +++ b/module/data/fields/actionField.mjs @@ -269,6 +269,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'), @@ -297,7 +300,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/module/documents/token.mjs b/module/documents/token.mjs index 89b07a49..8e810689 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; }; diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs index 980349ba..2fc18a63 100644 --- a/module/helpers/utils.mjs +++ b/module/helpers/utils.mjs @@ -509,3 +509,49 @@ export function getIconVisibleActiveEffects(effects) { return !effect.disabled && (alwaysShown || conditionalShown); }); } + +/** + * 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/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 }); } }); 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/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/styles/less/ux/tooltip/armorManagement.less b/styles/less/ux/tooltip/armorManagement.less new file mode 100644 index 00000000..bc716fa0 --- /dev/null +++ b/styles/less/ux/tooltip/armorManagement.less @@ -0,0 +1,100 @@ +.bordered-tooltip.locked-tooltip .daggerheart.armor-management-container { + display: flex; + flex-direction: column; + gap: 16px; + + .armor-source-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + + .armor-source-label { + font-size: var(--font-size-24); + font-weight: bold; + } + + .status-bar { + display: flex; + justify-content: center; + position: relative; + width: 80px; + height: 20px; + + .status-value { + position: absolute; + display: flex; + padding: 0 5px; + font-size: 1rem; + align-items: center; + width: 80px; + height: 20px; + justify-content: center; + text-align: center; + z-index: 2; + color: @beige; + + input[type='number'] { + background: transparent; + font-size: 1rem; + width: 30px; + height: 15px; + text-align: center; + border: none; + outline: 2px solid transparent; + color: @beige; + + &.bar-input { + padding: 0; + color: @beige; + backdrop-filter: none; + background: transparent; + transition: all 0.3s ease; + + &:hover, + &:focus { + background: @semi-transparent-dark-blue; + backdrop-filter: blur(9.5px); + } + } + } + + .bar-label { + width: 40px; + } + } + .progress-bar { + position: absolute; + appearance: none; + width: 80px; + height: 20px; + border: 1px solid light-dark(@dark-blue, @golden); + border-radius: 6px; + z-index: 1; + background: @dark-blue; + + &::-webkit-progress-bar { + border: none; + background: @dark-blue; + border-radius: 6px; + } + &::-webkit-progress-value { + background: @gradient-hp; + border-radius: 6px; + } + &.stress-color::-webkit-progress-value { + background: @gradient-stress; + border-radius: 6px; + } + &::-moz-progress-bar { + background: @gradient-hp; + border-radius: 6px; + } + &.stress-color::-moz-progress-bar { + background: @gradient-stress; + border-radius: 6px; + } + } + } + } +} 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 |}} -
    +
    diff --git a/templates/ui/sceneNavigation/scene-navigation.hbs b/templates/ui/sceneNavigation/scene-navigation.hbs index 933d2074..68df761e 100644 --- a/templates/ui/sceneNavigation/scene-navigation.hbs +++ b/templates/ui/sceneNavigation/scene-navigation.hbs @@ -37,17 +37,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}} @@ -57,9 +59,11 @@ {{#each scenes.inactive as |scene|}}
  • -
    +
      +
    • {{scene.name}} -
    +
  • +
{{/each}} --}} diff --git a/templates/ui/tooltip/armorManagement.hbs b/templates/ui/tooltip/armorManagement.hbs new file mode 100644 index 00000000..aa8c9878 --- /dev/null +++ b/templates/ui/tooltip/armorManagement.hbs @@ -0,0 +1,19 @@ +
+ {{#each sources as |source|}} +
+ +
+
+ + / + {{source.max}} +
+ +
+
+ {{/each}} +
\ No newline at end of file diff --git a/tools/analyze-damage.mjs b/tools/analyze-damage.mjs new file mode 100644 index 00000000..7b3fb9e5 --- /dev/null +++ b/tools/analyze-damage.mjs @@ -0,0 +1,144 @@ +/** + * 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); +}