diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..56ce8818 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.json text eol=lf diff --git a/lang/en.json b/lang/en.json index a06c46c2..f1841e09 100755 --- a/lang/en.json +++ b/lang/en.json @@ -712,12 +712,6 @@ "ReactionRoll": { "title": "Reaction Roll: {trait}" }, - "RerollDialog": { - "title": "Reroll", - "damageTitle": "Reroll Damage", - "deselectDiceNotification": "Deselect one of the selected dice first", - "acceptCurrentRolls": "Accept Current Rolls" - }, "ResourceDice": { "title": "{name} Resource", "rerollDice": "Reroll Dice" @@ -3097,6 +3091,7 @@ } }, "ChatLog": { + "rerollActionRoll": "Reroll Action", "rerollDamage": "Reroll Damage", "assignTagRoll": "Assign as Tag Roll" }, diff --git a/module/applications/dialogs/_module.mjs b/module/applications/dialogs/_module.mjs index c866f1cd..fc5169b2 100644 --- a/module/applications/dialogs/_module.mjs +++ b/module/applications/dialogs/_module.mjs @@ -10,7 +10,6 @@ export { default as ImageSelectDialog } from './imageSelectDialog.mjs'; export { default as ItemTransferDialog } from './itemTransfer.mjs'; export { default as MulticlassChoiceDialog } from './multiclassChoiceDialog.mjs'; export { default as OwnershipSelection } from './ownershipSelection.mjs'; -export { default as RerollDamageDialog } from './rerollDamageDialog.mjs'; export { default as ResourceDiceDialog } from './resourceDiceDialog.mjs'; export { default as ActionSelectionDialog } from './actionSelectionDialog.mjs'; export { default as TagTeamDialog } from './tagTeamDialog.mjs'; diff --git a/module/applications/dialogs/groupRollDialog.mjs b/module/applications/dialogs/groupRollDialog.mjs index bd45fe91..52baf537 100644 --- a/module/applications/dialogs/groupRollDialog.mjs +++ b/module/applications/dialogs/groupRollDialog.mjs @@ -358,8 +358,6 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat }); if (!result) return; - // todo: move logic to actor.rollTrait() or actor.diceRoll() - if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice }); const rollData = result.messageRoll.toJSON(); delete rollData.options.messageRoll; diff --git a/module/applications/dialogs/rerollDamageDialog.mjs b/module/applications/dialogs/rerollDamageDialog.mjs deleted file mode 100644 index b821bd24..00000000 --- a/module/applications/dialogs/rerollDamageDialog.mjs +++ /dev/null @@ -1,280 +0,0 @@ -const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; - -export default class RerollDamageDialog extends HandlebarsApplicationMixin(ApplicationV2) { - constructor(message, options = {}) { - super(options); - - this.message = message; - this.damage = Object.keys(message.system.damage).reduce((acc, typeKey) => { - const type = message.system.damage[typeKey]; - acc[typeKey] = Object.keys(type.parts).reduce((acc, partKey) => { - const part = type.parts[partKey]; - acc[partKey] = Object.keys(part.dice).reduce((acc, diceKey) => { - const dice = part.dice[diceKey]; - const activeResults = dice.results.filter(x => x.active); - acc[diceKey] = { - dice: dice.dice, - selectedResults: activeResults.length, - maxSelected: activeResults.length, - results: activeResults.map(x => ({ ...x, selected: true })) - }; - - return acc; - }, {}); - - return acc; - }, {}); - - return acc; - }, {}); - } - - static DEFAULT_OPTIONS = { - id: 'reroll-dialog', - classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'reroll-dialog'], - window: { - icon: 'fa-solid fa-dice' - }, - actions: { - toggleResult: RerollDamageDialog.#toggleResult, - selectRoll: RerollDamageDialog.#selectRoll, - doReroll: RerollDamageDialog.#doReroll, - save: RerollDamageDialog.#save - } - }; - - /** @override */ - static PARTS = { - main: { - id: 'main', - template: 'systems/daggerheart/templates/dialogs/rerollDialog/damage/main.hbs' - }, - footer: { - id: 'footer', - template: 'systems/daggerheart/templates/dialogs/rerollDialog/footer.hbs' - } - }; - - get title() { - return game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.damageTitle'); - } - - _attachPartListeners(partId, htmlElement, options) { - super._attachPartListeners(partId, htmlElement, options); - - htmlElement.querySelectorAll('.to-reroll-input').forEach(element => { - element.addEventListener('change', this.toggleDice.bind(this)); - }); - } - - async _prepareContext(_options) { - const context = await super._prepareContext(_options); - context.damage = this.damage; - context.disabledReroll = !this.getRerollDice().length; - context.saveDisabled = !this.isSelectionDone(); - - return context; - } - - static async #save() { - const update = { - 'system.damage': Object.keys(this.damage).reduce((acc, typeKey) => { - const type = this.damage[typeKey]; - let typeTotal = 0; - const messageType = this.message.system.damage[typeKey]; - const parts = Object.keys(type).map(partKey => { - const part = type[partKey]; - const messagePart = messageType.parts[partKey]; - let partTotal = messagePart.modifierTotal; - const dice = Object.keys(part).map(diceKey => { - const dice = part[diceKey]; - const total = dice.results.reduce((acc, result) => { - if (result.active) acc += result.result; - return acc; - }, 0); - partTotal += total; - const messageDice = messagePart.dice[diceKey]; - return { - ...messageDice, - total: total, - results: dice.results.map(x => ({ - ...x, - hasRerolls: dice.results.length > 1 - })) - }; - }); - - typeTotal += partTotal; - return { - ...messagePart, - total: partTotal, - dice: dice - }; - }); - - acc[typeKey] = { - ...messageType, - total: typeTotal, - parts: parts - }; - - return acc; - }, {}) - }; - - await this.message.update(update); - await this.close(); - } - - getRerollDice() { - const rerollDice = []; - Object.keys(this.damage).forEach(typeKey => { - const type = this.damage[typeKey]; - Object.keys(type).forEach(partKey => { - const part = type[partKey]; - Object.keys(part).forEach(diceKey => { - const dice = part[diceKey]; - Object.keys(dice.results).forEach(resultKey => { - const result = dice.results[resultKey]; - if (result.toReroll) { - rerollDice.push({ - ...result, - dice: dice.dice, - type: typeKey, - part: partKey, - dice: diceKey, - result: resultKey - }); - } - }); - }); - }); - }); - - return rerollDice; - } - - isSelectionDone() { - const diceFinishedData = []; - Object.keys(this.damage).forEach(typeKey => { - const type = this.damage[typeKey]; - Object.keys(type).forEach(partKey => { - const part = type[partKey]; - Object.keys(part).forEach(diceKey => { - const dice = part[diceKey]; - const selected = dice.results.reduce((acc, result) => acc + (result.active ? 1 : 0), 0); - diceFinishedData.push(selected === dice.maxSelected); - }); - }); - }); - - return diceFinishedData.every(x => x); - } - - toggleDice(event) { - const target = event.target; - const { type, part, dice } = target.dataset; - const toggleDice = this.damage[type][part][dice]; - - const existingDiceRerolls = this.getRerollDice().filter( - x => x.type === type && x.part === part && x.dice === dice - ); - - const allRerolled = existingDiceRerolls.length === toggleDice.results.filter(x => x.active).length; - - toggleDice.toReroll = !allRerolled; - toggleDice.results.forEach(result => { - if (result.active) { - result.toReroll = !allRerolled; - } - }); - - this.render(); - } - - static #toggleResult(event) { - event.stopPropagation(); - - const target = event.target.closest('.to-reroll-result'); - const { type, part, dice, result } = target.dataset; - const toggleDice = this.damage[type][part][dice]; - const toggleResult = toggleDice.results[result]; - toggleResult.toReroll = !toggleResult.toReroll; - - const existingDiceRerolls = this.getRerollDice().filter( - x => x.type === type && x.part === part && x.dice === dice - ); - - const allToReroll = existingDiceRerolls.length === toggleDice.results.filter(x => x.active).length; - toggleDice.toReroll = allToReroll; - - this.render(); - } - - static async #selectRoll(_, button) { - const { type, part, dice, result } = button.dataset; - - const diceVal = this.damage[type][part][dice]; - const diceResult = diceVal.results[result]; - if (!diceResult.active && diceVal.results.filter(x => x.active).length === diceVal.maxSelected) { - return ui.notifications.warn( - game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.deselectDiceNotification') - ); - } - - if (diceResult.active) { - diceVal.toReroll = false; - diceResult.toReroll = false; - } - - diceVal.selectedResults += diceResult.active ? -1 : 1; - diceResult.active = !diceResult.active; - - this.render(); - } - - static async #doReroll() { - const toReroll = this.getRerollDice().map(x => { - const { type, part, dice, result } = x; - const diceData = this.damage[type][part][dice].results[result]; - return { - ...diceData, - dice: this.damage[type][part][dice].dice, - typeKey: type, - partKey: part, - diceKey: dice, - resultsIndex: result - }; - }); - - const roll = await new Roll(toReroll.map(x => `1${x.dice}`).join(' + ')).evaluate(); - - if (game.modules.get('dice-so-nice')?.active) { - const diceSoNiceRoll = { - _evaluated: true, - dice: roll.dice, - options: { appearance: {} } - }; - - await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true); - } - - toReroll.forEach((data, index) => { - const { typeKey, partKey, diceKey, resultsIndex } = data; - const rerolledDice = roll.dice[index]; - - const dice = this.damage[typeKey][partKey][diceKey]; - dice.toReroll = false; - dice.results[resultsIndex].active = false; - dice.results[resultsIndex].discarded = true; - dice.results[resultsIndex].toReroll = false; - dice.results.splice(dice.results.length, 0, { - ...rerolledDice.results[0], - toReroll: false, - selected: true - }); - }); - - this.render(); - } -} diff --git a/module/applications/dialogs/rerollDialog.mjs b/module/applications/dialogs/rerollDialog.mjs deleted file mode 100644 index cae4e53a..00000000 --- a/module/applications/dialogs/rerollDialog.mjs +++ /dev/null @@ -1,279 +0,0 @@ -const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; - -export default class RerollDialog extends HandlebarsApplicationMixin(ApplicationV2) { - constructor(message, options = {}) { - super(options); - - this.message = message; - this.damage = Object.keys(message.system.damage).reduce((acc, typeKey) => { - const type = message.system.damage[typeKey]; - acc[typeKey] = Object.keys(type.parts).reduce((acc, partKey) => { - const part = type.parts[partKey]; - acc[partKey] = Object.keys(part.dice).reduce((acc, diceKey) => { - const dice = part.dice[diceKey]; - const activeResults = dice.results.filter(x => x.active); - acc[diceKey] = { - dice: dice.dice, - selectedResults: activeResults.length, - maxSelected: activeResults.length, - results: activeResults.map(x => ({ ...x, selected: true })) - }; - - return acc; - }, {}); - - return acc; - }, {}); - - return acc; - }, {}); - } - - static DEFAULT_OPTIONS = { - id: 'reroll-dialog', - classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'reroll-dialog'], - window: { - icon: 'fa-solid fa-dice' - }, - actions: { - toggleResult: RerollDialog.#toggleResult, - selectRoll: RerollDialog.#selectRoll, - doReroll: RerollDialog.#doReroll, - save: RerollDialog.#save - } - }; - - /** @override */ - static PARTS = { - main: { - id: 'main', - template: 'systems/daggerheart/templates/dialogs/rerollDialog/main.hbs' - }, - footer: { - id: 'footer', - template: 'systems/daggerheart/templates/dialogs/rerollDialog/footer.hbs' - } - }; - - get title() { - return game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.title'); - } - - _attachPartListeners(partId, htmlElement, options) { - super._attachPartListeners(partId, htmlElement, options); - - htmlElement.querySelectorAll('.to-reroll-input').forEach(element => { - element.addEventListener('change', this.toggleDice.bind(this)); - }); - } - - async _prepareContext(_options) { - const context = await super._prepareContext(_options); - context.damage = this.damage; - context.disabledReroll = !this.getRerollDice().length; - context.saveDisabled = !this.isSelectionDone(); - - return context; - } - - static async #save() { - const update = { - 'system.damage': Object.keys(this.damage).reduce((acc, typeKey) => { - const type = this.damage[typeKey]; - let typeTotal = 0; - const messageType = this.message.system.damage[typeKey]; - const parts = Object.keys(type).map(partKey => { - const part = type[partKey]; - const messagePart = messageType.parts[partKey]; - let partTotal = messagePart.modifierTotal; - const dice = Object.keys(part).map(diceKey => { - const dice = part[diceKey]; - const total = dice.results.reduce((acc, result) => { - if (result.active) acc += result.result; - return acc; - }, 0); - partTotal += total; - const messageDice = messagePart.dice[diceKey]; - return { - ...messageDice, - total: total, - results: dice.results.map(x => ({ - ...x, - hasRerolls: dice.results.length > 1 - })) - }; - }); - - typeTotal += partTotal; - return { - ...messagePart, - total: partTotal, - dice: dice - }; - }); - - acc[typeKey] = { - ...messageType, - total: typeTotal, - parts: parts - }; - - return acc; - }, {}) - }; - await this.message.update(update); - await this.close(); - } - - getRerollDice() { - const rerollDice = []; - Object.keys(this.damage).forEach(typeKey => { - const type = this.damage[typeKey]; - Object.keys(type).forEach(partKey => { - const part = type[partKey]; - Object.keys(part).forEach(diceKey => { - const dice = part[diceKey]; - Object.keys(dice.results).forEach(resultKey => { - const result = dice.results[resultKey]; - if (result.toReroll) { - rerollDice.push({ - ...result, - dice: dice.dice, - type: typeKey, - part: partKey, - dice: diceKey, - result: resultKey - }); - } - }); - }); - }); - }); - - return rerollDice; - } - - isSelectionDone() { - const diceFinishedData = []; - Object.keys(this.damage).forEach(typeKey => { - const type = this.damage[typeKey]; - Object.keys(type).forEach(partKey => { - const part = type[partKey]; - Object.keys(part).forEach(diceKey => { - const dice = part[diceKey]; - const selected = dice.results.reduce((acc, result) => acc + (result.active ? 1 : 0), 0); - diceFinishedData.push(selected === dice.maxSelected); - }); - }); - }); - - return diceFinishedData.every(x => x); - } - - toggleDice(event) { - const target = event.target; - const { type, part, dice } = target.dataset; - const toggleDice = this.damage[type][part][dice]; - - const existingDiceRerolls = this.getRerollDice().filter( - x => x.type === type && x.part === part && x.dice === dice - ); - - const allRerolled = existingDiceRerolls.length === toggleDice.results.filter(x => x.active).length; - - toggleDice.toReroll = !allRerolled; - toggleDice.results.forEach(result => { - if (result.active) { - result.toReroll = !allRerolled; - } - }); - - this.render(); - } - - static #toggleResult(event) { - event.stopPropagation(); - - const target = event.target.closest('.to-reroll-result'); - const { type, part, dice, result } = target.dataset; - const toggleDice = this.damage[type][part][dice]; - const toggleResult = toggleDice.results[result]; - toggleResult.toReroll = !toggleResult.toReroll; - - const existingDiceRerolls = this.getRerollDice().filter( - x => x.type === type && x.part === part && x.dice === dice - ); - - const allToReroll = existingDiceRerolls.length === toggleDice.results.length; - toggleDice.toReroll = allToReroll; - - this.render(); - } - - static async #selectRoll(_, button) { - const { type, part, dice, result } = button.dataset; - - const diceVal = this.damage[type][part][dice]; - const diceResult = diceVal.results[result]; - if (!diceResult.active && diceVal.results.filter(x => x.active).length === diceVal.maxSelected) { - return ui.notifications.warn( - game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.deselectDiceNotification') - ); - } - - if (diceResult.active) { - diceVal.toReroll = false; - diceResult.toReroll = false; - } - - diceVal.selectedResults += diceResult.active ? -1 : 1; - diceResult.active = !diceResult.active; - - this.render(); - } - - static async #doReroll() { - const toReroll = this.getRerollDice().map(x => { - const { type, part, dice, result } = x; - const diceData = this.damage[type][part][dice].results[result]; - return { - ...diceData, - dice: this.damage[type][part][dice].dice, - typeKey: type, - partKey: part, - diceKey: dice, - resultsIndex: result - }; - }); - - const roll = await new Roll(toReroll.map(x => `1${x.dice}`).join(' + ')).evaluate(); - - if (game.modules.get('dice-so-nice')?.active) { - const diceSoNiceRoll = { - _evaluated: true, - dice: roll.dice, - options: { appearance: {} } - }; - - await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true); - } - - toReroll.forEach((data, index) => { - const { typeKey, partKey, diceKey, resultsIndex } = data; - const rerolledDice = roll.dice[index]; - - const dice = this.damage[typeKey][partKey][diceKey]; - dice.toReroll = false; - dice.results[resultsIndex].active = false; - dice.results[resultsIndex].discarded = true; - dice.results[resultsIndex].toReroll = false; - dice.results.splice(dice.results.length, 0, { - ...rerolledDice.results[0], - toReroll: false, - selected: true - }); - }); - - this.render(); - } -} diff --git a/module/applications/dialogs/resourceDiceDialog.mjs b/module/applications/dialogs/resourceDiceDialog.mjs index 32e1e5d8..8394538c 100644 --- a/module/applications/dialogs/resourceDiceDialog.mjs +++ b/module/applications/dialogs/resourceDiceDialog.mjs @@ -1,4 +1,4 @@ -import { itemAbleRollParse } from '../../helpers/utils.mjs'; +import { itemAbleRollParse, triggerChatRollFx } from '../../helpers/utils.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; @@ -69,7 +69,7 @@ export default class ResourceDiceDialog extends HandlebarsApplicationMixin(Appli const max = itemAbleRollParse(this.item.system.resource.max, this.actor, this.item); const diceFormula = `${max}${this.item.system.resource.dieFaces}`; const roll = await new Roll(diceFormula).evaluate(); - if (game.modules.get('dice-so-nice')?.active) await game.dice3d.showForRoll(roll, game.user, true); + await triggerChatRollFx([roll]); this.rollValues = roll.terms[0].results.map(x => ({ value: x.result, used: false })); this.resetUsed = true; diff --git a/module/applications/dialogs/tagTeamDialog.mjs b/module/applications/dialogs/tagTeamDialog.mjs index ba76831f..4e63d93b 100644 --- a/module/applications/dialogs/tagTeamDialog.mjs +++ b/module/applications/dialogs/tagTeamDialog.mjs @@ -434,8 +434,6 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio if (!result) return; - if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice }); - const rollData = result.messageRoll.toJSON(); delete rollData.options.messageRoll; this.updatePartyData( diff --git a/module/applications/ui/chatLog.mjs b/module/applications/ui/chatLog.mjs index 34b25591..7036a5df 100644 --- a/module/applications/ui/chatLog.mjs +++ b/module/applications/ui/chatLog.mjs @@ -103,6 +103,19 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo _getEntryContextOptions() { return [ ...super._getEntryContextOptions(), + { + label: 'DAGGERHEART.UI.ChatLog.rerollActionRoll', + icon: '', + visible: li => { + const message = game.messages.get(li.dataset.messageId); + return message.system.hasRoll && (game.user.isGM || message.isAuthor); + }, + callback: async li => { + const message = game.messages.get(li.dataset.messageId); + const reroll = await message.rolls[0].reroll({ liveRoll: true }); + message.update({ rolls: [reroll] }); + } + }, { label: 'DAGGERHEART.UI.ChatLog.rerollDamage', icon: '', @@ -113,9 +126,10 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo : false; return (game.user.isGM || message.isAuthor) && hasRolledDamage; }, - callback: li => { + callback: async li => { const message = game.messages.get(li.dataset.messageId); - new game.system.api.applications.dialogs.RerollDamageDialog(message).render({ force: true }); + const update = await message.system.getRerolledDamage(); + message.update(update); } } ]; diff --git a/module/data/actor/adversary.mjs b/module/data/actor/adversary.mjs index d69e17ad..d6d0dcdf 100644 --- a/module/data/actor/adversary.mjs +++ b/module/data/actor/adversary.mjs @@ -3,8 +3,7 @@ import { ActionField } from '../fields/actionField.mjs'; import { commonActorRules } from './base.mjs'; import DhCreature from './creature.mjs'; import { bonusField } from '../fields/actorField.mjs'; -import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; -import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; +import { getTierAdjustedAdversary } from './tierAdjustment.mjs'; export default class DhpAdversary extends DhCreature { static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary']; @@ -206,205 +205,6 @@ export default class DhpAdversary extends DhCreature { /** 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 - // Parse damage, and convert all formula matches in the descriptions to the new damage - for (const action of Object.values(item.system.actions)) { - try { - const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' }); - if (!result) continue; - - 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, or null if it doesn't deal HP damage - */ - #adjustActionDamage(action, damageMeta) { - if (!action.damage?.parts.hitPoints) return null; - - const result = {}; - for (const property of ['value', 'valueAlt']) { - const data = action.damage.parts.hitPoints[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; + return getTierAdjustedAdversary(source, tier); } } diff --git a/module/data/actor/tierAdjustment.mjs b/module/data/actor/tierAdjustment.mjs new file mode 100644 index 00000000..785eec2b --- /dev/null +++ b/module/data/actor/tierAdjustment.mjs @@ -0,0 +1,218 @@ +import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs'; +import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs'; + +export function getTierAdjustedAdversary(source, tier) { + const currentTier = source.tier ?? 1; + + /** @type {(2 | 3 | 4)[]} */ + const tiers = new Array(Math.abs(tier - currentTier)) + .fill(0) + .map((_, idx) => idx + Math.min(tier, currentTier) + 1); + if (tier < currentTier) 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 > currentTier ? 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] } + }; + + // Store initial attack damage for abilities that have you deal a "standard attack" + const initialAttack = { + type: source.system.attack.damage?.parts.hitPoints?.type?.toSorted(), + value: getDamagePartsFormula(source.system.attack.damage?.parts.hitPoints?.value) + }; + + // Update damage of base attack. + try { + const damage = source.system.attack.damage; + if (!damage?.parts.hitPoints) throw new Error('Unexpected missing attack in adversary'); + + for (const property of ['value', 'valueAlt']) { + const data = damage.parts.hitPoints[property]; + const previousFormula = getDamagePartsFormula(data); + const { value, formula } = calculateAdjustedDamage(previousFormula, 'attack', damageMeta); + applyAdjustedDamage(data, value, formula); + } + } 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. Keep a record for a specific check later + const descriptionFormulas = []; + 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 newFormula = calculateAdjustedDamage(formula, 'action', damageMeta)?.formula; + descriptionFormulas.push(formula); + return match.replace(formula, newFormula); + } catch { + return match; + } + }); + } + + // Update damage in item actions and convert all formula matches in the descriptions to the new damage + for (const action of Object.values(item.system.actions)) { + if (!action.damage?.parts.hitPoints) continue; + try { + // Apply conversions and save a record. If it matches attack damage *and* Its not in the description, use attack conversion instead + const result = []; + for (const property of ['value', 'valueAlt']) { + const { [property]: data, type: damageType } = action.damage.parts.hitPoints; + const previousFormula = getDamagePartsFormula(data); + const isActuallyAttack = + previousFormula === initialAttack.value && + foundry.utils.equals(damageType.toSorted(), initialAttack.type) && + !descriptionFormulas.includes(previousFormula); + const type = isActuallyAttack ? 'attack' : 'action'; + const { value, formula } = calculateAdjustedDamage(previousFormula, type, damageMeta); + applyAdjustedDamage(data, value, formula); + result.push({ previousFormula, formula }); + } + + // Override text in the description with those values + 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 + */ +function calculateAdjustedDamage(formula, type, { currentDamageRange, newDamageRange }) { + 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()); + } + + const newFormula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus] + .filter(p => !!p) + .join('+'); + return { value, formula: newFormula }; +} + +function getDamagePartsFormula(data) { + return data.custom.enabled + ? data.custom.formula + : [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0].filter(p => !!p).join('+'); +} + +/** + * Updates damage to reflect a specific value. + * @throws if damage structure is invalid for conversion + * @returns the converted formula and value as a simplified term, or null if it doesn't deal HP damage + */ +function applyAdjustedDamage(diceData, value, formula) { + if (value.diceQuantity) { + diceData.custom.enabled = false; + diceData.bonus = value.bonus; + diceData.dice = `d${value.faces}`; + diceData.flatMultiplier = value.diceQuantity; + } else if (!value.diceQuantity) { + diceData.custom.enabled = true; + diceData.custom.formula = formula; + } +} diff --git a/module/data/chat-message/actorRoll.mjs b/module/data/chat-message/actorRoll.mjs index eaa1cdc2..ccfe25ea 100644 --- a/module/data/chat-message/actorRoll.mjs +++ b/module/data/chat-message/actorRoll.mjs @@ -1,3 +1,5 @@ +import { triggerChatRollFx } from '../../helpers/utils.mjs'; + const fields = foundry.data.fields; const targetsField = () => @@ -130,6 +132,35 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel { }); } + /* TODO: Change how damage data is stored somehow to enable better rerolling */ + async getRerolledDamage() { + if (!this.damage) return; + + const rerolls = []; + const update = { system: { damage: {} } }; + for (const partKey in this.damage) { + const part = this.damage[partKey]; + const testRoll = Roll.fromData(part.parts[0].roll); + const rerolled = await testRoll.reroll(); + rerolls.push(rerolled); + + if (!update.system.damage[partKey]) update.system.damage[partKey] = { parts: [part.parts[0]] }; + const partData = update.system.damage[partKey].parts[0]; + update.system.damage[partKey].total = rerolled.total; + partData.modifierTotal = rerolled.terms.reduce((acc, x) => { + if (x.isDeterministic && !x.operator) acc += x.total; + return acc; + }, 0); + partData.dice = rerolled.dice.map(d => ({ ...d.toJSON(), dice: d.denomination })); + partData.total = rerolled.total; + partData.roll = rerolled.toJSON(); + } + + await triggerChatRollFx(rerolls); + + return update; + } + registerTargetHook() { if (!this.parent.isAuthor || !this.hasTarget) return; if (this.targetMode && this.parent.targetHook !== null) { diff --git a/module/data/fields/action/damageField.mjs b/module/data/fields/action/damageField.mjs index 30a5ad7c..9b21d3ba 100644 --- a/module/data/fields/action/damageField.mjs +++ b/module/data/fields/action/damageField.mjs @@ -72,9 +72,6 @@ export default class DamageField extends fields.SchemaField { damageConfig.source.message = messageId; damageConfig.directDamage = !!damageConfig.source?.message; - // if(damageConfig.source?.message && game.modules.get('dice-so-nice')?.active) - // await game.dice3d.waitFor3DAnimationByMessageID(damageConfig.source.message); - const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig); if (!damageResult) return false; if (damageResult.actionChatMessageHandled) config.actionChatMessageHandled = true; diff --git a/module/data/fields/action/summonField.mjs b/module/data/fields/action/summonField.mjs index ec7881f7..a2275fa5 100644 --- a/module/data/fields/action/summonField.mjs +++ b/module/data/fields/action/summonField.mjs @@ -1,4 +1,4 @@ -import { itemAbleRollParse } from '../../../helpers/utils.mjs'; +import { itemAbleRollParse, triggerChatRollFx } from '../../../helpers/utils.mjs'; import FormulaField from '../formulaField.mjs'; const fields = foundry.data.fields; @@ -40,7 +40,7 @@ export default class DHSummonField extends fields.ArrayField { const roll = new Roll(itemAbleRollParse(summon.count, this.actor, this.item)); await roll.evaluate(); const count = roll.total; - if (!roll.isDeterministic && game.modules.get('dice-so-nice')?.active) rolls.push(roll); + if (!roll.isDeterministic) rolls.push(roll); const actor = await DHSummonField.getWorldActor(await foundry.utils.fromUuid(summon.actorUUID)); /* Extending summon data in memory so it's available in actionField.toChat. Think it's harmless, but ugly. Could maybe find a better way. */ @@ -56,7 +56,7 @@ export default class DHSummonField extends fields.ArrayField { } } - if (rolls.length) await Promise.all(rolls.map(roll => game.dice3d.showForRoll(roll, game.user, true))); + if (rolls.length) await triggerChatRollFx(rolls); this.actor.sheet?.minimize(); DHSummonField.handleSummon(summonData, this.actor); diff --git a/module/dice/d20Roll.mjs b/module/dice/d20Roll.mjs index 509f5d69..b1d3bd0b 100644 --- a/module/dice/d20Roll.mjs +++ b/module/dice/d20Roll.mjs @@ -1,4 +1,5 @@ import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs'; +import { triggerChatRollFx } from '../helpers/utils.mjs'; import DHRoll from './dhRoll.mjs'; export default class D20Roll extends DHRoll { @@ -224,4 +225,15 @@ export default class D20Roll extends DHRoll { resetFormula() { return (this._formula = this.constructor.getFormula(this.terms)); } + + async reroll(options) { + const result = await super.reroll(options); + if (this instanceof game.system.api.dice.DualityRoll) return result; + + if (options?.liveRoll) { + await triggerChatRollFx([result]); + } + + return result; + } } diff --git a/module/dice/damageRoll.mjs b/module/dice/damageRoll.mjs index 98fd8401..ef810ed7 100644 --- a/module/dice/damageRoll.mjs +++ b/module/dice/damageRoll.mjs @@ -1,5 +1,5 @@ import DamageDialog from '../applications/dialogs/damageDialog.mjs'; -import { parseRallyDice } from '../helpers/utils.mjs'; +import { parseRallyDice, triggerChatRollFx } from '../helpers/utils.mjs'; import DHRoll from './dhRoll.mjs'; export default class DamageRoll extends DHRoll { @@ -18,7 +18,12 @@ export default class DamageRoll extends DHRoll { if (config.evaluate !== false) for (const roll of config.roll) await roll.roll.evaluate(); roll._evaluated = true; - const parts = config.roll.map(r => this.postEvaluate(r)); + + const parts = []; + for (const roll of config.roll) { + parts.push(this.postEvaluate(roll)); + roll.roll = JSON.stringify(roll.roll.toJSON()); + } config.damage = this.unifyDamageRoll(parts); } @@ -38,25 +43,24 @@ export default class DamageRoll extends DHRoll { const chatMessage = config.source?.message ? ui.chat.collection.get(config.source.message) : getDocumentClass('ChatMessage').applyMode({}, config.rollMode ?? 'public'); + + const diceRolls = []; if (game.modules.get('dice-so-nice')?.active) { - const pool = foundry.dice.terms.PoolTerm.fromRolls( - Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll)) - ), - diceRoll = Roll.fromTerms([pool]); - await game.dice3d.showForRoll( - diceRoll, - game.user, - true, - chatMessage.whisper?.length > 0 ? chatMessage.whisper : null, - chatMessage.blind - ); config.mute = true; + const pool = foundry.dice.terms.PoolTerm.fromRolls( + Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll)) + ); + diceRolls.push(Roll.fromTerms([pool])); } + + await triggerChatRollFx(diceRolls, { + whisper: chatMessage.whisper?.length > 0 ? chatMessage.whisper : null, + blind: chatMessage.blind + }); await super.buildPost(roll, config, message); + if (config.source?.message) { chatMessage.update({ 'system.damage': config.damage }); - - if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice }); } } @@ -319,9 +323,10 @@ export default class DamageRoll extends DHRoll { const newIndex = parsedDiceTerms[dice].results.length; await term.reroll(`/r1=${termResult.result}`); + const diceRolls = []; if (game.modules.get('dice-so-nice')?.active) { const newResult = parsedDiceTerms[dice].results[newIndex]; - const diceSoNiceRoll = { + diceRolls.push({ _evaluated: true, dice: [ new foundry.dice.terms.Die({ @@ -332,11 +337,10 @@ export default class DamageRoll extends DHRoll { }) ], options: { appearance: {} } - }; - - await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true); + }); } + await triggerChatRollFx(diceRolls); await parsedRoll.evaluate(); const results = parsedRoll.dice[dice].results.map(result => ({ diff --git a/module/dice/dhRoll.mjs b/module/dice/dhRoll.mjs index d6975f71..02c4ab24 100644 --- a/module/dice/dhRoll.mjs +++ b/module/dice/dhRoll.mjs @@ -1,4 +1,5 @@ import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs'; +import { triggerChatRollFx } from '../helpers/utils.mjs'; export default class DHRoll extends Roll { baseTerms = []; @@ -75,9 +76,7 @@ export default class DHRoll extends Roll { } if (config.skips?.createMessage) { - if (game.modules.get('dice-so-nice')?.active) { - await game.dice3d.showForRoll(roll, game.user, true); - } + await triggerChatRollFx([roll]); } else if (!config.source?.message) { config.message = await this.toMessage(roll, config); } @@ -85,6 +84,7 @@ export default class DHRoll extends Roll { static postEvaluate(roll, config = {}) { return { + ...roll.options.roll, total: roll.total, formula: roll.formula, dice: roll.dice.map(d => ({ diff --git a/module/dice/die/dualityDie.mjs b/module/dice/die/dualityDie.mjs index 83229425..cc7ee75e 100644 --- a/module/dice/die/dualityDie.mjs +++ b/module/dice/die/dualityDie.mjs @@ -1,4 +1,4 @@ -import { ResourceUpdateMap } from '../../data/action/baseAction.mjs'; +import { updateResourcesForDualityReroll } from '../helpers.mjs'; export default class DualityDie extends foundry.dice.terms.Die { constructor(options) { @@ -12,24 +12,6 @@ export default class DualityDie extends foundry.dice.terms.Die { return roll.withHope ? 1 : roll.withFear ? -1 : 0; } - #updateResources(oldDuality, newDuality, actor) { - const { hopeFear } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation); - if (game.user.isGM ? !hopeFear.gm : !hopeFear.players) return; - - const updates = []; - const hope = (newDuality >= 0 ? 1 : 0) - (oldDuality >= 0 ? 1 : 0); - const stress = (newDuality === 0 ? 1 : 0) - (oldDuality === 0 ? 1 : 0); - const fear = (newDuality === -1 ? 1 : 0) - (oldDuality === -1 ? 1 : 0); - - if (hope !== 0) updates.push({ key: 'hope', value: hope, total: -1 * hope, enabled: true }); - if (stress !== 0) updates.push({ key: 'stress', value: -1 * stress, total: stress, enabled: true }); - if (fear !== 0) updates.push({ key: 'fear', value: fear, total: -1 * fear, enabled: true }); - - const resourceUpdates = new ResourceUpdateMap(actor); - resourceUpdates.addResources(updates); - resourceUpdates.updateResources(); - } - async reroll(modifier, options) { const oldDuality = this.#getDualityState(options.liveRoll.roll); await super.reroll(modifier, options); @@ -57,7 +39,7 @@ export default class DualityDie extends foundry.dice.terms.Die { if (options.liveRoll.isReaction) return; const newDuality = this.#getDualityState(options.liveRoll.roll); - this.#updateResources(oldDuality, newDuality, options.liveRoll.actor); + updateResourcesForDualityReroll(oldDuality, newDuality, options.liveRoll.actor); } } diff --git a/module/dice/dualityRoll.mjs b/module/dice/dualityRoll.mjs index d58811fe..f40e9781 100644 --- a/module/dice/dualityRoll.mjs +++ b/module/dice/dualityRoll.mjs @@ -1,6 +1,8 @@ import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs'; import D20Roll from './d20Roll.mjs'; import { parseRallyDice, setDiceSoNiceForDualityRoll } from '../helpers/utils.mjs'; +import { getDiceSoNicePresets } from '../config/generalConfig.mjs'; +import { updateResourcesForDualityReroll } from './helpers.mjs'; export default class DualityRoll extends D20Roll { _advantageNumber = 1; @@ -130,13 +132,7 @@ export default class DualityRoll extends D20Roll { } createBaseDice() { - if ( - this.dice[0] instanceof game.system.api.dice.diceTypes.HopeDie && - this.dice[1] instanceof game.system.api.dice.diceTypes.FearDie - ) { - this.terms = [this.terms[0], this.terms[1], this.terms[2]]; - return; - } + this.terms = [this.terms[0], this.terms[1], this.terms[2]]; this.terms[0] = new game.system.api.dice.diceTypes.HopeDie({ faces: this.data.rules.dualityRoll?.defaultHopeDice ?? 12 @@ -388,4 +384,40 @@ export default class DualityRoll extends D20Roll { if (currentCombatant?.actorId == config.data.id) ui.combat.setCombatantSpotlight(currentCombatant.id); } } + + async reroll(options) { + const oldDuality = this.withHope ? 1 : this.withFear ? -1 : 0; + const rerolled = DualityRoll.fromData((await super.reroll(options)).toJSON()); + + if (options?.liveRoll) { + if (game.modules.get('dice-so-nice')?.active) { + const diceAppearance = await getDiceSoNicePresets( + rerolled, + rerolled.dHope.denomination, + rerolled.dFear.denomination + ); + rerolled.dHope.options.appearance = diceAppearance.hope.appearance; + rerolled.dFear.options.appearance = diceAppearance.fear.appearance; + if (rerolled.dAdvantage) rerolled.dAdvantage.options.appearance = diceAppearance.advantage.appearance; + if (rerolled.dDisadvantage) + rerolled.dDisadvantage.options.appearance = diceAppearance.disadvantage.appearance; + + await game.dice3d.showForRoll(rerolled, game.user, true); + } else { + foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice }); + } + + if (this.options.actionType === 'reaction') return; + + const newDuality = rerolled.withHope ? 1 : rerolled.withFear ? -1 : 0; + const actor = await foundry.utils.fromUuid(this.options.source.actor); + updateResourcesForDualityReroll(oldDuality, newDuality, actor); + } + + return rerolled; + } + + fromJSON(json) { + return super.fromJSON(json); + } } diff --git a/module/dice/helpers.mjs b/module/dice/helpers.mjs new file mode 100644 index 00000000..33519949 --- /dev/null +++ b/module/dice/helpers.mjs @@ -0,0 +1,17 @@ +export function updateResourcesForDualityReroll(oldDuality, newDuality, actor) { + const { hopeFear } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation); + if (game.user.isGM ? !hopeFear.gm : !hopeFear.players) return; + + const updates = []; + const hope = (newDuality >= 0 ? 1 : 0) - (oldDuality >= 0 ? 1 : 0); + const stress = (newDuality === 0 ? 1 : 0) - (oldDuality === 0 ? 1 : 0); + const fear = (newDuality === -1 ? 1 : 0) - (oldDuality === -1 ? 1 : 0); + + if (hope !== 0) updates.push({ key: 'hope', value: hope, total: -1 * hope, enabled: true }); + if (stress !== 0) updates.push({ key: 'stress', value: -1 * stress, total: stress, enabled: true }); + if (fear !== 0) updates.push({ key: 'fear', value: fear, total: -1 * fear, enabled: true }); + + const resourceUpdates = new ResourceUpdateMap(actor); + resourceUpdates.addResources(updates); + resourceUpdates.updateResources(); +} diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs index c9ee772e..2f20175b 100644 --- a/module/helpers/utils.mjs +++ b/module/helpers/utils.mjs @@ -904,3 +904,17 @@ export async function fromUuids(uuids) { return entries.map(e => e.value); } +/** + * Triggers DiceSoNice rolls or dice roll audio for rolls. Not used for duality rolls. + * @param { Roll[] } rolls + * @return { void } + */ +export async function triggerChatRollFx(rolls, options = { whisper: false, blind: false }) { + const { whisper, blind } = options; + if (game.modules.get('dice-so-nice')?.active) { + const rerollPromises = rolls.map(roll => game.dice3d.showForRoll(roll, game.user, true, whisper, blind)); + await Promise.allSettled(rerollPromises); + } else { + foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice }); + } +} diff --git a/styles/less/dialog/index.less b/styles/less/dialog/index.less index 11d9635e..e8f61318 100644 --- a/styles/less/dialog/index.less +++ b/styles/less/dialog/index.less @@ -24,8 +24,6 @@ @import './multiclass-choice/sheet.less'; -@import './reroll-dialog/sheet.less'; - @import './tag-team-dialog/initialization.less'; @import './tag-team-dialog/sheet.less'; diff --git a/styles/less/dialog/reroll-dialog/sheet.less b/styles/less/dialog/reroll-dialog/sheet.less deleted file mode 100644 index 71c94d80..00000000 --- a/styles/less/dialog/reroll-dialog/sheet.less +++ /dev/null @@ -1,125 +0,0 @@ -.daggerheart.dialog.dh-style.views.reroll-dialog { - .window-content { - max-width: 648px; - } - - .reroll-outer-container { - h2 { - margin: 0; - } - - .dices-container { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .dice-outer-container { - width: 300px; - - legend { - display: flex; - align-items: center; - gap: 4px; - - i { - margin-right: 4px; - } - } - - .dice-container { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; - - .result-container { - position: relative; - aspect-ratio: 1; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.375rem; - opacity: 0.8; - - &.selected { - opacity: 1; - border: 1px solid; - border-radius: 6px; - border-color: light-dark(@dark-blue, @golden); - filter: drop-shadow(0 0 3px @golden); - } - - &:before { - content: ' '; - position: absolute; - width: 100%; - height: 100%; - z-index: -1; - mask: var(--svg-die) no-repeat center; - mask-size: contain; - background: linear-gradient(139.01deg, #efe6d8 3.51%, #372e1f 96.49%); - } - - &.d4:before { - --svg-die: url(../assets/icons/dice/default/d4.svg); - } - &.d6:before { - --svg-die: url(../assets/icons/dice/default/d6.svg); - } - &.d8:before { - --svg-die: url(../assets/icons/dice/default/d8.svg); - } - &.d10:before { - --svg-die: url(../assets/icons/dice/default/d10.svg); - } - &.d12:before { - --svg-die: url('../assets/icons/dice/default/d12.svg'); - } - &.d20:before { - --svg-die: url(../assets/icons/dice/default/d20.svg); - } - - .to-reroll-result { - position: absolute; - bottom: -7px; - gap: 2px; - border: 1px solid; - border-radius: 6px; - background-image: url(../assets/parchments/dh-parchment-dark.png); - display: flex; - align-items: center; - padding: 2px 6px; - - input { - margin: 0; - height: 12px; - line-height: 0px; - position: relative; - top: 1px; - - &:before, - &:after { - line-height: 12px; - font-size: var(--font-size-12); - } - } - - i { - font-size: var(--font-size-10); - } - } - } - } - } - } - - footer { - margin-top: 8px; - display: flex; - justify-content: space-between; - - .controls { - display: flex; - gap: 8px; - } - } -} diff --git a/styles/less/global/prose-mirror.less b/styles/less/global/prose-mirror.less index 8412235d..e4b1249f 100644 --- a/styles/less/global/prose-mirror.less +++ b/styles/less/global/prose-mirror.less @@ -40,6 +40,11 @@ ul { list-style: disc; } + } + // Fixes centering and makes it not render over scrollbar + &:hover button.toggle:enabled { + display: flex; + right: 12px; } } } diff --git a/styles/less/sheets-settings/adversary-settings/sheet.less b/styles/less/sheets-settings/adversary-settings/sheet.less index b4b0683b..e6eb8d0b 100644 --- a/styles/less/sheets-settings/adversary-settings/sheet.less +++ b/styles/less/sheets-settings/adversary-settings/sheet.less @@ -7,7 +7,7 @@ &.attack.active { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; } .fieldsets-section { diff --git a/templates/dialogs/rerollDialog/damage/main.hbs b/templates/dialogs/rerollDialog/damage/main.hbs deleted file mode 100644 index 5b994bf6..00000000 --- a/templates/dialogs/rerollDialog/damage/main.hbs +++ /dev/null @@ -1,35 +0,0 @@ -
- {{#each damage}} -

{{localize (concat 'DAGGERHEART.CONFIG.HealingType.' @key '.name')}}

- {{#each this}} -
- {{#each this}} -
- - - - {{this.selectedResults}}/{{this.maxSelected}} Selected - - -
- {{#each this.results}} -
- {{this.result}} - {{#if this.active}} - - - - - {{/if}} -
- {{/each}} -
-
- {{/each}} -
- {{/each}} - {{/each}} -
\ No newline at end of file diff --git a/templates/dialogs/rerollDialog/footer.hbs b/templates/dialogs/rerollDialog/footer.hbs deleted file mode 100644 index 5d4ae2b2..00000000 --- a/templates/dialogs/rerollDialog/footer.hbs +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/templates/dialogs/rerollDialog/main.hbs b/templates/dialogs/rerollDialog/main.hbs deleted file mode 100644 index 6f10ce33..00000000 --- a/templates/dialogs/rerollDialog/main.hbs +++ /dev/null @@ -1,35 +0,0 @@ -
- {{#each damage}} -

{{localize (concat 'DAGGERHEART.CONFIG.HealingType.' @key '.name')}}

- {{#each this}} -
- {{#each this}} -
- - - - {{this.selectedResults}}/{{this.results.length}} Selected - - -
- {{#each this.results}} -
- {{this.result}} - {{#if this.active}} - - - - - {{/if}} -
- {{/each}} -
-
- {{/each}} -
- {{/each}} - {{/each}} -
\ No newline at end of file diff --git a/templates/sheets-settings/adversary-settings/details.hbs b/templates/sheets-settings/adversary-settings/details.hbs index dc2fd386..3160fbb9 100644 --- a/templates/sheets-settings/adversary-settings/details.hbs +++ b/templates/sheets-settings/adversary-settings/details.hbs @@ -18,6 +18,12 @@ {{formField systemFields.motivesAndTactics value=document._source.system.motivesAndTactics label=(localize "DAGGERHEART.ACTORS.Adversary.FIELDS.motivesAndTactics.label")}} +
+ {{localize "DAGGERHEART.GENERAL.DamageThresholds.title"}} + {{formGroup systemFields.damageThresholds.fields.major value=document._source.system.damageThresholds.major label=(localize "DAGGERHEART.GENERAL.DamageThresholds.majorThreshold")}} + {{formGroup systemFields.damageThresholds.fields.severe value=document._source.system.damageThresholds.severe label=(localize "DAGGERHEART.GENERAL.DamageThresholds.severeThreshold")}} +
+
{{localize "DAGGERHEART.GENERAL.Resource.plural"}} @@ -26,10 +32,4 @@ {{/each}}
- -
- {{localize "DAGGERHEART.GENERAL.DamageThresholds.title"}} - {{formGroup systemFields.damageThresholds.fields.major value=document._source.system.damageThresholds.major label=(localize "DAGGERHEART.GENERAL.DamageThresholds.majorThreshold")}} - {{formGroup systemFields.damageThresholds.fields.severe value=document._source.system.damageThresholds.severe label=(localize "DAGGERHEART.GENERAL.DamageThresholds.severeThreshold")}} -